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=<value>` 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=<hex>` 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