defmodule Image.Plug.Provider.Cloudflare.Options do @moduledoc """ Parses the comma-separated `` 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 `` 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();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()`. Remaining # sub-entries are `key=value` pairs separated by `;`. @draw_url_re ~r/^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