defmodule Image.Plug.Provider.Cloudflare.Options do
@moduledoc """
Parses the comma-separated `<options>` segment of a Cloudflare
Images URL into a canonical `Image.Plug.Pipeline`.
Recognised keys (canonical name; aliases in parens):
* Resize: `width` (`w`), `height` (`h`), `fit`, `gravity` (`g`),
`dpr`, `zoom` / `face-zoom`.
* Output: `quality` (`q`), `format` (`f`), `metadata`, `anim`,
`compression`, `slow-connection-quality` (`scq`).
* Effects: `background`, `blur`, `sharpen`, `brightness`,
`contrast`, `gamma`, `saturation`.
* Geometry: `rotate`, `flip`, `trim`, `border`.
* Misc: `segment`, `onerror`.
Aliases normalise to the canonical key before dispatch. Unknown
keys raise `:unknown_option` by default; pass `strict?: false` to
log and ignore them. Drawing/overlays via the URL `draw=` grammar
(`url(...)`, `width`, `height`, `fit`, `gravity`, `opacity`, `top`,
`left`, `bottom`, `right`, `rotate`, `repeat`, `background`) are
parsed into `Ops.Draw` layers.
"""
alias Image.Plug.{Error, Pipeline}
alias Image.Plug.Pipeline.Ops
require Logger
@aliases %{
"w" => "width",
"h" => "height",
"q" => "quality",
"f" => "format",
"g" => "gravity",
"scq" => "slow-connection-quality",
"face-zoom" => "zoom"
}
@named_quality %{
"high" => 90,
"medium-high" => 80,
"medium-low" => 65,
"low" => 50
}
@fit_atoms %{
"contain" => :contain,
"cover" => :cover,
"crop" => :crop,
"pad" => :pad,
"scale-down" => :scale_down,
"squeeze" => :squeeze
}
@format_atoms %{
"auto" => :auto,
"avif" => :avif,
"webp" => :webp,
"jpeg" => :jpeg,
"baseline-jpeg" => :baseline_jpeg,
"png" => :png,
"json" => :json
}
@gravity_atoms %{
"auto" => :auto,
"face" => :face,
"center" => :center,
"centre" => :center,
"left" => :west,
"right" => :east,
"top" => :north,
"bottom" => :south,
"north" => :north,
"south" => :south,
"east" => :east,
"west" => :west,
"northeast" => :north_east,
"northwest" => :north_west,
"southeast" => :south_east,
"southwest" => :south_west
}
@flip_atoms %{
"h" => :horizontal,
"v" => :vertical,
"hv" => :both,
"vh" => :both
}
@metadata_atoms %{
"copyright" => :copyright,
"keep" => :keep,
"none" => :none
}
@doc """
Parses an options string into a `Image.Plug.Pipeline`.
### Arguments
* `options_string` is the comma-separated `<options>` segment from
the request URL.
* `parser_options` is a keyword list controlling parsing behaviour.
### Options
* `:strict?` — if `true` (the default), unknown option keys produce
`{:error, %Image.Plug.Error{tag: :unknown_option}}`. If `false`,
unknown keys are logged and ignored.
### Returns
* `{:ok, pipeline}` on success.
* `{:error, %Image.Plug.Error{}}` on the first malformed entry.
### Examples
iex> alias Image.Plug.Provider.Cloudflare.Options
iex> {:ok, pipeline} = Options.parse("width=200,fit=cover,format=webp", [])
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(options_string, parser_options \\ []) when is_binary(options_string) do
strict? = Keyword.get(parser_options, :strict?, true)
options_string
|> split_entries()
|> reduce_entries(strict?)
end
defp split_entries(""), do: []
defp split_entries(options_string) do
options_string
|> String.split(",", trim: true)
|> Enum.map(&split_kv/1)
end
defp split_kv(entry) do
case String.split(entry, "=", parts: 2) do
[key] -> {String.trim(key), nil}
[key, value] -> {String.trim(key), String.trim(value)}
end
end
defp reduce_entries(entries, strict?) do
initial =
{:ok,
%{
resize: nil,
adjust: nil,
output: %Ops.Format{},
on_error: nil,
appended: []
}}
Enum.reduce_while(entries, initial, fn {key, value}, {:ok, acc} ->
case apply_entry(canonical_key(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.Cloudflare)
|> Pipeline.put_output(acc.output)
|> maybe_put_on_error(acc.on_error)
|> append_in_canonical_order(acc)
{:ok, pipeline}
end
defp maybe_put_on_error(pipeline, nil), do: pipeline
defp maybe_put_on_error(pipeline, on_error), do: %{pipeline | on_error: on_error}
defp canonical_key(key), do: Map.get(@aliases, key, key)
# Order matches Cloudflare's documented processing order:
# rotate -> trim -> flip -> resize -> background -> border -> adjust
# -> sharpen -> blur -> segment.
defp append_in_canonical_order(pipeline, acc) do
[
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.Sharpen),
find_one(acc.appended, Ops.Blur),
find_one(acc.appended, Ops.Segment),
find_one(acc.appended, Ops.Draw)
]
|> 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
# Per-key parsers — one clause per option key. Each clause returns
# `{:ok, acc}` or `{:error, %Error{}}`.
# Resize family ----------------------------------------------------
defp apply_entry("width", "auto", acc, _strict?) do
{:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:width, :auto)}}
end
defp apply_entry("width", value, acc, _strict?) do
with {:ok, integer} <- parse_pos_integer("width", value) do
{:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:width, integer)}}
end
end
defp apply_entry("height", value, acc, _strict?) do
with {:ok, integer} <- parse_pos_integer("height", value) do
{:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:height, integer)}}
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("gravity", value, acc, _strict?) do
with {:ok, gravity} <- parse_gravity(value) do
{:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:gravity, gravity)}}
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, integer)
output = %{acc.output | dpr: integer}
{:ok, %{acc | resize: resize, output: output}}
end
end
defp apply_entry("zoom", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("zoom", value) do
{:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:face_zoom, float)}}
end
end
# Output / format family -------------------------------------------
defp apply_entry("quality", value, acc, _strict?) do
with {:ok, quality} <- parse_quality(value) do
{:ok, %{acc | output: %{acc.output | quality: quality}}}
end
end
defp apply_entry("format", value, acc, _strict?) do
case Map.fetch(@format_atoms, value) do
{:ok, atom} -> {:ok, %{acc | output: %{acc.output | type: atom}}}
:error -> {:error, invalid("format", value)}
end
end
defp apply_entry("metadata", value, acc, _strict?) do
case Map.fetch(@metadata_atoms, value) do
{:ok, atom} -> {:ok, %{acc | output: %{acc.output | metadata: atom}}}
:error -> {:error, invalid("metadata", value)}
end
end
defp apply_entry("anim", "true", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | anim?: true}}}
end
defp apply_entry("anim", "false", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | anim?: false}}}
end
defp apply_entry("anim", value, _acc, _strict?), do: {:error, invalid("anim", value)}
defp apply_entry("compression", "fast", acc, _strict?) do
{:ok, %{acc | output: %{acc.output | compression: :fast}}}
end
defp apply_entry("compression", value, _acc, _strict?) do
{:error, invalid("compression", value)}
end
defp apply_entry("slow-connection-quality", value, acc, _strict?) do
with {:ok, quality} <- parse_quality(value) do
{:ok, %{acc | output: %{acc.output | scq_quality: quality}}}
end
end
defp apply_entry("onerror", "redirect", acc, _strict?) do
{:ok, %{acc | on_error: :fallback_to_source}}
end
defp apply_entry("onerror", value, _acc, _strict?), do: {:error, invalid("onerror", value)}
# Effects family ---------------------------------------------------
defp apply_entry("background", value, acc, _strict?) when is_binary(value) and value != "" do
op = %Ops.Background{color: value}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
defp apply_entry("background", value, _acc, _strict?),
do: {:error, invalid("background", value)}
defp apply_entry("blur", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("blur", value) do
sigma = blur_to_sigma(float)
op = %Ops.Blur{sigma: sigma}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
end
defp apply_entry("sharpen", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("sharpen", value) do
sigma = sharpen_to_sigma(float)
op = %Ops.Sharpen{sigma: sigma}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
end
defp apply_entry("brightness", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("brightness", value) do
adjust = ensure_adjust(acc.adjust) |> Map.put(:brightness, float)
{:ok, %{acc | adjust: adjust}}
end
end
defp apply_entry("contrast", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("contrast", value) do
adjust = ensure_adjust(acc.adjust) |> Map.put(:contrast, float)
{:ok, %{acc | adjust: adjust}}
end
end
defp apply_entry("gamma", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("gamma", value) do
adjust = ensure_adjust(acc.adjust) |> Map.put(:gamma, float)
{:ok, %{acc | adjust: adjust}}
end
end
defp apply_entry("saturation", value, acc, _strict?) do
with {:ok, float} <- parse_non_neg_float("saturation", value) do
adjust = ensure_adjust(acc.adjust) |> Map.put(:saturation, float)
{:ok, %{acc | adjust: adjust}}
end
end
# Geometry family --------------------------------------------------
defp apply_entry("rotate", value, acc, _strict?) do
with {:ok, angle} <- parse_pos_integer("rotate", 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("rotate", value)}
end
end
end
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("trim", value, acc, _strict?) do
with {:ok, op} <- parse_trim(value) do
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
end
defp apply_entry("border", value, acc, _strict?) do
with {:ok, op} <- parse_border(value) do
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
end
defp apply_entry("segment", "foreground", acc, _strict?) do
op = %Ops.Segment{kind: :foreground}
{:ok, %{acc | appended: replace_or_append(acc.appended, op)}}
end
defp apply_entry("segment", value, _acc, _strict?), do: {:error, invalid("segment", value)}
defp apply_entry("draw", value, acc, _strict?) when is_binary(value) do
with {:ok, layer} <- parse_draw_layer(value) do
{:ok, %{acc | appended: append_draw_layer(acc.appended, layer)}}
end
end
defp apply_entry("draw", value, _acc, _strict?), do: {:error, invalid("draw", value)}
# Unknown keys -----------------------------------------------------
defp apply_entry(key, _value, acc, false) do
Logger.debug(fn -> "image_plug: ignoring unknown Cloudflare option #{inspect(key)}" end)
{:ok, acc}
end
defp apply_entry(key, value, _acc, true) do
{:error,
Error.new(:unknown_option, "unknown Cloudflare option key",
details: %{key: key, value: value}
)}
end
defp invalid(key, value) do
Error.new(:invalid_option, "invalid value for Cloudflare option",
details: %{key: key, value: value}
)
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_pos_integer(key, value), do: {:error, invalid(key, value)}
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_non_neg_integer(key, value), do: {:error, invalid(key, value)}
defp parse_non_neg_float(key, value) when is_binary(value) do
case Float.parse(value) do
{float, ""} when float >= 0.0 ->
{:ok, float}
:error ->
case Integer.parse(value) do
{integer, ""} when integer >= 0 -> {:ok, integer / 1}
_ -> {:error, invalid(key, value)}
end
end
end
defp parse_non_neg_float(key, value), do: {:error, invalid(key, value)}
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 parse_quality(value) do
case Map.fetch(@named_quality, value) do
{:ok, integer} ->
{:ok, integer}
:error ->
with {:ok, integer} <- parse_pos_integer("quality", value) do
if integer in 1..100 do
{:ok, integer}
else
{:error, invalid("quality", value)}
end
end
end
end
defp parse_gravity(value) do
case Map.fetch(@gravity_atoms, value) do
{:ok, atom} ->
{:ok, atom}
:error ->
parse_xy_gravity(value)
end
end
defp parse_xy_gravity(value) do
with [x_str, y_str] <- String.split(value, "x", parts: 2),
{:ok, x} <- parse_unit_float(x_str),
{:ok, y} <- parse_unit_float(y_str),
true <- x >= 0.0 and x <= 1.0 and y >= 0.0 and y <= 1.0 do
{:ok, {:xy, x, y}}
else
_ -> {:error, invalid("gravity", value)}
end
end
defp parse_unit_float(value) do
case Float.parse(value) do
{float, ""} ->
{:ok, float}
_ ->
case Integer.parse(value) do
{integer, ""} -> {:ok, integer / 1}
_ -> :error
end
end
end
# `blur=N` where N is 0..250 maps to a libvips sigma. Cloudflare's
# exact mapping is not documented; we use the same proportional
# mapping the Sharp project uses (sigma = N / 2) clipped to [0, 100].
defp blur_to_sigma(value) when value <= 0, do: 0.0
defp blur_to_sigma(value) when value > 250, do: 125.0
defp blur_to_sigma(value), do: value / 2.0
# `sharpen=N` where N is 0..10 maps to a libvips sigma. We use the
# documented Cloudflare-friendly mapping sigma = N (sharpen=1 is the
# baseline value Cloudflare recommends for downscaled images).
defp sharpen_to_sigma(value) when value <= 0, do: 0.0
defp sharpen_to_sigma(value) when value > 10, do: 10.0
defp sharpen_to_sigma(value), do: value / 1.0
# `trim=border` triggers auto-detect; otherwise expect
# `trim=top;right;bottom;left`.
defp parse_trim("border"), do: {:ok, %Ops.Trim{mode: :border}}
defp parse_trim(value) when is_binary(value) do
with [t, r, b, l] <- String.split(value, ";"),
{:ok, top} <- parse_non_neg_integer("trim", t),
{:ok, right} <- parse_non_neg_integer("trim", r),
{:ok, bottom} <- parse_non_neg_integer("trim", b),
{:ok, left} <- parse_non_neg_integer("trim", l) do
{:ok, %Ops.Trim{mode: :explicit, top: top, right: right, bottom: bottom, left: left}}
else
_ -> {:error, invalid("trim", value)}
end
end
defp parse_trim(value), do: {:error, invalid("trim", value)}
# `border=color=#hex;width=N` or per-side: `border=color=#hex;top=N;...`.
defp parse_border(value) when is_binary(value) do
parts =
value
|> String.split(";", trim: true)
|> Enum.map(&split_kv/1)
|> Map.new()
color = Map.get(parts, "color", "#000000")
case Map.get(parts, "width") do
nil ->
with {:ok, top} <- border_side(parts, "top"),
{:ok, right} <- border_side(parts, "right"),
{:ok, bottom} <- border_side(parts, "bottom"),
{:ok, left} <- border_side(parts, "left") do
{:ok, %Ops.Border{color: color, top: top, right: right, bottom: bottom, left: left}}
end
width_value ->
with {:ok, width} <- parse_non_neg_integer("border", width_value) do
{:ok, %Ops.Border{color: color, top: width, right: width, bottom: width, left: width}}
end
end
end
defp parse_border(value), do: {:error, invalid("border", value)}
defp border_side(parts, side) do
case Map.get(parts, side) do
nil -> {:ok, 0}
value -> parse_non_neg_integer("border", value)
end
end
# ---------- draw= sub-grammar ----------
#
# `draw=url(<absolute-url>);width=N;height=N;fit=...;gravity=...;
# opacity=0.5;repeat=true|x|y;top=N;right=N;bottom=N;left=N;
# background=#hex;rotate=90`
#
# The first sub-entry must be `url(<absolute-url>)`. Remaining
# sub-entries are `key=value` pairs separated by `;`.
@draw_url_re ~r/^url\((?<url>[^)]+)\)$/
defp parse_draw_layer(value) do
parts = String.split(value, ";", trim: true)
with {:ok, url, rest} <- take_draw_url(parts),
{:ok, source} <- Image.Plug.Source.url(url),
{:ok, fields} <- parse_draw_fields(rest) do
build_draw_layer(source, fields)
end
end
defp take_draw_url([first | rest]) when is_binary(first) do
case Regex.named_captures(@draw_url_re, first) do
%{"url" => url} -> {:ok, url, rest}
_ -> {:error, invalid("draw", "missing url(...)")}
end
end
defp take_draw_url([]), do: {:error, invalid("draw", "empty")}
defp parse_draw_fields(parts) do
Enum.reduce_while(parts, {:ok, %{}}, fn entry, {:ok, acc} ->
case String.split(entry, "=", parts: 2) do
[k, v] -> {:cont, {:ok, Map.put(acc, k, v)}}
_ -> {:halt, {:error, invalid("draw", entry)}}
end
end)
end
defp build_draw_layer(source, fields) do
with {:ok, width} <- optional_pos_int(fields, "width"),
{:ok, height} <- optional_pos_int(fields, "height"),
{:ok, fit} <- optional_atom(fields, "fit", @fit_atoms, :contain),
{:ok, gravity} <- optional_gravity(fields, "gravity", :center),
{:ok, opacity} <- optional_unit_float(fields, "opacity", 1.0),
{:ok, repeat} <- optional_repeat(fields, "repeat"),
{:ok, top} <- optional_pos_int(fields, "top"),
{:ok, right} <- optional_pos_int(fields, "right"),
{:ok, bottom} <- optional_pos_int(fields, "bottom"),
{:ok, left} <- optional_pos_int(fields, "left"),
{:ok, rotate} <- optional_rotate(fields, "rotate"),
:ok <- validate_position(top, bottom, left, right) do
background = Map.get(fields, "background")
position =
if Enum.any?([top, bottom, left, right], &(&1 != nil)) do
{:offset, [top: top, right: right, bottom: bottom, left: left]}
else
nil
end
{:ok,
%Ops.Draw.Layer{
source: source,
width: width,
height: height,
fit: fit,
gravity: gravity,
opacity: opacity,
repeat: repeat,
position: position,
background: background,
rotate: rotate
}}
end
end
defp optional_pos_int(fields, key) do
case Map.fetch(fields, key) do
:error -> {:ok, nil}
{:ok, value} -> parse_pos_integer("draw.#{key}", value)
end
end
defp optional_atom(fields, key, atoms_map, default) do
case Map.fetch(fields, key) do
:error ->
{:ok, default}
{:ok, value} ->
case Map.fetch(atoms_map, value) do
{:ok, atom} -> {:ok, atom}
:error -> {:error, invalid("draw.#{key}", value)}
end
end
end
defp optional_gravity(fields, key, default) do
case Map.fetch(fields, key) do
:error -> {:ok, default}
{:ok, value} -> parse_gravity(value)
end
end
defp optional_unit_float(fields, key, default) do
case Map.fetch(fields, key) do
:error ->
{:ok, default}
{:ok, value} ->
case parse_unit_float(value) do
{:ok, float} when float >= 0.0 and float <= 1.0 -> {:ok, float}
_ -> {:error, invalid("draw.#{key}", value)}
end
end
end
defp optional_repeat(fields, key) do
case Map.fetch(fields, key) do
:error -> {:ok, false}
{:ok, "true"} -> {:ok, true}
{:ok, "false"} -> {:ok, false}
{:ok, "x"} -> {:ok, :x}
{:ok, "y"} -> {:ok, :y}
{:ok, value} -> {:error, invalid("draw.#{key}", value)}
end
end
defp optional_rotate(fields, key) do
case Map.fetch(fields, key) do
:error ->
{:ok, 0}
{:ok, value} ->
with {:ok, angle} <- parse_pos_integer("draw.#{key}", value) do
if angle in [90, 180, 270] do
{:ok, angle}
else
{:error, invalid("draw.#{key}", value)}
end
end
end
end
defp validate_position(top, bottom, _left, _right)
when not is_nil(top) and not is_nil(bottom) do
{:error, invalid("draw", "top and bottom may not both be set")}
end
defp validate_position(_top, _bottom, left, right)
when not is_nil(left) and not is_nil(right) do
{:error, invalid("draw", "left and right may not both be set")}
end
defp validate_position(_top, _bottom, _left, _right), do: :ok
defp append_draw_layer(appended, %Ops.Draw.Layer{} = layer) do
case Enum.find_index(appended, fn op -> op.__struct__ == Ops.Draw end) do
nil ->
appended ++ [%Ops.Draw{layers: [layer]}]
index ->
existing = Enum.at(appended, index)
List.replace_at(appended, index, %{existing | layers: existing.layers ++ [layer]})
end
end
end