defmodule Image.Plug.Provider.Cloudinary.Options do @moduledoc """ Parses a [Cloudinary transform string](https://cloudinary.com/documentation/transformation_reference) into a canonical `Image.Plug.Pipeline`. Cloudinary's transform vocabulary is large (~100+ keys). v0.1 implements the common subset that maps cleanly onto the canonical IR: * Sizing: `w_`, `h_`, `dpr_`, `c_` (crop mode), `g_` (gravity), `x_` / `y_` (focal point — derived together with `g_xy_center`). * Output: `q_`, `f_`. * Effects: `b_` (background), `e_blur:`, `e_sharpen:`, `e_brightness:`, `e_contrast:`, `e_saturation:`, `e_gamma:`. * Geometry: `a_` (rotate, multiples of 90), `e_grayscale` (treated as `Adjust{saturation: 0.0}`), `bo_` (border `bo_px_solid_`). * Overlays: `l_` (single-layer base form). Multiple transform stages (`w_200,c_fill/e_blur:300/`) are flattened by the URL recogniser into a single comma-joined string; this parser then walks them as one set. The canonical IR doesn't model chained transforms in v0.1, so order-dependent multi-stage recipes (sharpen → resize → sharpen) collapse to last-write-wins. This is documented in `guides/cloudinary_conformance.md`. Cloudinary-only features that don't fit the canonical IR (`e_vignette`, `e_pixelate`, `e_cartoonify`, `e_replace_color`, `e_fade`, `cs_srgb`, named transformations `t_`) raise `:unsupported_option` so users learn early. """ alias Image.Plug.{Error, Pipeline} alias Image.Plug.Pipeline.Ops require Logger # Cloudinary `c_` modes → canonical fit + (optional) gravity hint. # crop=:crop is preserved as a fit value distinct from :cover so # the interpreter can choose absolute-pixel cropping when both # width AND height are given. @c_to_fit %{ "scale" => :squeeze, "fit" => :contain, "limit" => :scale_down, "mfit" => :contain, "fill" => :cover, "lfill" => :cover, "crop" => :crop, "thumb" => :cover, "pad" => :pad, "lpad" => :pad, "mpad" => :pad, "fill_pad" => :pad, "imagga_crop" => :cover, "imagga_scale" => :squeeze } @g_to_gravity %{ "north" => :north, "north_east" => :north_east, "north_west" => :north_west, "south" => :south, "south_east" => :south_east, "south_west" => :south_west, "east" => :east, "west" => :west, "center" => :center, "centre" => :center, "face" => :face, "faces" => :face, "auto" => :auto, "auto:subject" => :auto, "auto:classic" => :auto, "xy_center" => :focalpoint } @f_to_atom %{ "jpg" => :jpeg, "jpe" => :jpeg, "jpeg" => :jpeg, "png" => :png, "webp" => :webp, "avif" => :avif, "auto" => :auto } # Cloudinary's `cs_` accepts a small set of named # colorspaces. Map them to the atoms `Image.to_colorspace/2` # accepts. `tinysrgb` is Cloudinary-specific (size-optimised # sRGB); we map to plain `:srgb` since the tinification is a # Cloudinary product layer, not a colorspace. @cs_to_target %{ "srgb" => :srgb, "tinysrgb" => :srgb, "no_cmyk" => :srgb, "cmyk" => :cmyk } @unsupported_effects %{ "redeye" => "cloudinary `e_redeye` is not implemented" } # Cloudinary CSS colour name forms accept underscores between # multi-word names (e.g. `misty_rose`) and lowercase hex without a # leading `#`. We pass both through verbatim — the Image library's # `Image.replace_color/2` accepts hex strings (`"#abcdef"`), CSS # names (string or atom), 0..255 integers, and RGB lists. defp normalise_replace_color_value("rgb:" <> hex), do: "#" <> hex defp normalise_replace_color_value(value) when is_binary(value) do cond do hex_color?(value) -> "#" <> value true -> value end end defp hex_color?(value) when byte_size(value) in [3, 6, 8] do String.match?(value, ~r/^[0-9a-fA-F]+$/) end defp hex_color?(_value), do: false # Re-join consecutive `["rgb", ""]` pairs that an outer # colon-split has separated. `["rgb", "abcdef", "50", "rgb", "fedcba"]` # → `["rgb:abcdef", "50", "rgb:fedcba"]`. defp glue_rgb([]), do: [] defp glue_rgb(["rgb", hex | rest]) do ["rgb:" <> hex | glue_rgb(rest)] end defp glue_rgb([head | rest]) do [head | glue_rgb(rest)] end @unsupported_keys %{ "t" => "cloudinary named transformations (`t_`) are server-side aliases not modelled by the IR", "u" => "cloudinary underlay `u_` is not implemented in v0.1", "if" => "cloudinary conditional transforms (`if_...`) are not implemented in v0.1", "vc" => "cloudinary video codec `vc_` is video-only", "ac" => "cloudinary audio codec `ac_` is video-only", "br" => "cloudinary bitrate `br_` is video-only" } @doc """ Parses a Cloudinary transform string into an `Image.Plug.Pipeline`. ### Arguments * `transform_string` — the comma-joined transform string emitted by `Image.Plug.Provider.Cloudinary.URL` (multiple stages are pre-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.Cloudinary.Options iex> {:ok, pipeline} = Options.parse("w_200,c_fill,f_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(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 `_` where prefix is a short letter # token. `e_[:]` is the only entry that can carry an # internal colon. 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} }} 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.Cloudinary) |> Pipeline.put_output(acc.output) |> append_in_canonical_order(apply_focal_point(acc)) {:ok, pipeline} 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.Colorspace), find_one(acc.appended, Ops.Sepia), find_one(acc.appended, Ops.Tint), find_one(acc.appended, Ops.ReplaceColor), find_one(acc.appended, Ops.Posterize), find_one(acc.appended, Ops.Pixelate), find_one(acc.appended, Ops.PixelateFaces), 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.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 cloudinary 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 defp parse_int(key, value) when is_binary(value) do case Integer.parse(value) do {integer, ""} -> {: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 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 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 # `z_` — face-zoom factor, only meaningful with # `g_face`. Range `[0.0, 1.0]` matches the IR's # `Resize.face_zoom` and the symmetric ImageKit `z-` / # Cloudflare `face-zoom=` parsers. 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("c", value, acc, _strict?) 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_entry("g", value, acc, _strict?) do case Map.fetch(@g_to_gravity, value) do {:ok, gravity} -> {:ok, %{acc | resize: ensure_resize(acc.resize) |> Map.put(:gravity, gravity)}} :error -> {:error, invalid("g", 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 case value do # `q_auto` and `q_auto:eco` etc. are Cloudinary's content-aware # quality. The Image library doesn't have an "auto quality" # knob; we leave the encoder default (85) in place. The # `:compression: :fast` flag is set so the encoder leans on # libvips' faster encode path. "auto" -> {:ok, %{acc | output: %{acc.output | compression: :fast}}} "auto:" <> _ -> {:ok, %{acc | output: %{acc.output | compression: :fast}}} _ -> 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 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 # Effects ---------------------------------------------------------- defp apply_entry("b", value, acc, _strict?) when is_binary(value) and value != "" do color = normalise_color(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("a", value, acc, _strict?) do with {:ok, angle} <- parse_int("a", 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("a", value)} end end end defp apply_entry("bo", value, acc, _strict?) do # bo_px_solid_ or bo_px_solid_ case String.split(value, "_", parts: 3) do [width_str, "solid", color] -> width_str = String.trim_trailing(width_str, "px") with {:ok, width} <- parse_non_neg_integer("bo", width_str) do op = %Ops.Border{ color: normalise_border_color(color), top: width, right: width, bottom: width, left: width } {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end _ -> {:error, invalid("bo", value)} end end defp apply_entry("fl", "force_strip", acc, _strict?) do # Strip metadata is implicit in our encoder (we don't carry it). # Recognised so users with `fl_force_strip` in their URLs don't # get rejected. {:ok, acc} end defp apply_entry("fl", "preserve_transparency", acc, _strict?), do: {:ok, acc} defp apply_entry("fl", "progressive", acc, _strict?) do {:ok, %{acc | output: %{acc.output | progressive: true}}} end defp apply_entry("fl", "lossy", acc, _strict?) do {:ok, %{acc | output: %{acc.output | lossy: true}}} end defp apply_entry("fl", value, _acc, _strict?), do: {:error, invalid("fl", value)} # Rounded corners ---------------------------------------------------- # # Cloudinary `r_` — n is the corner radius in pixels. # `r_max` produces a fully circular / pill-shaped result. defp apply_entry("r", "max", acc, _strict?) do op = %Ops.Rounded{radius: :max} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end defp apply_entry("r", value, acc, _strict?) do with {:ok, n} <- parse_pos_integer("r", value) do op = %Ops.Rounded{radius: n} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end end # Mid-pipeline opacity ----------------------------------------------- # # Cloudinary `o_` — n is a 0..100 opacity percentage. defp apply_entry("o", value, acc, _strict?) do case parse_int_in_range_value(value, 0..100) do {:ok, 100} -> {:ok, acc} {:ok, percent} -> op = %Ops.Opacity{factor: percent / 100} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("o", 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, "cloudinary `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 # Overlays --------------------------------------------------------- defp apply_entry("l", value, acc, _strict?) when is_binary(value) and value != "" do # Single-public-id overlay. v0.1 treats it as a relative path # against the source resolver. 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 cloudinary option #{inspect(key)}" end) {:ok, acc} end defp apply_entry(key, value, _acc, true) do {:error, Error.new(:unknown_option, "unknown cloudinary option key", details: %{key: key, value: value} )} end # ---------- effect dispatch ---------- 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("brightness", value, acc), do: adjust_effect(:brightness, value, acc, "e_brightness") defp apply_effect("contrast", value, acc), do: adjust_effect(:contrast, value, acc, "e_contrast") defp apply_effect("saturation", value, acc), do: adjust_effect(:saturation, value, acc, "e_saturation") defp apply_effect("gamma", value, acc), do: adjust_effect(:gamma, value, acc, "e_gamma") 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 # Cloudinary `e_replace_color:[:[:]]`. Default # `` is `:auto` (the average of the top-left 10×10 region of # the image), default tolerance is 50. Colour values may take the # `rgb:RRGGBB` form which contains its own colon — `glue_rgb/1` # rejoins those before the per-position split. defp apply_effect("replace_color", value, acc) do {to, tolerance, from} = case glue_rgb(String.split(value, ":")) do [to] -> {to, "50", nil} [to, tolerance] -> {to, tolerance, nil} [to, tolerance, from] -> {to, tolerance, from} _ -> {value, "50", nil} end with {:ok, threshold} <- parse_non_neg_integer("e_replace_color", tolerance) do op = %Ops.ReplaceColor{ to: normalise_replace_color_value(to), from: if(is_nil(from), do: :auto, else: normalise_replace_color_value(from)), threshold: threshold } appended = case Enum.find_index(acc.appended, fn existing -> existing.__struct__ == Ops.ReplaceColor end) do nil -> acc.appended ++ [op] index -> List.replace_at(acc.appended, index, op) end {:ok, %{acc | appended: appended}} end end # `e_improve` and the `e_auto_*` family map to `Image.enhance/2`, # a sensible-defaults stack of luminance equalisation + # saturation boost + mild sharpen. Cloudinary's hosted versions # are ML-driven; we approximate. defp apply_effect(name, _value, acc) when name in ~w(improve auto_brightness auto_color auto_contrast) do op = %Ops.Enhance{} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end # `e_vignette[:N]` — N is 0..100 strength percentage. Default # is `50` (matches `Image.vignette/2`'s `:strength` default). defp apply_effect("vignette", "", acc), do: apply_effect("vignette", "50", acc) defp apply_effect("vignette", value, acc) do case parse_int_in_range_value(value, 0..100) do {:ok, 0} -> {:ok, acc} {:ok, n} -> op = %Ops.Vignette{strength: n / 100} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("e_vignette", value)} end end # `e_sepia[:N]` — N is 0..100 strength percentage. Default # full sepia when no value is given. defp apply_effect("sepia", "", acc), do: apply_effect("sepia", "100", acc) defp apply_effect("sepia", value, acc) 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("e_sepia", value)} end end # `e_pixelate[:N]` — N is the block size in pixels. Default # block size 5 when no value is given. defp apply_effect("pixelate", "", acc), do: apply_effect("pixelate", "5", acc) defp apply_effect("pixelate", value, acc) do with {:ok, block_size} <- parse_pos_integer("e_pixelate", value) do op = %Ops.Pixelate{scale: 1.0 / block_size} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end end # `e_pixelate_faces[:N]` — same block-size convention as # `e_pixelate`, but only the regions occupied by detected # faces are pixelated. Requires the optional `:image_vision` # dependency at runtime; without it the interpreter # silently no-ops (returns the source image unchanged). defp apply_effect("pixelate_faces", "", acc), do: apply_effect("pixelate_faces", "5", acc) defp apply_effect("pixelate_faces", value, acc) do with {:ok, block_size} <- parse_pos_integer("e_pixelate_faces", value) do op = %Ops.PixelateFaces{scale: 1.0 / block_size} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} end end # `e_cartoonify[:level_count]` — `level_count` defaults to # `5`. We map straight through to `Image.posterize/2`'s # tonal-quantisation; Cloudinary's full effect also adds an # edge-detect overlay we don't model. defp apply_effect("cartoonify", "", acc), do: apply_effect("cartoonify", "5", acc) defp apply_effect("cartoonify", value, acc) do case parse_int_in_range_value(value, 2..256) do {:ok, levels} -> op = %Ops.Posterize{levels: levels} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("e_cartoonify", value)} end end # `e_fade[:N]` — N is a 0..100 length percentage applied to # the bottom edge by default (matches Cloudinary's documented # default). Cloudinary's directional flavours # (`e_fade_top` etc.) aren't modelled in v0.1. defp apply_effect("fade", "", acc), do: apply_effect("fade", "20", acc) defp apply_effect("fade", value, acc) do case parse_int_in_range_value(value, 0..100) do {:ok, 0} -> {:ok, acc} {:ok, percent} -> op = %Ops.Fade{edges: [:bottom], length: percent / 100} {:ok, %{acc | appended: replace_or_append(acc.appended, op)}} :error -> {:error, invalid("e_fade", value)} 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 cloudinary effect", details: %{key: "e_#{name}", value: value} )} end defp adjust_effect(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 # `b_` accepts `rgb:RRGGBB`, `rgb:RRGGBBAA`, ``, # or `auto:border` etc. We map the common rgb form; everything else # is forwarded verbatim to libvips' colour parser via # `Image.flatten/2`. defp normalise_color("rgb:" <> hex), do: "#" <> hex defp normalise_color(other), do: other defp normalise_border_color("rgb:" <> hex), do: "#" <> hex defp normalise_border_color(other), do: "#" <> other end