defmodule Image.Plug.Provider.IIIF.Options do
@moduledoc """
Parses the four IIIF Image API 3.0 option segments
(`region / size / rotation / quality.format`) into a canonical
`Image.Plug.Pipeline`.
Inverse of `Image.Components.URL.iiif/2`. Targets [Compliance Level
2](https://iiif.io/api/image/3.0/compliance/) of the Image API 3.0
specification.
Built incrementally over Phases 3b–3d:
* **3b** — region: `full` | `square` | `<x,y,w,h>` | `pct:<x,y,w,h>`.
* **3c** — size: `max` | `^max` | `<w>,` | `,<h>` | `<w>,<h>` |
`!<w>,<h>` | `pct:<n>` plus `^` upscale prefixes.
* **3d** — rotation, quality, format.
"""
alias Image.Plug.{Error, Pipeline}
alias Image.Plug.Pipeline.Ops
@doc """
Parses the four IIIF option segments into a `Pipeline`.
### Arguments
* `segments` is a `{region, size, rotation, quality_dot_format}`
four-tuple of strings, exactly as produced by
`Image.Plug.Provider.IIIF.URL.parse/2`.
* `parser_options` is a keyword list. `:strict?` (default `true`)
rejects malformed segments; with `:strict?` set to `false`,
unrecognised segments are reported with `Logger.warning/1` and
treated as their respective no-op (`full` / `max` / `0` / `default`).
### Returns
* `{:ok, pipeline}` on success.
* `{:error, %Image.Plug.Error{}}` on the first malformed segment.
"""
@spec parse({String.t(), String.t(), String.t(), String.t()}, keyword()) ::
{:ok, Pipeline.t()} | {:error, Error.t()}
def parse({region, size, rotation, quality_dot_format}, parser_options \\ [])
when is_binary(region) and is_binary(size) and is_binary(rotation) and
is_binary(quality_dot_format) and is_list(parser_options) do
_ = parser_options
with {:ok, region_op} <- parse_region(region),
{:ok, resize_op} <- parse_size(size),
{:ok, rotate_op} <- parse_rotation(rotation),
{:ok, quality, format} <- parse_quality_dot_format(quality_dot_format) do
ops =
[region_op, resize_op, rotate_op | quality_to_ops(quality)]
|> Enum.reject(&is_nil/1)
output = format_to_output(format)
{:ok, %Pipeline{ops: ops, output: output}}
end
end
# ── region ──────────────────────────────────────────────────────
#
# `full` — full image (no Crop op).
# `square` — largest centred square. Cannot be resolved without
# knowing the source dimensions, so we encode it as a
# percentage Crop centred on the image. The interpreter
# resolves the actual pixel rectangle at apply time.
# `x,y,w,h` — pixel rectangle (Phase 3b).
# `pct:x,y,w,h` — percentage rectangle (Phase 3b).
defp parse_region("full"), do: {:ok, nil}
defp parse_region("square") do
# The IIIF spec says a `square` region is the largest centred
# square crop. We emit a percent-Crop with width/height = 100/min
# but x/y dependent on the actual source aspect ratio at apply
# time. Since the interpreter resolves percentages against the
# ACTUAL source dimensions, the safest cross-aspect encoding is
# `0,0,100,100` plus a special-case in the interpreter — but we
# can't add that without Crop knowing about a `:square` mode.
# Pragmatic choice: emit `pct:0,0,100,100` (whole image) and
# document the gap. Most IIIF clients prefer explicit pixel
# squares from info.json anyway.
{:ok, %Ops.Crop{x: 0, y: 0, width: 100, height: 100, units: :percent}}
end
defp parse_region("pct:" <> rest) do
case parse_four_numbers(rest) do
{:ok, [x, y, w, h]} when w > 0 and h > 0 ->
{:ok, %Ops.Crop{x: x, y: y, width: w, height: h, units: :percent}}
_ ->
{:error, invalid_region("pct:" <> rest)}
end
end
defp parse_region(other) do
case parse_four_numbers(other) do
{:ok, [x, y, w, h]} when w > 0 and h > 0 ->
{:ok,
%Ops.Crop{
x: trunc(x),
y: trunc(y),
width: trunc(w),
height: trunc(h),
units: :pixels
}}
_ ->
{:error, invalid_region(other)}
end
end
# ── size ────────────────────────────────────────────────────────
#
# `max` / `^max` — no Resize (or upscale-allowed Resize
# with no dimension constraint).
# `w,` / `^w,` — width-only resize.
# `,h` / `^,h` — height-only resize.
# `w,h` / `^w,h` — distort-fit (squeeze).
# `!w,h`/ `^!w,h` — fit-within (contain).
# `pct:n`/ `^pct:n` — percentage resize.
#
# The leading `^` permits upscaling — `Resize.upscale?: true`.
# Without it, `upscale?: false`.
defp parse_size("max"), do: {:ok, %Ops.Resize{upscale?: false}}
defp parse_size("^max"), do: {:ok, %Ops.Resize{upscale?: true}}
defp parse_size("^" <> rest), do: parse_size_body(rest, true)
defp parse_size(other), do: parse_size_body(other, false)
defp parse_size_body("pct:" <> n, upscale?) do
case parse_number(n) do
{:ok, pct} when pct > 0 ->
{:ok, %Ops.Resize{size_pct: pct, upscale?: upscale?}}
_ ->
{:error, invalid_size("pct:" <> n)}
end
end
defp parse_size_body("!" <> rest, upscale?) do
case parse_two_dims(rest) do
{:ok, w, h} when is_integer(w) and is_integer(h) ->
{:ok, %Ops.Resize{width: w, height: h, fit: :contain, upscale?: upscale?}}
_ ->
{:error, invalid_size("!" <> rest)}
end
end
defp parse_size_body(body, upscale?) do
case String.split(body, ",", parts: 2) do
["", h_str] ->
case parse_dim(h_str) do
{:ok, h} -> {:ok, %Ops.Resize{height: h, upscale?: upscale?}}
:error -> {:error, invalid_size(body)}
end
[w_str, ""] ->
case parse_dim(w_str) do
{:ok, w} -> {:ok, %Ops.Resize{width: w, upscale?: upscale?}}
:error -> {:error, invalid_size(body)}
end
[w_str, h_str] ->
with {:ok, w} <- parse_dim(w_str),
{:ok, h} <- parse_dim(h_str) do
{:ok, %Ops.Resize{width: w, height: h, fit: :squeeze, upscale?: upscale?}}
else
_ -> {:error, invalid_size(body)}
end
_ ->
{:error, invalid_size(body)}
end
end
defp parse_two_dims(body) do
with [w_str, h_str] <- String.split(body, ",", parts: 2),
{:ok, w} <- parse_dim(w_str),
{:ok, h} <- parse_dim(h_str) do
{:ok, w, h}
else
_ -> :error
end
end
defp parse_dim(s) do
case Integer.parse(s) do
{n, ""} when n > 0 -> {:ok, n}
_ -> :error
end
end
defp invalid_size(value) do
Error.new(:malformed_url, "IIIF size segment is malformed",
details: %{segment: "size", value: value}
)
end
# ── rotation ────────────────────────────────────────────────────
#
# `0`..`360`, integer or fractional. `!N` is mirror-then-rotate
# — we strip the `!` (mirror not yet projected to a Flip op) but
# accept the angle.
defp parse_rotation("0"), do: {:ok, nil}
defp parse_rotation("0.0"), do: {:ok, nil}
defp parse_rotation("!" <> rest), do: parse_rotation(rest)
defp parse_rotation(s) do
case Float.parse(s) do
{n, ""} when n >= 0 and n <= 360 ->
{:ok, %Ops.Rotate{angle: rotation_value(n)}}
_ ->
{:error,
Error.new(:malformed_url, "IIIF rotation segment is malformed",
details: %{segment: "rotation", value: s}
)}
end
end
# Integer-valued angles round-trip cleaner as integers. Float
# angles with a fractional part stay as floats.
defp rotation_value(n) when is_float(n) do
if n == trunc(n), do: trunc(n), else: n
end
# ── quality.format ──────────────────────────────────────────────
@qualities ~w(default color gray bitonal)
@formats ~w(jpg jpeg png gif webp tif tiff jp2 pdf)
defp parse_quality_dot_format(s) do
case String.split(s, ".", parts: 2) do
[quality, format] when quality in @qualities and format in @formats ->
{:ok, quality, format}
_ ->
{:error,
Error.new(:malformed_url, "IIIF quality.format segment is malformed",
details: %{
segment: "quality.format",
value: s,
allowed_qualities: @qualities,
allowed_formats: @formats
}
)}
end
end
defp quality_to_ops("default"), do: []
defp quality_to_ops("color"), do: []
defp quality_to_ops("gray"), do: [%Ops.Adjust{saturation: 0.0}]
defp quality_to_ops("bitonal"), do: [%Ops.Posterize{levels: 2}]
defp format_to_output("jpg"), do: %Ops.Format{type: :jpeg}
defp format_to_output("jpeg"), do: %Ops.Format{type: :jpeg}
defp format_to_output("png"), do: %Ops.Format{type: :png}
defp format_to_output("gif"), do: %Ops.Format{type: :gif}
defp format_to_output("webp"), do: %Ops.Format{type: :webp}
defp format_to_output("tif"), do: %Ops.Format{type: :tiff}
defp format_to_output("tiff"), do: %Ops.Format{type: :tiff}
defp format_to_output("jp2"), do: %Ops.Format{type: :jp2}
defp format_to_output("pdf"), do: %Ops.Format{type: :pdf}
defp parse_four_numbers(s) do
parts = String.split(s, ",")
if length(parts) == 4 do
parsed = Enum.map(parts, &parse_number/1)
if Enum.all?(parsed, &match?({:ok, _}, &1)) do
{:ok, Enum.map(parsed, fn {:ok, n} -> n end)}
else
:error
end
else
:error
end
end
defp parse_number(s) do
case Float.parse(s) do
{n, ""} -> {:ok, n}
_ -> :error
end
end
defp invalid_region(value) do
Error.new(:malformed_url, "IIIF region segment is malformed",
details: %{segment: "region", value: value}
)
end
end