Skip to main content

lib/image/plug/provider/cloudinary/options.ex

defmodule Image.Plug.Provider.Cloudinary.Options do
  @moduledoc """
  Parses a [Cloudinary transform string](https://cloudinary.com/documentation/transformation_reference)
  into a canonical `Image.Plug.Pipeline`.

  Cloudinary's transform vocabulary is large (~100+ keys). v0.1
  implements the common subset that maps cleanly onto the canonical
  IR:

  * Sizing: `w_`, `h_`, `dpr_`, `c_` (crop mode), `g_` (gravity),
    `x_` / `y_` (focal point — derived together with `g_xy_center`).

  * Output: `q_`, `f_`.

  * Effects: `b_` (background), `e_blur:`, `e_sharpen:`,
    `e_brightness:`, `e_contrast:`, `e_saturation:`, `e_gamma:`.

  * Geometry: `a_` (rotate, multiples of 90), `e_grayscale` (treated
    as `Adjust{saturation: 0.0}`), `bo_` (border `bo_<W>px_solid_<color>`).

  * Overlays: `l_<public-id>` (single-layer base form).

  Multiple transform stages (`w_200,c_fill/e_blur:300/`) are
  flattened by the URL recogniser into a single comma-joined string;
  this parser then walks them as one set. The canonical IR doesn't
  model chained transforms in v0.1, so order-dependent multi-stage
  recipes (sharpen → resize → sharpen) collapse to last-write-wins.
  This is documented in `guides/cloudinary_conformance.md`.

  Cloudinary-only features that don't fit the canonical IR
  (`e_vignette`, `e_pixelate`, `e_cartoonify`, `e_replace_color`,
  `e_fade`, `cs_srgb`, named transformations `t_<name>`) raise
  `:unsupported_option` so users learn early.
  """

  alias Image.Plug.{Error, Pipeline}
  alias Image.Plug.Pipeline.Ops

  require Logger

  # Cloudinary `c_` modes → canonical fit + (optional) gravity hint.
  # crop=:crop is preserved as a fit value distinct from :cover so
  # the interpreter can choose absolute-pixel cropping when both
  # width AND height are given.
  @c_to_fit %{
    "scale" => :squeeze,
    "fit" => :contain,
    "limit" => :scale_down,
    "mfit" => :contain,
    "fill" => :cover,
    "lfill" => :cover,
    "crop" => :crop,
    "thumb" => :cover,
    "pad" => :pad,
    "lpad" => :pad,
    "mpad" => :pad,
    "fill_pad" => :pad,
    "imagga_crop" => :cover,
    "imagga_scale" => :squeeze
  }

  @g_to_gravity %{
    "north" => :north,
    "north_east" => :north_east,
    "north_west" => :north_west,
    "south" => :south,
    "south_east" => :south_east,
    "south_west" => :south_west,
    "east" => :east,
    "west" => :west,
    "center" => :center,
    "centre" => :center,
    "face" => :face,
    "faces" => :face,
    "auto" => :auto,
    "auto:subject" => :auto,
    "auto:classic" => :auto,
    "xy_center" => :focalpoint
  }

  @f_to_atom %{
    "jpg" => :jpeg,
    "jpe" => :jpeg,
    "jpeg" => :jpeg,
    "png" => :png,
    "webp" => :webp,
    "avif" => :avif,
    "auto" => :auto
  }

  # Cloudinary's `cs_<value>` accepts a small set of named
  # colorspaces. Map them to the atoms `Image.to_colorspace/2`
  # accepts. `tinysrgb` is Cloudinary-specific (size-optimised
  # sRGB); we map to plain `:srgb` since the tinification is a
  # Cloudinary product layer, not a colorspace.
  @cs_to_target %{
    "srgb" => :srgb,
    "tinysrgb" => :srgb,
    "no_cmyk" => :srgb,
    "cmyk" => :cmyk
  }

  @unsupported_effects %{
    "redeye" => "cloudinary `e_redeye` is not implemented"
  }

  # Cloudinary CSS colour name forms accept underscores between
  # multi-word names (e.g. `misty_rose`) and lowercase hex without a
  # leading `#`. We pass both through verbatim — the Image library's
  # `Image.replace_color/2` accepts hex strings (`"#abcdef"`), CSS
  # names (string or atom), 0..255 integers, and RGB lists.
  defp normalise_replace_color_value("rgb:" <> hex), do: "#" <> hex

  defp normalise_replace_color_value(value) when is_binary(value) do
    cond do
      hex_color?(value) -> "#" <> value
      true -> value
    end
  end

  defp hex_color?(value) when byte_size(value) in [3, 6, 8] do
    String.match?(value, ~r/^[0-9a-fA-F]+$/)
  end

  defp hex_color?(_value), do: false

  # Re-join consecutive `["rgb", "<hex>"]` pairs that an outer
  # colon-split has separated. `["rgb", "abcdef", "50", "rgb", "fedcba"]`
  # → `["rgb:abcdef", "50", "rgb:fedcba"]`.
  defp glue_rgb([]), do: []

  defp glue_rgb(["rgb", hex | rest]) do
    ["rgb:" <> hex | glue_rgb(rest)]
  end

  defp glue_rgb([head | rest]) do
    [head | glue_rgb(rest)]
  end

  @unsupported_keys %{
    "t" =>
      "cloudinary named transformations (`t_<name>`) are server-side aliases not modelled by the IR",
    "u" => "cloudinary underlay `u_` is not implemented in v0.1",
    "if" => "cloudinary conditional transforms (`if_...`) are not implemented in v0.1",
    "vc" => "cloudinary video codec `vc_` is video-only",
    "ac" => "cloudinary audio codec `ac_` is video-only",
    "br" => "cloudinary bitrate `br_` is video-only"
  }

  @doc """
  Parses a Cloudinary transform string into an `Image.Plug.Pipeline`.

  ### Arguments

  * `transform_string` — the comma-joined transform string emitted
    by `Image.Plug.Provider.Cloudinary.URL` (multiple stages are
    pre-flattened to one comma-separated list).

  * `parser_options` — keyword list. `:strict?` (default `true`)
    controls whether unknown keys raise.

  ### Returns

  * `{:ok, pipeline}` on success.

  * `{:error, %Image.Plug.Error{}}` on the first malformed entry.

  ### Examples

      iex> alias Image.Plug.Provider.Cloudinary.Options
      iex> {:ok, pipeline} = Options.parse("w_200,c_fill,f_webp,q_80")
      iex> [resize] = pipeline.ops
      iex> {resize.width, resize.fit}
      {200, :cover}
      iex> pipeline.output.type
      :webp

  """
  @spec parse(String.t(), keyword()) :: {:ok, Pipeline.t()} | {:error, Error.t()}
  def parse(transform_string, parser_options \\ []) when is_binary(transform_string) do
    strict? = Keyword.get(parser_options, :strict?, true)

    transform_string
    |> split_entries()
    |> reduce_entries(strict?)
  end

  defp split_entries(""), do: []

  defp split_entries(string) do
    string
    |> String.split(",", trim: true)
    |> Enum.map(&parse_entry/1)
  end

  # An entry is `<prefix>_<value>` where prefix is a short letter
  # token. `e_<name>[:<value>]` is the only entry that can carry an
  # internal colon.
  defp parse_entry(entry) do
    case String.split(entry, "_", parts: 2) do
      [prefix, value] -> {prefix, value}
      [prefix] -> {prefix, ""}
    end
  end

  defp reduce_entries(entries, strict?) do
    initial =
      {:ok,
       %{
         resize: nil,
         adjust: nil,
         output: %Ops.Format{},
         draw_layer: nil,
         appended: [],
         focal_point: %{x: nil, y: nil}
       }}

    entries
    |> Enum.reduce_while(initial, fn {key, value}, {:ok, acc} ->
      case apply_entry(key, value, acc, strict?) do
        {:ok, acc} -> {:cont, {:ok, acc}}
        {:error, _} = error -> {:halt, error}
      end
    end)
    |> finalise()
  end

  defp finalise({:error, _} = error), do: error

  defp finalise({:ok, acc}) do
    pipeline =
      Pipeline.new(provider: Image.Plug.Provider.Cloudinary)
      |> Pipeline.put_output(acc.output)
      |> append_in_canonical_order(apply_focal_point(acc))

    {:ok, pipeline}
  end

  defp apply_focal_point(
         %{resize: %Ops.Resize{gravity: :focalpoint} = resize, focal_point: fp} = acc
       )
       when not is_nil(fp.x) and not is_nil(fp.y) do
    %{acc | resize: %{resize | gravity: {:xy, fp.x, fp.y}}}
  end

  defp apply_focal_point(%{resize: %Ops.Resize{gravity: :focalpoint} = resize} = acc) do
    %{acc | resize: %{resize | gravity: :center}}
  end

  defp apply_focal_point(acc), do: acc

  defp append_in_canonical_order(pipeline, acc) do
    [
      find_one(acc.appended, Ops.Rotate),
      find_one(acc.appended, Ops.Flip),
      acc.resize,
      find_one(acc.appended, Ops.Background),
      find_one(acc.appended, Ops.Border),
      acc.adjust,
      find_one(acc.appended, Ops.Enhance),
      find_one(acc.appended, Ops.Colorspace),
      find_one(acc.appended, Ops.Sepia),
      find_one(acc.appended, Ops.Tint),
      find_one(acc.appended, Ops.ReplaceColor),
      find_one(acc.appended, Ops.Posterize),
      find_one(acc.appended, Ops.Pixelate),
      find_one(acc.appended, Ops.PixelateFaces),
      find_one(acc.appended, Ops.Sharpen),
      find_one(acc.appended, Ops.Blur),
      find_one(acc.appended, Ops.Vignette),
      find_one(acc.appended, Ops.Fade),
      find_one(acc.appended, Ops.Rounded),
      find_one(acc.appended, Ops.Opacity),
      acc.draw_layer
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.reduce(pipeline, &Pipeline.append(&2, &1))
  end

  defp find_one(appended, module) do
    Enum.find(appended, fn op -> op.__struct__ == module end)
  end

  defp ensure_resize(nil), do: %Ops.Resize{}
  defp ensure_resize(%Ops.Resize{} = resize), do: resize

  defp ensure_adjust(nil), do: %Ops.Adjust{}
  defp ensure_adjust(%Ops.Adjust{} = adjust), do: adjust

  defp replace_or_append(appended, %module{} = op) do
    case Enum.find_index(appended, fn existing -> existing.__struct__ == module end) do
      nil -> appended ++ [op]
      index -> List.replace_at(appended, index, op)
    end
  end

  defp invalid(key, value) do
    Error.new(:invalid_option, "invalid value for cloudinary option",
      details: %{key: key, value: value}
    )
  end

  defp unsupported(key, message) do
    Error.new(:unsupported_option, message, details: %{key: key})
  end

  defp parse_pos_integer(key, value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} when integer > 0 -> {:ok, integer}
      _ -> {:error, invalid(key, value)}
    end
  end

  defp parse_non_neg_integer(key, value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} when integer >= 0 -> {:ok, integer}
      _ -> {:error, invalid(key, value)}
    end
  end

  defp parse_int(key, value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} -> {:ok, integer}
      _ -> {:error, invalid(key, value)}
    end
  end

  # Range-bounded integer parser. Returns `{:ok, n}` or `:error`
  # without wrapping in a typed error so callers can produce
  # context-specific messages.
  defp parse_int_in_range_value(value, range) when is_binary(value) do
    case Integer.parse(value) do
      {n, ""} -> if n in range, do: {:ok, n}, else: :error
      _ -> :error
    end
  end

  defp parse_int_in_range_value(_value, _range), do: :error

  defp parse_unit_float(key, value) when is_binary(value) do
    case Float.parse(value) do
      {float, ""} when float >= 0.0 and float <= 1.0 -> {:ok, float}
      _ -> {:error, invalid(key, value)}
    end
  end

  # ---------- per-key parsers ----------
  # Sizing -----------------------------------------------------------

  defp apply_entry("w", value, acc, _strict?) do
    with {:ok, integer} <- parse_pos_integer("w", value) do
      {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:width, integer)}}
    end
  end

  defp apply_entry("h", value, acc, _strict?) do
    with {:ok, integer} <- parse_pos_integer("h", value) do
      {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:height, integer)}}
    end
  end

  defp apply_entry("dpr", value, acc, _strict?) do
    with {:ok, integer} <- parse_pos_integer("dpr", value) do
      capped = min(integer, 3)
      resize = ensure_resize(acc.resize) |> Map.put(:dpr, capped)
      {:ok, %{acc | resize: resize, output: %{acc.output | dpr: capped}}}
    end
  end

  # `z_<float>` — face-zoom factor, only meaningful with
  # `g_face`. Range `[0.0, 1.0]` matches the IR's
  # `Resize.face_zoom` and the symmetric ImageKit `z-` /
  # Cloudflare `face-zoom=` parsers.
  defp apply_entry("z", value, acc, _strict?) do
    case Float.parse(value) do
      {f, ""} when f >= 0.0 and f <= 1.0 ->
        resize = ensure_resize(acc.resize) |> Map.put(:face_zoom, f)
        {:ok, %{acc | resize: resize}}

      _ ->
        {:error, invalid("z", value)}
    end
  end

  defp apply_entry("c", value, acc, _strict?) do
    case Map.fetch(@c_to_fit, value) do
      {:ok, fit} ->
        {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:fit, fit)}}

      :error ->
        {:error, invalid("c", value)}
    end
  end

  defp apply_entry("g", value, acc, _strict?) do
    case Map.fetch(@g_to_gravity, value) do
      {:ok, gravity} ->
        {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:gravity, gravity)}}

      :error ->
        {:error, invalid("g", value)}
    end
  end

  defp apply_entry("x", value, acc, _strict?) do
    with {:ok, x} <- parse_unit_float("x", value) do
      {:ok, %{acc | focal_point: %{acc.focal_point | x: x}}}
    end
  end

  defp apply_entry("y", value, acc, _strict?) do
    with {:ok, y} <- parse_unit_float("y", value) do
      {:ok, %{acc | focal_point: %{acc.focal_point | y: y}}}
    end
  end

  # Output -----------------------------------------------------------

  defp apply_entry("q", value, acc, _strict?) do
    case value do
      # `q_auto` and `q_auto:eco` etc. are Cloudinary's content-aware
      # quality. The Image library doesn't have an "auto quality"
      # knob; we leave the encoder default (85) in place. The
      # `:compression: :fast` flag is set so the encoder leans on
      # libvips' faster encode path.
      "auto" ->
        {:ok, %{acc | output: %{acc.output | compression: :fast}}}

      "auto:" <> _ ->
        {:ok, %{acc | output: %{acc.output | compression: :fast}}}

      _ ->
        with {:ok, quality} <- parse_pos_integer("q", value) do
          if quality in 1..100 do
            {:ok, %{acc | output: %{acc.output | quality: quality}}}
          else
            {:error, invalid("q", value)}
          end
        end
    end
  end

  defp apply_entry("f", value, acc, _strict?) do
    case Map.fetch(@f_to_atom, value) do
      {:ok, atom} -> {:ok, %{acc | output: %{acc.output | type: atom}}}
      :error -> {:error, invalid("f", value)}
    end
  end

  # Effects ----------------------------------------------------------

  defp apply_entry("b", value, acc, _strict?) when is_binary(value) and value != "" do
    color = normalise_color(value)
    {:ok, %{acc | appended: replace_or_append(acc.appended, %Ops.Background{color: color})}}
  end

  defp apply_entry("e", value, acc, _strict?) when is_binary(value) do
    case String.split(value, ":", parts: 2) do
      [name] -> apply_effect(name, "", acc)
      [name, sub_value] -> apply_effect(name, sub_value, acc)
    end
  end

  # Geometry ---------------------------------------------------------

  defp apply_entry("a", value, acc, _strict?) do
    with {:ok, angle} <- parse_int("a", value) do
      cond do
        rem(angle, 90) == 0 ->
          op = %Ops.Rotate{angle: rem(angle, 360)}
          {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

        true ->
          {:error, invalid("a", value)}
      end
    end
  end

  defp apply_entry("bo", value, acc, _strict?) do
    # bo_<W>px_solid_<rgb:RRGGBB> or bo_<W>px_solid_<color>
    case String.split(value, "_", parts: 3) do
      [width_str, "solid", color] ->
        width_str = String.trim_trailing(width_str, "px")

        with {:ok, width} <- parse_non_neg_integer("bo", width_str) do
          op = %Ops.Border{
            color: normalise_border_color(color),
            top: width,
            right: width,
            bottom: width,
            left: width
          }

          {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
        end

      _ ->
        {:error, invalid("bo", value)}
    end
  end

  defp apply_entry("fl", "force_strip", acc, _strict?) do
    # Strip metadata is implicit in our encoder (we don't carry it).
    # Recognised so users with `fl_force_strip` in their URLs don't
    # get rejected.
    {:ok, acc}
  end

  defp apply_entry("fl", "preserve_transparency", acc, _strict?), do: {:ok, acc}

  defp apply_entry("fl", "progressive", acc, _strict?) do
    {:ok, %{acc | output: %{acc.output | progressive: true}}}
  end

  defp apply_entry("fl", "lossy", acc, _strict?) do
    {:ok, %{acc | output: %{acc.output | lossy: true}}}
  end

  defp apply_entry("fl", value, _acc, _strict?), do: {:error, invalid("fl", value)}

  # Rounded corners ----------------------------------------------------
  #
  # Cloudinary `r_<n>` — n is the corner radius in pixels.
  # `r_max` produces a fully circular / pill-shaped result.
  defp apply_entry("r", "max", acc, _strict?) do
    op = %Ops.Rounded{radius: :max}
    {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
  end

  defp apply_entry("r", value, acc, _strict?) do
    with {:ok, n} <- parse_pos_integer("r", value) do
      op = %Ops.Rounded{radius: n}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  # Mid-pipeline opacity -----------------------------------------------
  #
  # Cloudinary `o_<n>` — n is a 0..100 opacity percentage.
  defp apply_entry("o", value, acc, _strict?) do
    case parse_int_in_range_value(value, 0..100) do
      {:ok, 100} ->
        {:ok, acc}

      {:ok, percent} ->
        op = %Ops.Opacity{factor: percent / 100}
        {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

      :error ->
        {:error, invalid("o", value)}
    end
  end

  # Colorspace -------------------------------------------------------

  defp apply_entry("cs", value, acc, _strict?) do
    case Map.fetch(@cs_to_target, value) do
      {:ok, target} ->
        op = %Ops.Colorspace{target: target}
        {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

      :error ->
        {:error,
         Error.new(
           :unsupported_option,
           "cloudinary `cs_#{value}` not implemented. " <>
             "Custom-ICC profiles (Adobe RGB, ProPhoto, etc.) need an `Ops.IccTransform{}` " <>
             "op constructed programmatically with `profile: \"path/to/profile.icc\"` — " <>
             "the parser does not synthesise these from URL strings.",
           details: %{key: "cs", value: value}
         )}
    end
  end

  # Overlays ---------------------------------------------------------

  defp apply_entry("l", value, acc, _strict?) when is_binary(value) and value != "" do
    # Single-public-id overlay. v0.1 treats it as a relative path
    # against the source resolver.
    case Image.Plug.Source.path("/" <> value) do
      {:ok, source} ->
        layer = %Ops.Draw.Layer{source: source}
        {:ok, %{acc | draw_layer: %Ops.Draw{layers: [layer]}}}

      {:error, _} = error ->
        error
    end
  end

  # Unsupported keys -------------------------------------------------

  defp apply_entry(key, _value, _acc, _strict?) when is_map_key(@unsupported_keys, key) do
    {:error, unsupported(key, Map.fetch!(@unsupported_keys, key))}
  end

  # Unknown keys -----------------------------------------------------

  defp apply_entry(key, _value, acc, false) do
    Logger.debug(fn -> "image_plug: ignoring unknown cloudinary option #{inspect(key)}" end)
    {:ok, acc}
  end

  defp apply_entry(key, value, _acc, true) do
    {:error,
     Error.new(:unknown_option, "unknown cloudinary option key",
       details: %{key: key, value: value}
     )}
  end

  # ---------- effect dispatch ----------

  defp apply_effect("blur", "", acc), do: apply_effect("blur", "100", acc)

  defp apply_effect("blur", value, acc) do
    with {:ok, n} <- parse_non_neg_integer("e_blur", value) do
      sigma = min(n, 2000) / 100.0
      op = %Ops.Blur{sigma: sigma}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  defp apply_effect("sharpen", "", acc), do: apply_effect("sharpen", "100", acc)

  defp apply_effect("sharpen", value, acc) do
    with {:ok, n} <- parse_non_neg_integer("e_sharpen", value) do
      sigma = min(n, 100) / 10.0
      op = %Ops.Sharpen{sigma: sigma}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  defp apply_effect("brightness", value, acc),
    do: adjust_effect(:brightness, value, acc, "e_brightness")

  defp apply_effect("contrast", value, acc),
    do: adjust_effect(:contrast, value, acc, "e_contrast")

  defp apply_effect("saturation", value, acc),
    do: adjust_effect(:saturation, value, acc, "e_saturation")

  defp apply_effect("gamma", value, acc), do: adjust_effect(:gamma, value, acc, "e_gamma")

  defp apply_effect(grayscale, _, acc) when grayscale in ~w(grayscale greyscale) do
    adjust = ensure_adjust(acc.adjust) |> Map.put(:saturation, 0.0)
    {:ok, %{acc | adjust: adjust}}
  end

  # Cloudinary `e_replace_color:<to>[:<tolerance>[:<from>]]`. Default
  # `<from>` is `:auto` (the average of the top-left 10×10 region of
  # the image), default tolerance is 50. Colour values may take the
  # `rgb:RRGGBB` form which contains its own colon — `glue_rgb/1`
  # rejoins those before the per-position split.
  defp apply_effect("replace_color", value, acc) do
    {to, tolerance, from} =
      case glue_rgb(String.split(value, ":")) do
        [to] -> {to, "50", nil}
        [to, tolerance] -> {to, tolerance, nil}
        [to, tolerance, from] -> {to, tolerance, from}
        _ -> {value, "50", nil}
      end

    with {:ok, threshold} <- parse_non_neg_integer("e_replace_color", tolerance) do
      op = %Ops.ReplaceColor{
        to: normalise_replace_color_value(to),
        from: if(is_nil(from), do: :auto, else: normalise_replace_color_value(from)),
        threshold: threshold
      }

      appended =
        case Enum.find_index(acc.appended, fn existing ->
               existing.__struct__ == Ops.ReplaceColor
             end) do
          nil -> acc.appended ++ [op]
          index -> List.replace_at(acc.appended, index, op)
        end

      {:ok, %{acc | appended: appended}}
    end
  end

  # `e_improve` and the `e_auto_*` family map to `Image.enhance/2`,
  # a sensible-defaults stack of luminance equalisation +
  # saturation boost + mild sharpen. Cloudinary's hosted versions
  # are ML-driven; we approximate.
  defp apply_effect(name, _value, acc)
       when name in ~w(improve auto_brightness auto_color auto_contrast) do
    op = %Ops.Enhance{}
    {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
  end

  # `e_vignette[:N]` — N is 0..100 strength percentage. Default
  # is `50` (matches `Image.vignette/2`'s `:strength` default).
  defp apply_effect("vignette", "", acc), do: apply_effect("vignette", "50", acc)

  defp apply_effect("vignette", value, acc) do
    case parse_int_in_range_value(value, 0..100) do
      {:ok, 0} ->
        {:ok, acc}

      {:ok, n} ->
        op = %Ops.Vignette{strength: n / 100}
        {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

      :error ->
        {:error, invalid("e_vignette", value)}
    end
  end

  # `e_sepia[:N]` — N is 0..100 strength percentage. Default
  # full sepia when no value is given.
  defp apply_effect("sepia", "", acc), do: apply_effect("sepia", "100", acc)

  defp apply_effect("sepia", value, acc) do
    case parse_int_in_range_value(value, 0..100) do
      {:ok, 0} ->
        {:ok, acc}

      {:ok, n} ->
        op = %Ops.Sepia{strength: n / 100}
        {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

      :error ->
        {:error, invalid("e_sepia", value)}
    end
  end

  # `e_pixelate[:N]` — N is the block size in pixels. Default
  # block size 5 when no value is given.
  defp apply_effect("pixelate", "", acc), do: apply_effect("pixelate", "5", acc)

  defp apply_effect("pixelate", value, acc) do
    with {:ok, block_size} <- parse_pos_integer("e_pixelate", value) do
      op = %Ops.Pixelate{scale: 1.0 / block_size}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  # `e_pixelate_faces[:N]` — same block-size convention as
  # `e_pixelate`, but only the regions occupied by detected
  # faces are pixelated. Requires the optional `:image_vision`
  # dependency at runtime; without it the interpreter
  # silently no-ops (returns the source image unchanged).
  defp apply_effect("pixelate_faces", "", acc), do: apply_effect("pixelate_faces", "5", acc)

  defp apply_effect("pixelate_faces", value, acc) do
    with {:ok, block_size} <- parse_pos_integer("e_pixelate_faces", value) do
      op = %Ops.PixelateFaces{scale: 1.0 / block_size}
      {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
    end
  end

  # `e_cartoonify[:level_count]` — `level_count` defaults to
  # `5`. We map straight through to `Image.posterize/2`'s
  # tonal-quantisation; Cloudinary's full effect also adds an
  # edge-detect overlay we don't model.
  defp apply_effect("cartoonify", "", acc), do: apply_effect("cartoonify", "5", acc)

  defp apply_effect("cartoonify", value, acc) do
    case parse_int_in_range_value(value, 2..256) do
      {:ok, levels} ->
        op = %Ops.Posterize{levels: levels}
        {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

      :error ->
        {:error, invalid("e_cartoonify", value)}
    end
  end

  # `e_fade[:N]` — N is a 0..100 length percentage applied to
  # the bottom edge by default (matches Cloudinary's documented
  # default). Cloudinary's directional flavours
  # (`e_fade_top` etc.) aren't modelled in v0.1.
  defp apply_effect("fade", "", acc), do: apply_effect("fade", "20", acc)

  defp apply_effect("fade", value, acc) do
    case parse_int_in_range_value(value, 0..100) do
      {:ok, 0} ->
        {:ok, acc}

      {:ok, percent} ->
        op = %Ops.Fade{edges: [:bottom], length: percent / 100}
        {:ok, %{acc | appended: replace_or_append(acc.appended, op)}}

      :error ->
        {:error, invalid("e_fade", value)}
    end
  end

  defp apply_effect(name, _value, _acc) when is_map_key(@unsupported_effects, name) do
    {:error, unsupported("e_#{name}", Map.fetch!(@unsupported_effects, name))}
  end

  defp apply_effect(name, value, _acc) do
    {:error,
     Error.new(:unknown_option, "unknown cloudinary effect",
       details: %{key: "e_#{name}", value: value}
     )}
  end

  defp adjust_effect(field, value, acc, key) do
    with {:ok, integer} <- parse_int(key, value) do
      if integer in -100..100 do
        multiplier = 1.0 + integer / 100.0
        adjust = ensure_adjust(acc.adjust) |> Map.put(field, multiplier)
        {:ok, %{acc | adjust: adjust}}
      else
        {:error, invalid(key, value)}
      end
    end
  end

  # `b_<color>` accepts `rgb:RRGGBB`, `rgb:RRGGBBAA`, `<colorname>`,
  # or `auto:border` etc. We map the common rgb form; everything else
  # is forwarded verbatim to libvips' colour parser via
  # `Image.flatten/2`.
  defp normalise_color("rgb:" <> hex), do: "#" <> hex
  defp normalise_color(other), do: other

  defp normalise_border_color("rgb:" <> hex), do: "#" <> hex
  defp normalise_border_color(other), do: "#" <> other
end