Skip to main content

lib/image/components/iiif.ex

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 `<img>` 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 `<img>`.

    * **`:tiles` mode** — emits a CSS grid of `<img>` 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 `<div>` carrying
      `data-iiif-info-url=…`, ready for a JavaScript deep-zoom
      viewer (OpenSeadragon, Mirador, Leaflet-IIIF) to mount into.
      A static fallback `<img>` 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 `<img>`
    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 `<div>` via `:rest`.

  ### Returns

  * Renders an `<img>` (static), a wrapper `<div>` containing
    a CSS grid of `<img>` tiles (tiles), or a wrapper `<div>`
    with viewer-mount data attributes plus a fallback `<img>`
    (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"""
    <img src={@__src} {@rest} />
    """
  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"""
    <div
      style={"display:grid;grid-template-columns:#{@grid_template_columns};width:#{@rendered_width}px;height:#{@rendered_height}px;line-height:0;"}
      {@rest}
    >
      <img :for={tile <- @tiles} src={tile.url} width={tile.rendered_w} height={tile.rendered_h} />
    </div>
    """
  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 `<div>` carrying enough data attributes
  # for a deep-zoom JS viewer (OpenSeadragon, Mirador, Leaflet-IIIF)
  # to mount on. The static fallback `<img>` 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"""
    <div
      data-iiif-info-url={@__info_url}
      data-iiif-viewer={Atom.to_string(@viewer)}
      style={"width:#{@__viewer_width}px;height:#{@__viewer_height}px;"}
      {@rest}
    >
      <img src={@__fallback_url} style="width:100%;height:100%;object-fit:contain;" />
    </div>
    """
  end
end