defmodule Image.Plug.Provider.Imgix.Options do @moduledoc """ Parses an [imgix](https://docs.imgix.com/en/latest/apis/rendering) query string into a canonical `Image.Plug.Pipeline`. Imgix exposes ~80 documented parameters. v0.1 implements the common subset that maps cleanly onto the canonical IR: * Sizing: `w`, `h`, `dpr`, `fit`, `crop`, `fp-x`, `fp-y`. * Output: `q`, `fm`, `auto` (multi-value `format,compress`). * Effects: `bg`, `blur`, `sharp`, `bri`, `con`, `sat`, `gam`. * Geometry: `flip`, `rot`, `trim`, `trimcolor`, `border`. * Overlays: `mark`, `mark-w`, `mark-h`, `mark-x`, `mark-y`, `mark-fit`, `mark-rot`. * Signing: `s`, `expires` — handled by `Image.Plug.Provider.Imgix.Signing`, not here. Unknown keys raise `:unknown_option` by default; pass `strict?: false` to log and ignore. Custom-ICC colourspaces (`cs=adobergb1998`, `cs=appleRGB`, etc.) return `:unsupported_option` — the parser does not synthesise those from URL strings. Construct an `Ops.IccTransform{profile, intent}` op directly, or wire an application-level alias map. See `guides/imgix_conformance.md` for the per-option matrix. """ alias Image.Plug.{Error, Pipeline} alias Image.Plug.Pipeline.Ops require Logger @signing_keys ~w(s expires) @ignored_keys ~w(ixlib ixid) @fit_atoms %{ "clip" => :contain, "clamp" => :contain, "crop" => :cover, "facearea" => :cover, "fill" => :pad, "fillmax" => :pad, "max" => :scale_down, "min" => :scale_down, "scale" => :squeeze } @crop_to_gravity %{ "top" => :north, "bottom" => :south, "left" => :west, "right" => :east, "top,left" => :north_west, "top,right" => :north_east, "bottom,left" => :south_west, "bottom,right" => :south_east, "faces" => :face, "entropy" => :auto, "edges" => :auto, "focalpoint" => :focalpoint } @fm_atoms %{ "jpg" => :jpeg, "jpeg" => :jpeg, "pjpg" => :baseline_jpeg, "png" => :png, "png8" => :png, "png32" => :png, "webp" => :webp, "avif" => :avif } @flip_atoms %{ "h" => :horizontal, "v" => :vertical, "hv" => :both } # imgix's `cs=` accepts a small set of named colorspaces. # Map them to the atoms `Image.to_colorspace/2` accepts. @cs_to_target %{ "srgb" => :srgb, "strip" => :srgb, "cmyk" => :cmyk, "rgb" => :rgb } @unsupported_auto %{ "redeye" => "imgix `auto=redeye` is not implemented", "true" => "imgix `auto=true` is unspecified; pass an explicit value like `auto=format,compress`" } @doc """ Parses an imgix query string into an `Image.Plug.Pipeline`. ### Arguments * `query_string` — the raw query string (no leading `?`). * `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.Imgix.Options iex> {:ok, pipeline} = Options.parse("w=200&fit=crop&fm=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(query_string, parser_options \\ []) when is_binary(query_string) do strict? = Keyword.get(parser_options, :strict?, true) query_string |> URI.decode_query() |> Map.drop(@signing_keys ++ @ignored_keys) |> reduce_entries(strict?) end defp reduce_entries(params, strict?) do initial = {:ok, %{ resize: nil, adjust: nil, output: %Ops.Format{}, draw_layer: nil, appended: [], focal_point: %{x: nil, y: nil} }} params |> Enum.sort() |> 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.Imgix) |> Pipeline.put_output(acc.output) |> append_in_canonical_order(apply_focal_point(acc)) {:ok, pipeline} end # If a focalpoint crop was requested AND fp-x/fp-y were provided, # rewrite the resize op's gravity to {:xy, fx, fy}. 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 # focalpoint crop without fp-x/fp-y → centre %{acc | resize: %{resize | gravity: :center}} end defp apply_focal_point(acc), do: acc # Order matches the Cloudflare provider's canonical order (which # in turn mirrors Sharp); the normaliser will re-sort as needed. defp append_in_canonical_order(pipeline, acc) do [ find_one(acc.appended, Ops.Orientation), find_one(acc.appended, Ops.Rotate), find_one(acc.appended, Ops.Trim), 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.Posterize), find_one(acc.appended, Ops.Pixelate), 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.DropShadow), 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 imgix 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 # Range-bounded integer parser without the {:error, …} wrapping # used by the existing helpers — callers wrap themselves so they # can vary the error message. 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 # Parses a hex colour into a 3-element `[r, g, b]` 0..255 list. # `Color.new/1` already handles `#abc`, `abc`, `#aabbcc`, # `aabbcc`, and rejects garbage; we just need to scale the # unit-range floats it returns into the 0..255 range stored on # `%Ops.Tint{}` and friends. defp parse_hex_rgb(value) when is_binary(value) do case Color.new(value) do {:ok, %Color.SRGB{r: r, g: g, b: b}} -> {:ok, [round(r * 255), round(g * 255), round(b * 255)]} _ -> :error end end defp parse_hex_rgb(_), do: :error 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 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 -> case Integer.parse(value) do {0, ""} -> {:ok, 0.0} {1, ""} -> {:ok, 1.0} _ -> {:error, invalid(key, value)} end _ -> {:error, invalid(key, value)} end end # ---------- per-key parsers ---------- # Resize / 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 resize = ensure_resize(acc.resize) |> Map.put(:dpr, min(integer, 3)) {:ok, %{acc | resize: resize, output: %{acc.output | dpr: min(integer, 3)}}} end end defp apply_entry("fit", value, acc, _strict?) do case Map.fetch(@fit_atoms, value) do {:ok, atom} -> {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:fit, atom)}} :error -> {:error, invalid("fit", value)} end end defp apply_entry("crop", value, acc, _strict?) do case Map.fetch(@crop_to_gravity, value) do {:ok, gravity} -> {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:gravity, gravity)}} :error -> {:error, invalid("crop", value)} end end defp apply_entry("fp-x", value, acc, _strict?) do with {:ok, x} <- parse_unit_float("fp-x", value) do {:ok, %{acc | focal_point: %{acc.focal_point | x: x}}} end end defp apply_entry("fp-y", value, acc, _strict?) do with {:ok, y} <- parse_unit_float("fp-y", value) do {:ok, %{acc | focal_point: %{acc.focal_point | y: y}}} end end # Output / format -------------------------------------------------- defp apply_entry("q", value, acc, _strict?) do 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 defp apply_entry("fm", value, acc, _strict?) do case Map.fetch(@fm_atoms, value) do {:ok, atom} -> {:ok, %{acc | output: %{acc.output | type: atom}}} :error -> {:error, invalid("fm", value)} end end defp apply_entry("auto", value, acc, _strict?) do value |> String.split(",", trim: true) |> Enum.reduce_while({:ok, acc}, fn part, {:ok, acc} -> case apply_auto_part(part, acc) do {:ok, acc} -> {:cont, {:ok, acc}} {:error, _} = error -> {:halt, error} end end) end # Effects ---------------------------------------------------------- defp apply_entry("bg", value, acc, _strict?) when is_binary(value) and value != "" do color = if String.starts_with?(value, "#"), do: value, else: "#" <> value {:ok, %{acc | appended: replace_or_append(acc.appended, %Ops.Background{color: color})}} end defp apply_entry("blur", value, acc, _strict?) do with {:ok, n} <- parse_non_neg_integer("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_entry("sharp", value, acc, _strict?) do with {:ok, n} <- parse_non_neg_integer("sharp", 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_entry("bri", value, acc, _strict?), do: adjust(:brightness, value, acc, "bri") defp apply_entry("con", value, acc, _strict?), do: adjust(:contrast, value, acc, "con") defp apply_entry("sat", value, acc, _strict?), do: adjust(:saturation, value, acc, "sat") defp apply_entry("gam", value, acc, _strict?), do: adjust(:gamma, value, acc, "gam") # Geometry --------------------------------------------------------- defp apply_entry("flip", value, acc, _strict?) do case Map.fetch(@flip_atoms, value) do {:ok, direction} -> op = %Ops.Flip{direction: direction} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("flip", value)} end end defp apply_entry("rot", value, acc, _strict?) do with {:ok, angle} <- parse_pos_integer("rot", value) do if rem(angle, 90) == 0 do op = %Ops.Rotate{angle: angle} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} else {:error, invalid("rot", value)} end end end defp apply_entry("trim", "auto", acc, _strict?) do op = %Ops.Trim{mode: :border} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end defp apply_entry("trim", "color", acc, _strict?) do op = %Ops.Trim{mode: :border} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end defp apply_entry("trim", value, _acc, _strict?), do: {:error, invalid("trim", value)} defp apply_entry("trimcolor", _value, acc, _strict?) do # Trimcolor refines trim=color; absorbed by the existing # %Trim{} op (the IR carries an optional :color field). {:ok, acc} end defp apply_entry("border", value, acc, _strict?) do case String.split(value, ",", parts: 2) do [width_str, color] -> with {:ok, width} <- parse_non_neg_integer("border", width_str) do op = %Ops.Border{ color: if(String.starts_with?(color, "#"), do: color, else: "#" <> color), top: width, right: width, bottom: width, left: width } {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end _ -> {:error, invalid("border", 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, "imgix `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 # imgix `monochrome=` produces a tinted monochrome. # `Image.tint/2` does this in one pass via a luminance + tint # colour-recombination matrix. defp apply_entry("monochrome", value, acc, _strict?) do case parse_hex_rgb(value) do {:ok, rgb} -> op = %Ops.Tint{color: rgb} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("monochrome", value)} end end # imgix `sepia=N` is `0..100` percentage strength. Maps to # `Image.sepia/2`'s `0.0..1.0` strength via `N / 100`. defp apply_entry("sepia", value, acc, _strict?) 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("sepia", value)} end end # imgix `px=N` is the pixelate block size in pixels (1..100). # `Image.pixelate/2` takes a `scale` factor (smaller = chunkier # blocks); we convert via `scale = 1 / N`. defp apply_entry("px", value, acc, _strict?) do case parse_int_in_range_value(value, 1..100) do {:ok, n} -> op = %Ops.Pixelate{scale: 1.0 / n} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("px", value)} end end # imgix `or=N` overrides the EXIF orientation tag. `N` is an # integer in the EXIF orientation enumeration (1..8). Maps to # `Image.set_orientation/2`. defp apply_entry("or", value, acc, _strict?) do case parse_int_in_range_value(value, 1..8) do {:ok, n} -> op = %Ops.Orientation{value: n} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("or", value)} end end # Overlays --------------------------------------------------------- defp apply_entry("mark", value, acc, _strict?) when is_binary(value) and value != "" do case Image.Plug.Source.url(value) do {:ok, source} -> layer = %Ops.Draw.Layer{source: source} {:ok, %{acc | draw_layer: %Ops.Draw{layers: [layer]}}} {:error, _} = error -> error end end # Unknown keys ----------------------------------------------------- defp apply_entry(key, _value, acc, false) do Logger.debug(fn -> "image_plug: ignoring unknown imgix option #{inspect(key)}" end) {:ok, acc} end defp apply_entry(key, value, _acc, true) do {:error, Error.new(:unknown_option, "unknown imgix option key", details: %{key: key, value: value})} end # ---------- helpers used by per-key clauses ---------- defp apply_auto_part("format", acc), do: {:ok, %{acc | output: %{acc.output | type: :auto}}} defp apply_auto_part("compress", acc), do: {:ok, %{acc | output: %{acc.output | compression: :fast}}} # `auto=enhance` adds an Enhance op (luminance equalisation + # mild saturation + sharpen). Approximation; imgix's hosted # version is ML-driven. defp apply_auto_part("enhance", acc) do op = %Ops.Enhance{} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end defp apply_auto_part(other, _acc) do case Map.fetch(@unsupported_auto, other) do {:ok, message} -> {:error, unsupported("auto=#{other}", message)} :error -> {:error, invalid("auto", other)} end end defp adjust(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 end