defmodule Image.Options.Write do @moduledoc """ Options and option validation for `Image.write/3`. """ # Map the keyword option to the # Vix option. alias Image.Color import Color, only: [is_inbuilt_profile: 1, is_color: 1] @typedoc "Options for writing an image to a file with `Image.write/2`." @type image_write_options :: [ {:quality, 1..100} | {:background, Image.pixel()} | stream_write_option() | jpeg_write_option() | png_write_option() | tiff_write_option() | webp_write_option() | heif_write_option() | gif_write_option() ] @typedoc "Options for writing an image stream" @type stream_write_option :: {:buffer_size, pos_integer() | :unbuffered} | {:suffix, String.t()} @typedoc "Options for writing a jpeg file with `Image.write/2`." @type jpeg_write_option :: {:strip_metadata, boolean()} | {:icc_profile, Path.t()} | {:minimize_file_size, boolean()} @typedoc "Options for writing a png file with `Image.write/2`." @type png_write_option :: {:strip_metadata, boolean()} | {:icc_profile, Path.t()} | {:minimize_file_size, boolean()} | {:compression, 1..9} | {:effort, 1..10} @typedoc "Options for writing a tiff file with `Image.write/2`." @type tiff_write_option :: {:icc_profile, Path.t()} @typedoc "Options for writing a heif file with `Image.write/2`." @type heif_write_option :: {:compression, heif_compression()} | {:effort, 1..10} | {:minimize_file_size, boolean()} @typedoc "Options for writing a gif file with `Image.write/2`." @type gif_write_option :: {:interframe_maxerror, 0..32} | {:effort, 1..10} @typedoc "Options for writing a webp file with `Image.write/2`." @type webp_write_option :: {:icc_profile, Path.t()} | {:strip_metadata, boolean()} | {:minimize_file_size, boolean()} | {:effort, 1..10} @typedoc "Allowable compression types for heif images." @type heif_compression :: :hevc | :avc | :jpeg | :av1 @doc false defguard is_jpg(image_type) when image_type in [".jpg", ".jpeg"] @doc false defguard is_png(image_type) when image_type in [".png"] @doc false defguard is_webp(image_type) when image_type in [".webp"] @doc false defguard is_tiff(image_type) when image_type in [".tiff", ".tif"] @doc false defguard is_heif(image_type) when image_type in [".heif", ".heic", ".avif"] @doc false defguard is_gif(image_type) when image_type in [".gif", ".GIF"] @suffix_map %{ ".jpg" => :jpg, ".jpeg" => :jpg, ".png" => :png, ".tif" => :tif, ".tiff" => :tif, ".heif" => :heif, ".heic" => :heif, ".avif" => :heif, ".gif" => :gif, ".webp" => :webp } @suffix_keys Map.keys(@suffix_map) @suffix_values Map.values(@suffix_map) |> Enum.uniq() def validate_options(options, :require_suffix) when is_list(options) do case Keyword.fetch(options, :suffix) do {:ok, _options} -> validate_options("", options) _other -> {:error, "The option :suffix must be provided. Example: suffix: \".jpg\""} end end def validate_options(path, options) when is_binary(path) and is_list(options) do with {:ok, image_type} <- path |> Path.extname() |> image_type_from(options[:suffix]), {:ok, options} <- merge_image_type_options(options, image_type) do case Enum.reduce_while(options, options, &validate_option(&1, &2, image_type)) do {:error, value} -> {:error, value} options -> {:ok, options} end end end defp validate_option({:suffix, "." <> _suffix}, options, _image_type) do {:cont, options} end # :quality for png files is ignored, there's no practical setting # that adjust quality in the same way as other formats. defp validate_option({:quality, quality}, options, image_type) when is_png(image_type) and is_integer(quality) and quality in 1..100 do options = Keyword.delete(options, :quality) {:cont, options} end defp validate_option({:quality, quality}, options, _image_type) when is_integer(quality) and quality in 1..100 do options = options |> Keyword.delete(:quality) |> Keyword.put(:Q, quality) {:cont, options} end defp validate_option({:buffer_size, buffer_size}, options, _image_type) when (is_integer(buffer_size) and buffer_size >= 0) or buffer_size == :unbuffered do {:cont, options} end defp validate_option({:strip_metadata, strip?}, options, _image_type) when is_boolean(strip?) do options = options |> Keyword.delete(:strip_metadata) |> Keyword.put(:strip, strip?) {:cont, options} end defp validate_option({:progressive, progressive?}, options, _image_type) when is_boolean(progressive?) do options = options |> Keyword.delete(:progressive) |> Keyword.put(:interlace, progressive?) {:cont, options} end defp validate_option({:compression, compression}, options, image_type) when is_png(image_type) and compression in 1..9 do {:cont, options} end # From: https://www.libvips.org/API/current/VipsForeignSave.html#vips-jpegsave # Applies only to jpeg save # For maximum compression with mozjpeg, a useful set of options is # strip, optimize-coding, interlace, optimize-scans, trellis-quant, quant_table=3. defp validate_option({:minimize_file_size, true}, options, image_type) when is_jpg(image_type) do options = options |> Keyword.delete(:minimize_file_size) |> Keyword.put(:strip, true) |> Keyword.put(:"optimize-coding", true) |> Keyword.put(:interlace, true) |> Keyword.put(:"optimize-scans", true) |> Keyword.put(:"trellis-quant", true) |> Keyword.put(:"quant-table", 3) {:cont, options} end # Quantize a png image defp validate_option({:minimize_file_size, true}, options, image_type) when is_png(image_type) do options = options |> Keyword.delete(:minimize_file_size) |> Keyword.put(:strip, true) |> Keyword.put(:palette, true) {:cont, options} end # For webp, apply min-size, strip, and mixed (allow mixed encoding which might reduce file size) defp validate_option({:minimize_file_size, true}, options, image_type) when is_webp(image_type) do options = options |> Keyword.delete(:minimize_file_size) |> Keyword.put(:"min-size", true) |> Keyword.put(:strip, true) |> Keyword.put(:mixed, true) {:cont, options} end # For webp, apply min-size, strip, and mixed (allow mixed encoding which might reduce file size) defp validate_option({:minimize_file_size, true}, options, image_type) when is_heif(image_type) do options = options |> Keyword.delete(:minimize_file_size) |> Keyword.put(:strip, true) {:cont, options} end defp validate_option({:minimize_file_size, false}, options, image_type) when is_png(image_type) or is_jpg(image_type) or is_webp(image_type) do options = options |> Keyword.delete(:minimize_file_size) {:cont, options} end defp validate_option({:icc_profile, profile}, options, _image_type) when is_inbuilt_profile(profile) or is_binary(profile) do options = options |> Keyword.delete(:icc_profile) |> Keyword.put(:profile, to_string(profile)) if Color.known_icc_profile?(profile) do {:cont, options} else {:halt, {:error, "The color profile #{inspect(profile)} is not known"}} end end defp validate_option({:background, background}, options, _image_type) when is_color(background) do {:cont, options} end defp validate_option({:effort, effort}, options, image_type) when is_integer(effort) and effort in 1..10 and not is_jpg(image_type) and not is_tiff(image_type) do options = Keyword.put(options, :effort, conform_effort(effort, image_type)) {:cont, options} end defp validate_option({:interframe_maxerror, int_max_error}, options, image_type) when is_gif(image_type) and int_max_error in 0..32 do {:cont, options} end defp validate_option(option, _options, image_type) do {:halt, {:error, invalid_option(option, image_type)}} end defp invalid_option(option, image_type) do "Invalid option or option value: #{inspect(option)} for image type #{inspect(image_type)}" end defp merge_image_type_options(options, suffix) when suffix in @suffix_keys do suffix_option = Map.fetch!(@suffix_map, suffix) {format_opts, options} = Keyword.pop(options, suffix_option, []) options = delete_all_type_options(options) {:ok, Keyword.merge(options, format_opts)} end defp merge_image_type_options(_options, suffix) do {:error, "Unknown image type #{inspect(suffix)}"} end defp delete_all_type_options(options) do Enum.reduce(@suffix_values, options, &Keyword.delete(&2, &1)) end # Range 1..10 defp conform_effort(effort, ".png"), do: effort # Range 0..9 defp conform_effort(effort, image_type) when is_heif(image_type), do: effort - 1 # Range 0..6 defp conform_effort(effort, ".webp"), do: round(effort / 10 * 6) defp image_type_from("", "") do {:error, "Cannot determine image type"} end defp image_type_from("", suffix) do {:ok, String.downcase(suffix)} end defp image_type_from(extname, _suffix) do {:ok, String.downcase(extname)} end end