lib/image/options/write.ex

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