`
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