defmodule Image.Components.IIIF do @moduledoc """ IIIF Image API 3.0-specific Phoenix components. `<.image provider={:iiif}>` covers the static-thumbnail case — it emits a single `` whose `src` is built by `Image.Components.URL.iiif/2`. This module covers the IIIF fundamentals that don't fit the cross-provider component shape: * **`:static` mode** — same as `<.image provider={:iiif}>`, but lives here for symmetry with the other modes. Renders a single ``. * **`:tiles` mode** — emits a CSS grid of `` tiles at a chosen scale factor. No JavaScript. Useful for high-resolution static layouts (atlases, scanned documents) where the tile structure of the IIIF source maps cleanly onto a fixed-size grid. * **`:viewer` mode** — emits a `
` carrying `data-iiif-info-url=…`, ready for a JavaScript deep-zoom viewer (OpenSeadragon, Mirador, Leaflet-IIIF) to mount into. A static fallback `` lives inside the div for the no-JS / loading-state case. Use this module when your image source is IIIF-compliant *and* you need tiling or viewer mounting. For thumbnails, use the generic `<.image provider={:iiif}>` from `Image.Components`. ## Usage defmodule MyAppWeb.GalleryLive do use MyAppWeb, :live_view import Image.Components.IIIF def render(assigns) do ~H\"\"\" <%!-- Static thumbnail --%> <.iiif src="/cat.jpg" host="https://iiif.example.org" width={400} /> <%!-- Tile grid for a 4096×4096 source at scale factor 2 --%> <.iiif src="/atlas.jpg" host="https://iiif.example.org" mode={:tiles} source_width={4096} source_height={4096} tile_width={512} scale_factor={2} /> <%!-- Deep-zoom viewer mount --%> <.iiif src="/portrait.jpg" host="https://iiif.example.org" mode={:viewer} width={800} height={600} phx-hook="OpenSeadragon" id="viewer-1" /> \"\"\" end end See [the IIIF guide](https://hexdocs.pm/image_components/iiif.html) for the per-mode walkthrough and the JavaScript-viewer wiring recipe. """ use Phoenix.Component alias Image.Components.URL alias Image.Plug.Pipeline alias Image.Plug.Pipeline.Ops @doc """ Renders an IIIF image. See the moduledoc for mode descriptions. ### Attributes Common to all modes: * `src` — the IIIF identifier path. Required. * `host` — the IIIF server's host (e.g. `\"https://iiif.example.org\"`). Defaults to `\"\"`. * `iiif_prefix` — the server's IIIF version prefix segment. Defaults to `\"/iiif/3\"`. * `mode` — `:static` (default), `:tiles`, or `:viewer`. Static mode uses every per-transform attribute that `Image.Components.image/1` accepts (`width`, `height`, `fit`, `region`, `iiif_quality`, `format`, …) — see that component's docs. Tile mode (`mode={:tiles}`): * `source_width`, `source_height` — the source image's pixel dimensions, *required*. (Discover them from `info.json` if you don't have them — the discovery step is out of scope here.) * `tile_width` — pixel width of each rendered tile. Defaults to `512` (the IIIF Image API 3.0 default tile size). Pass the server's actual `tiles[0].width` from `info.json` for tightest cache alignment with the server's pre-computed tiles. * `tile_height` — defaults to `tile_width` (square tiles). * `scale_factor` — integer; the tiles cover `tile_width * scale_factor` source pixels each, rendered at `tile_width`. `1` is full resolution. Higher = more pixels per tile, fewer tiles total. Defaults to `1`. * `format`, `iiif_quality` — applied to every tile URL. Default `:jpeg` and `:default`. Viewer mode (`mode={:viewer}`): * `width`, `height` — pixel size of the viewer container. CSS pixels. Defaults to `800` × `600`. * `viewer` — informational atom passed through as `data-iiif-viewer="…"` so a JS hook can dispatch on viewer family. Defaults to `:openseadragon`. Other common values: `:mirador`, `:leaflet`. * `fallback_size` — width passed to the static fallback `` inside the viewer div. Shown before JS mounts. Defaults to `800`. * Any `phx-hook=…`, `id=…`, `class=…`, or other HTML attributes pass through to the outer `
` via `:rest`. ### Returns * Renders an `` (static), a wrapper `
` containing a CSS grid of `` tiles (tiles), or a wrapper `
` with viewer-mount data attributes plus a fallback `` (viewer). """ attr(:src, :string, required: true) attr(:host, :string, default: "") attr(:iiif_prefix, :string, default: "/iiif/3") attr(:mode, :atom, values: [:static, :tiles, :viewer], default: :static) # Static-mode attrs (shared with <.image>). attr(:width, :integer, default: nil) attr(:height, :integer, default: nil) attr(:fit, :atom, default: nil) attr(:region, :any, default: nil) attr(:iiif_quality, :atom, values: [:default, :color, :gray, :bitonal, nil], default: nil) attr(:format, :atom, default: nil) attr(:iiif_format, :atom, default: :jpeg) # Tiles-mode attrs. attr(:source_width, :integer, default: nil) attr(:source_height, :integer, default: nil) attr(:tile_width, :integer, default: 512) attr(:tile_height, :integer, default: nil) attr(:scale_factor, :integer, default: 1) # Viewer-mode attrs. attr(:viewer, :atom, default: :openseadragon) attr(:fallback_size, :integer, default: 800) attr(:rest, :global, include: ~w(alt class id loading decoding fetchpriority phx-hook phx-update style) ) def iiif(%{mode: :static} = assigns), do: render_static(assigns) def iiif(%{mode: :tiles} = assigns), do: render_tiles(assigns) def iiif(%{mode: :viewer} = assigns), do: render_viewer(assigns) # ── :static ───────────────────────────────────────────────────── defp render_static(assigns) do pipeline = build_static_pipeline(assigns) assigns = assign( assigns, :__src, URL.iiif(pipeline, source_path: assigns.src, host: assigns.host, iiif_prefix: assigns.iiif_prefix, iiif_format: assigns.iiif_format ) ) ~H""" """ end defp build_static_pipeline(assigns) do resize_fields = [ width: assigns[:width], height: assigns[:height], fit: assigns[:fit] ] |> Enum.reject(fn {_k, v} -> is_nil(v) end) format_fields = [type: assigns[:format]] |> Enum.reject(fn {_k, v} -> is_nil(v) end) ops = [] |> maybe_prepend_resize(resize_fields) |> maybe_prepend_quality(assigns[:iiif_quality]) |> maybe_prepend_region(assigns[:region]) output = case format_fields do [] -> nil fs -> struct(Ops.Format, fs) end %Pipeline{ops: ops, output: output} end defp maybe_prepend_resize(ops, []), do: ops defp maybe_prepend_resize(ops, fs), do: [struct(Ops.Resize, fs) | ops] defp maybe_prepend_quality(ops, nil), do: ops defp maybe_prepend_quality(ops, :default), do: ops defp maybe_prepend_quality(ops, :color), do: ops defp maybe_prepend_quality(ops, :gray) do [%Ops.Adjust{saturation: 0.0} | ops] end defp maybe_prepend_quality(ops, :bitonal) do [%Ops.Posterize{levels: 2} | ops] end defp maybe_prepend_region(ops, nil), do: ops defp maybe_prepend_region(ops, :full), do: ops defp maybe_prepend_region(ops, {units, x, y, w, h}) when units in [:pixels, :percent] do if Code.ensure_loaded?(Ops.Crop) do [struct(Ops.Crop, x: x, y: y, width: w, height: h, units: units) | ops] else ops end end defp maybe_prepend_region(ops, _), do: ops # ── :tiles ────────────────────────────────────────────────────── # # IIIF tile URLs follow a per-region/per-size template: # # {base}/{x},{y},{rw},{rh}/{w},{h}/0/{quality}.{format} # # where (x, y, rw, rh) is the region in source pixels, # (w, h) is the rendered tile size, and the rendered tile is a # downscaled view of the region: rw == w * scale_factor. # # The grid is built top-to-bottom, left-to-right; the last tile # in each row/column is clipped to whatever source pixels are # left, so the bottom-right tile is typically smaller than the # rest. The renderer doesn't pad — the grid mirrors the source # aspect. defp render_tiles(assigns) do if is_nil(assigns.source_width) or is_nil(assigns.source_height) do raise ArgumentError, """ `<.iiif mode={:tiles}>` requires `source_width` and `source_height` attributes (the source image's pixel dimensions). Discover them from the IIIF info.json document. Got src=#{inspect(assigns.src)}. """ end tile_height = assigns.tile_height || assigns.tile_width tiles = build_tile_grid( assigns.source_width, assigns.source_height, assigns.tile_width, tile_height, assigns.scale_factor ) |> Enum.map(fn tile -> Map.put(tile, :url, build_tile_url(tile, assigns)) end) rendered_w = ceil_div(assigns.source_width, assigns.scale_factor) rendered_h = ceil_div(assigns.source_height, assigns.scale_factor) cols = length(Enum.uniq_by(tiles, & &1.x)) grid_template_columns = String.duplicate("auto ", cols) |> String.trim() assigns = assign(assigns, tiles: tiles, grid_template_columns: grid_template_columns, rendered_width: rendered_w, rendered_height: rendered_h ) ~H"""
""" end # Walks the source pixel space in row-major order, emitting a # tile descriptor per cell. The last tile in each axis is # clipped to the remaining source pixels (so an N×N source with # tile T may produce a final partial tile of T-mod-N pixels). defp build_tile_grid(src_w, src_h, tile_w, tile_h, scale) do region_w = tile_w * scale region_h = tile_h * scale for y <- 0..(src_h - 1)//region_h, x <- 0..(src_w - 1)//region_w do remaining_w = min(region_w, src_w - x) remaining_h = min(region_h, src_h - y) rendered_w = ceil_div(remaining_w, scale) rendered_h = ceil_div(remaining_h, scale) %{ x: x, y: y, region_w: remaining_w, region_h: remaining_h, rendered_w: rendered_w, rendered_h: rendered_h } end end defp build_tile_url(tile, assigns) do quality = iiif_quality_token(assigns.iiif_quality) format = iiif_format_token(assigns.format || assigns.iiif_format) identifier_path = "#{assigns.host}#{assigns.iiif_prefix}/#{encode_identifier(assigns.src)}" region = "#{tile.x},#{tile.y},#{tile.region_w},#{tile.region_h}" size = "#{tile.rendered_w},#{tile.rendered_h}" "#{identifier_path}/#{region}/#{size}/0/#{quality}.#{format}" end defp iiif_quality_token(:gray), do: "gray" defp iiif_quality_token(:bitonal), do: "bitonal" defp iiif_quality_token(:color), do: "color" defp iiif_quality_token(_), do: "default" defp iiif_format_token(:jpeg), do: "jpg" defp iiif_format_token(:png), do: "png" defp iiif_format_token(:webp), do: "webp" defp iiif_format_token(:gif), do: "gif" defp iiif_format_token(:tiff), do: "tif" defp iiif_format_token(:jp2), do: "jp2" defp iiif_format_token(_), do: "jpg" # Strip leading slash, percent-encode embedded slashes — same # rule as the URL builder. defp encode_identifier(path) do path |> String.trim_leading("/") |> URI.encode(&URI.char_unreserved?/1) end defp ceil_div(a, b), do: div(a + b - 1, b) # ── :viewer ───────────────────────────────────────────────────── # # Emits a sized container `
` carrying enough data attributes # for a deep-zoom JS viewer (OpenSeadragon, Mirador, Leaflet-IIIF) # to mount on. The static fallback `` inside is what the user # sees before the JS runs (and what no-JS clients see permanently). # # We deliberately don't ship the JS — the user's app supplies it # via a Phoenix LiveView Hook or a plain mount script that listens # for elements matching `[data-iiif-viewer]`. The `data-iiif-info-url` # attribute is the only contract; viewers that speak IIIF Image API # 3.0 all read it the same way. defp render_viewer(assigns) do info_url = URL.iiif_info_url( source_path: assigns.src, host: assigns.host, iiif_prefix: assigns.iiif_prefix ) width = assigns.width || 800 height = assigns.height || 600 fallback_size = assigns.fallback_size || width fallback_pipeline = %Pipeline{ ops: [%Ops.Resize{width: fallback_size, upscale?: false}], output: %Ops.Format{} } fallback_url = URL.iiif(fallback_pipeline, source_path: assigns.src, host: assigns.host, iiif_prefix: assigns.iiif_prefix, iiif_format: assigns.iiif_format ) assigns = assign(assigns, __info_url: info_url, __fallback_url: fallback_url, __viewer_width: width, __viewer_height: height ) ~H"""
""" end end