defmodule Image.Plug.Provider.ImageKit.Options do
@moduledoc """
Parses an [ImageKit transform string](https://imagekit.io/docs/transformations)
into a canonical `Image.Plug.Pipeline`.
ImageKit's transform vocabulary is medium-sized (~50 keys). v0.1
implements the common subset that maps cleanly onto the canonical
IR:
* Sizing: `w`, `h`, `dpr`, `c` / `cm` (crop mode), `fo` (focus =
gravity), `x` / `y` (focal point — derived together with
`fo-custom` or `fo-xy_center`).
* Output: `q`, `f`.
* Effects: `bg` (background), `e-blur`, `e-sharpen`, `e-contrast`,
`e-grayscale`, `e-usm` (unsharp mask).
* Geometry: `rt` (rotate, multiples of 90), `b` (border
`b-<W>-<color>`).
* Overlays: `oi` (overlay-image base form).
Multiple chained transform stages (`tr:w-200:rt-90`) are flattened
to a single comma-joined list by the URL recogniser. The canonical
IR doesn't model chained transforms in v0.1, so order-dependent
multi-stage recipes collapse to last-write-wins. Documented in
`guides/image_kit_conformance.md`.
ImageKit-only features that don't fit the canonical IR
(`e-shadow`, `e-gradient`, AI tags) raise `:unsupported_option`.
"""
alias Image.Plug.{Error, Pipeline}
alias Image.Plug.Pipeline.Ops
require Logger
# ImageKit `c-` / `cm-` modes → canonical fit + (optional) gravity hint.
@c_to_fit %{
"maintain_ratio" => :contain,
"force" => :squeeze,
"at_least" => :scale_down,
"at_max" => :scale_down,
"at_max_enlarge" => :contain,
"extract" => :crop,
"pad_extract" => :pad,
"pad_resize" => :pad
}
@fo_to_gravity %{
"center" => :center,
"centre" => :center,
"top" => :north,
"bottom" => :south,
"left" => :west,
"right" => :east,
"top_left" => :north_west,
"top_right" => :north_east,
"bottom_left" => :south_west,
"bottom_right" => :south_east,
"auto" => :auto,
"face" => :face,
"custom" => :focalpoint,
"xy_center" => :focalpoint
}
@f_to_atom %{
"jpg" => :jpeg,
"jpeg" => :jpeg,
"png" => :png,
"webp" => :webp,
"avif" => :avif,
"auto" => :auto
}
@unsupported_effects %{
"gradient" =>
"imagekit `e-gradient` needs a gradient overlay helper in the Image library — see TODO.md",
"removedotbg" =>
"imagekit `e-removedotbg` is a third-party AI background-removal call; not implemented",
"bgremove" => "imagekit `e-bgremove` is an AI background-removal call; not implemented",
"changebg" => "imagekit `e-changebg` is a generative-AI call; not implemented",
"edit" => "imagekit `e-edit` is a generative-AI call; not implemented",
"upscale" => "imagekit `e-upscale` is a model-driven super-resolution call; not implemented"
}
@unsupported_keys %{
"ot" => "imagekit overlay-text (`ot-`) is not implemented in v0.1",
"obg" => "imagekit overlay-background (`obg-`) is not implemented in v0.1",
"t" => "imagekit named transformation (`t-`) is a server-side alias not modelled by the IR"
}
@doc """
Parses an ImageKit transform string into an `Image.Plug.Pipeline`.
### Arguments
* `transform_string` — the comma-joined transform string emitted
by `Image.Plug.Provider.ImageKit.URL` (multiple stages already
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.ImageKit.Options
iex> {:ok, pipeline} = Options.parse("w-200,c-extract,f-webp,q-80")
iex> [resize] = pipeline.ops
iex> {resize.width, resize.fit}
{200, :crop}
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 token.
# `e-<name>[-<value>]` may also appear without a value
# (`e-grayscale`).
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},
aspect_ratio: 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.ImageKit)
|> Pipeline.put_output(acc.output)
|> append_in_canonical_order(acc |> apply_aspect_ratio() |> apply_focal_point())
{:ok, pipeline}
end
# When `ar-W-H` was specified along with exactly one of `w`/`h`,
# derive the missing dimension from the ratio. When both are
# already set, the user has been explicit; leave them alone.
# When neither is set, `ar` alone is a no-op (ImageKit's docs
# say `ar` only takes effect with one of `w`/`h`).
defp apply_aspect_ratio(%{aspect_ratio: nil} = acc), do: acc
defp apply_aspect_ratio(%{aspect_ratio: {w_part, h_part}, resize: resize} = acc) do
case resize do
%Ops.Resize{width: w, height: nil} when is_integer(w) ->
derived = max(round(w * h_part / w_part), 1)
%{acc | resize: %{resize | height: derived}}
%Ops.Resize{width: nil, height: h} when is_integer(h) ->
derived = max(round(h * w_part / h_part), 1)
%{acc | resize: %{resize | width: derived}}
_ ->
acc
end
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.Sharpen),
find_one(acc.appended, Ops.Blur),
find_one(acc.appended, Ops.DropShadow),
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 imagekit 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
# 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
# Parses ImageKit's `e-shadow` parameter string. Format is
# any combination of `bl-<n>` (blur radius), `st-<n>`
# (strength / opacity 0..100), `x-<n>` (dx pixels),
# `y-<n>` (dy pixels), `c-<hex>` (colour), separated by
# underscores. Order is not significant; missing components
# use the defaults from `%Ops.DropShadow{}`. A bare
# `e-shadow` returns the default struct.
@shadow_defaults %{color: [0, 0, 0], opacity: 0.5, sigma: 5.0, dx: 0, dy: 10}
defp parse_shadow_params("") do
{:ok, @shadow_defaults}
end
defp parse_shadow_params(value) when is_binary(value) do
pairs = parse_shadow_pairs(value)
Enum.reduce_while(pairs, {:ok, @shadow_defaults}, fn {key, val}, {:ok, acc} ->
case apply_shadow_pair(key, val, acc) do
{:ok, _} = success -> {:cont, success}
{:error, _} = error -> {:halt, error}
end
end)
end
# Each component is `<key>-<value>`; the components are joined
# by `_`. The negative-number-friendly split below drops empty
# tokens introduced by ImageKit's `y--3` syntax (which means
# "y = -3", not "y = (no value), then -3").
defp parse_shadow_pairs(value) do
value
|> String.split("_", trim: true)
|> Enum.map(fn token ->
case String.split(token, "-", parts: 2) do
[key, val] -> {key, val}
[key] -> {key, ""}
end
end)
end
defp apply_shadow_pair("bl", val, acc) do
case parse_int_in_range_value(val, 0..100) do
{:ok, n} -> {:ok, %{acc | sigma: max(n / 2.0, 0.5)}}
:error -> {:error, invalid("e-shadow:bl", val)}
end
end
defp apply_shadow_pair("st", val, acc) do
case parse_int_in_range_value(val, 0..100) do
{:ok, n} -> {:ok, %{acc | opacity: n / 100.0}}
:error -> {:error, invalid("e-shadow:st", val)}
end
end
defp apply_shadow_pair("x", val, acc) do
with {:ok, n} <- parse_int("e-shadow:x", val), do: {:ok, %{acc | dx: n}}
end
defp apply_shadow_pair("y", val, acc) do
with {:ok, n} <- parse_int("e-shadow:y", val), do: {:ok, %{acc | dy: n}}
end
defp apply_shadow_pair("c", val, acc) do
case Color.new(val) do
{:ok, %Color.SRGB{r: r, g: g, b: b}} ->
{:ok, %{acc | color: [round(r * 255), round(g * 255), round(b * 255)]}}
_ ->
{:error, invalid("e-shadow:c", val)}
end
end
defp apply_shadow_pair(_key, _val, acc), do: {:ok, acc}
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, 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
# ImageKit `ar-W-H` — sets the target aspect ratio. Only
# takes effect when exactly one of `w`/`h` is also given.
defp apply_entry("ar", value, acc, _strict?) do
case String.split(value, "-", parts: 2) do
[w_str, h_str] ->
with {:ok, w_part} <- parse_pos_integer("ar", w_str),
{:ok, h_part} <- parse_pos_integer("ar", h_str) do
{:ok, %{acc | aspect_ratio: {w_part, h_part}}}
end
_ ->
{:error, invalid("ar", value)}
end
end
# ImageKit `z-<n>` — face-zoom factor in `[0.0, 1.0]`. Stored
# on the IR's Resize op (`face_zoom` field); the interpreter
# does not yet act on it (matches Cloudflare's `face-zoom`
# behaviour). Parsed so users with `z-` in URLs aren't
# rejected.
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("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
defp apply_entry("c", value, acc, _strict?), do: apply_crop_mode(value, acc)
defp apply_entry("cm", value, acc, _strict?), do: apply_crop_mode(value, acc)
defp apply_entry("fo", value, acc, _strict?) do
case Map.fetch(@fo_to_gravity, value) do
{:ok, gravity} ->
{:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:gravity, gravity)}}
:error ->
{:error, invalid("fo", 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
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("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
# Encoder flags ----------------------------------------------------
#
# ImageKit `lo-true` — lossless mode (WebP / AVIF / PNG only).
defp apply_entry("lo", "true", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | lossy: false}}}
end
defp apply_entry("lo", "false", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | lossy: true}}}
end
defp apply_entry("lo", value, _acc, _strict?), do: {:error, invalid("lo", value)}
# ImageKit `pr-true` — progressive JPEG.
defp apply_entry("pr", "true", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | progressive: true}}}
end
defp apply_entry("pr", "false", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | progressive: false}}}
end
defp apply_entry("pr", value, _acc, _strict?), do: {:error, invalid("pr", value)}
# ImageKit `cp-<n>` — chroma subsampling. `1` (4:1:1 / 4:2:0)
# is the typical low-bandwidth setting; `0` selects libvips'
# auto behaviour. Values `2`/`3` (4:2:2 / 4:4:4) collapse to
# `:off` (force full chroma).
defp apply_entry("cp", value, acc, _strict?) do
case parse_int_in_range_value(value, 0..3) do
{:ok, 0} ->
{:ok, %{acc | output: %{acc.output | chroma_subsampling: :auto}}}
{:ok, 1} ->
{:ok, %{acc | output: %{acc.output | chroma_subsampling: :on}}}
{:ok, _} ->
{:ok, %{acc | output: %{acc.output | chroma_subsampling: :off}}}
:error ->
{:error, invalid("cp", value)}
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("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("rt", value, acc, _strict?) do
with {:ok, angle} <- parse_int("rt", 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("rt", value)}
end
end
end
defp apply_entry("b", value, acc, _strict?) do
# Either `b-<W>-<color>` (which our split-on-first-`-` lands as
# `value = "<W>-<color>"`) or `b-<W>_<color>` (the underscore
# form ImageKit also accepts).
parts =
cond do
String.contains?(value, "_") -> String.split(value, "_", parts: 2)
String.contains?(value, "-") -> String.split(value, "-", parts: 2)
true -> [value]
end
case parts do
[width_str, color] ->
with {:ok, width} <- parse_non_neg_integer("b", 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("b", value)}
end
end
# Overlays ---------------------------------------------------------
defp apply_entry("oi", value, acc, _strict?) when is_binary(value) and value != "" do
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 imagekit option #{inspect(key)}" end)
{:ok, acc}
end
defp apply_entry(key, value, _acc, true) do
{:error,
Error.new(:unknown_option, "unknown imagekit option key", details: %{key: key, value: value})}
end
# ---------- helpers ----------
defp apply_crop_mode(value, acc) 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_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("usm", value, acc) do
# Unsharp mask: usm-<radius>-<sigma>-<amount>-<threshold>.
# We only honour the sigma-equivalent (second component); the
# rest tweak the kernel and aren't modelled by the IR.
case String.split(value, "-") do
[_radius, sigma_str | _] ->
with {:ok, n} <- parse_non_neg_integer("e-usm", sigma_str) do
sigma = min(n, 100) / 10.0
op = %Ops.Sharpen{sigma: sigma}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
_ ->
# Bare `e-usm` with no parameters → default unsharp.
{:ok, %{acc | appended: replace_or_append(acc.appended, %Ops.Sharpen{sigma: 1.0})}}
end
end
defp apply_effect("contrast", _value, acc) do
# ImageKit's `e-contrast` is a single boolean toggle (auto-contrast
# on the input). Approximate by setting Adjust contrast to 1.1
# (a mild boost). Not exact; documented as ⚠️ in the conformance
# guide.
adjust = ensure_adjust(acc.adjust) |> Map.put(:contrast, 1.1)
{:ok, %{acc | adjust: adjust}}
end
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
# ImageKit `e-retouch` maps to `Image.enhance/2`. The hosted
# version is ML-driven; we approximate.
defp apply_effect("retouch", _value, acc) do
op = %Ops.Enhance{}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
# ImageKit `e-shadow` accepts an optional dash-separated tuple
# of `bl-<blur>-st-<strength>-x-<dx>-y-<dy>-c-<hex>` (in any
# order) per the ImageKit docs. Bare `e-shadow` uses sensible
# defaults; missing components fall back to the same defaults.
defp apply_effect("shadow", value, acc) do
with {:ok, params} <- parse_shadow_params(value) do
op = %Ops.DropShadow{
color: params.color,
opacity: params.opacity,
sigma: params.sigma,
dx: params.dx,
dy: params.dy
}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
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 imagekit effect",
details: %{key: "e-#{name}", value: value}
)}
end
end