Operational Context

A Python developer has a working manifest for a three-volume folio — every leaf is digitized, and each page is a Canvas in the Manifest’s items array in the correct reading order. The manifest opens in a viewer, and the reader can page from the front board of volume one straight through to the back board of volume three. What they cannot do is jump to “Volume II, Chapter 4,” because a flat list of two hundred Canvases carries no intra-object structure: the viewer has nothing to render as a table of contents, and the archival hierarchy that exists in the collections management system is lost at the delivery boundary. The same failure hits an archival series modeled as a single Manifest — boxes, folders, and items collapse into one undifferentiated scroll of Canvases.

This page resolves that exact gap. It expresses the volume/chapter/plate hierarchy as a nested Range tree in the Manifest’s structures property, so a IIIF viewer renders a navigable outline over the same Canvases without duplicating a single pixel of image data. It sits inside the structuring IIIF Collections and Ranges stage and consumes the manifest produced upstream by generating IIIF v3 Manifests with Python — structure is added after the Canvas list exists, never before.

Root Cause Analysis

Three distinct problems make multi-volume structure fail, and each has one root cause rather than a spread of unrelated bugs.

First, a flat Canvas list has no expressible hierarchy. The Manifest’s items array is a single ordered sequence — it answers “what comes next?” but never “what contains this?” A three-volume folio, an illuminated manuscript with numbered gatherings, or a records series with box/folder/item nesting all have real tree structure that a linear array simply cannot hold. Structure is a separate concern, and IIIF gives it a separate property: structures, an array of Range objects that sits alongside items, not inside it.

Second, Ranges must reference Canvases that already exist, by id. A Range does not contain a Canvas; it points at one. Its items array holds lightweight {"id": ..., "type": "Canvas"} references whose ids must exactly match Canvas ids in the Manifest’s items. The commonest failure is a Range that references a Canvas id with a trailing slash, a stale base URL, or a page that was never added — a dangling reference that some viewers silently drop and others reject outright. The Canvas body lives exactly once, in items; the Range tree is pure navigation metadata layered over it.

Third, behavior hints drive rendering, and their absence is a decision by default. Whether a viewer paints two-page openings, a continuous scroll, or individual leaves is governed by the behavior property, and whether a Range appears in the table of contents or stays hidden is governed by Range-level behavior such as no-nav. Omit them and you inherit the viewer’s default, which is rarely what a bound folio or a rolled scroll should look like. The remedy on all three fronts is the same: build a nested Range tree whose leaf Ranges reference existing Canvas ids, validate every reference against the Manifest’s items, and attach explicit behavior plus a deliberate start Canvas.

Canonical Solution

The builder below takes a finished manifest (its items already populated) and a declarative tree of nodes, then emits a nested structures array. Each node is either a grouping Range (a volume or chapter, holding child Ranges) or a leaf Range (a chapter or plate section, holding Canvas references). Every referenced Canvas id is checked against the Manifest’s items as the tree is built, so a dangling reference raises immediately instead of shipping. The code is Python 3.9+ and depends only on the standard library.

python
from __future__ import annotations

from dataclasses import dataclass, field


@dataclass
class RangeNode:
    """One node in the intra-object structure tree.

    A node is either a *grouping* range (volume, chapter) that nests child nodes,
    or a *leaf* range whose canvas_ids point at Canvases already in the Manifest.
    A node never holds both children and canvas_ids — mixing structure and leaves
    on one Range makes the table of contents ambiguous.
    """
    label: str
    canvas_ids: list[str] = field(default_factory=list)      # leaf: Canvases in reading order
    children: list["RangeNode"] = field(default_factory=list)  # grouping: nested Ranges
    behavior: list[str] | None = None                        # e.g. ["no-nav"] to hide from the TOC


class RangeReferenceError(ValueError):
    """A Range referenced a Canvas id that is not present in the Manifest's items."""


def _canvas_ids(manifest: dict) -> set[str]:
    # The Manifest's items are the single source of truth for what Canvases exist.
    # Any Range reference outside this set is a structural error, never ignored.
    return {c["id"] for c in manifest.get("items", [])}


def _build_range(node: RangeNode, base: str, seq: dict[str, int], known: set[str]) -> dict:
    seq["n"] += 1
    rng: dict = {
        "id": f"{base}/range/r{seq['n']}",     # stable, dereferenceable Range id
        "type": "Range",
        "label": {"en": [node.label]},
        "items": [],
    }
    if node.behavior:
        rng["behavior"] = node.behavior        # e.g. no-nav to keep loose plates out of the TOC
    # Grouping ranges nest child Ranges; leaf ranges reference Canvases by id. A node
    # carrying both is a modeling mistake, so reject it rather than emit an odd tree.
    if node.children and node.canvas_ids:
        raise ValueError(f"Range '{node.label}' has both child Ranges and Canvas refs")
    for child in node.children:
        rng["items"].append(_build_range(child, base, seq, known))
    for cid in node.canvas_ids:
        if cid not in known:
            raise RangeReferenceError(f"Range '{node.label}' references unknown Canvas {cid!r}")
        # Reference by id and type only — the Canvas body lives once, in items.
        rng["items"].append({"id": cid, "type": "Canvas"})
    return rng


def add_structures(
    manifest: dict,
    tree: list[RangeNode],
    start_canvas_id: str | None = None,
    manifest_behavior: list[str] | None = None,
) -> dict:
    """Attach a nested Range tree (and optional start Canvas) to an existing Manifest."""
    known = _canvas_ids(manifest)
    seq = {"n": 0}
    base = manifest["id"].rstrip("/")
    manifest["structures"] = [_build_range(node, base, seq, known) for node in tree]
    if manifest_behavior:
        manifest["behavior"] = manifest_behavior   # paged | continuous | individuals
    if start_canvas_id is not None:
        if start_canvas_id not in known:
            raise RangeReferenceError(f"start Canvas {start_canvas_id!r} not in items")
        # The viewer opens here instead of the first leaf — e.g. the title page of
        # volume II rather than the front board of volume I.
        manifest["start"] = {"id": start_canvas_id, "type": "Canvas"}
    return manifest

Declaring the folio’s structure is now a plain data literal — the tree mirrors the physical object, and the builder turns it into spec-conformant Ranges:

python
BASE = "https://example.org/iiif/folio-1723"


def canvas(n: int) -> str:
    return f"{BASE}/canvas/p{n}"


tree = [
    RangeNode(label="Volume I", children=[
        RangeNode(label="Chapter 1", canvas_ids=[canvas(1), canvas(2), canvas(3)]),
        RangeNode(label="Chapter 2", canvas_ids=[canvas(4), canvas(5)]),
    ]),
    RangeNode(label="Volume II", children=[
        RangeNode(label="Chapter 3", canvas_ids=[canvas(6), canvas(7)]),
        # Loose plates are real Canvases but should not clutter the outline:
        RangeNode(label="Plates", canvas_ids=[canvas(8)], behavior=["no-nav"]),
    ]),
]

manifest = {"id": BASE, "type": "Manifest", "items": [
    {"id": canvas(n), "type": "Canvas"} for n in range(1, 9)
]}

add_structures(
    manifest,
    tree,
    start_canvas_id=canvas(6),   # open on Volume II's first page
    manifest_behavior=["paged"], # render two-page openings, as a bound book
)

Because every leaf Range references a Canvas id and never a Canvas body, the structures tree adds only kilobytes of navigation metadata regardless of how many gigabytes of tiles the object carries. Re-running the builder is idempotent — it rebuilds structures from the declarative tree each time — and a typo in any Canvas id fails loudly at build time rather than degrading navigation for a reader.

A nested Range tree references, but never copies, the Manifest's Canvases Top to bottom: a Manifest node with structures and start branches by solid arrows into two grouping Ranges, Volume I and Volume II. Each volume branches into chapter Ranges — Chapter 1 and Chapter 2 under Volume I, Chapter 3 and a no-nav Plates range under Volume II. The bottom of the figure is the flat manifest items array, a strip of six Canvas boxes. Dashed arrows run from each leaf chapter Range down to individual Canvases in that strip, illustrating reference-by-id. The Canvas that the start property points at is outlined in the accent colour. Nested Range tree — leaf Ranges reference Canvas ids in the flat items list Manifest structures[] · start Range · Volume I grouping Range · Volume II grouping Chapter 1 leaf Chapter 2 leaf Chapter 3 leaf Plates behavior: no-nav manifest['items'] — one Canvas per id, referenced not copied Canvas p1 id Canvas p3 id Canvas p4 id Canvas p6 start Canvas Canvas p7 id Canvas p8 id nests (grouping → child Range) references (leaf Range → Canvas id)

Edge Cases and Variants

  • Single volume vs. multi-volume. A single bound volume needs only one level of Ranges (chapters under the Manifest) — you can skip the volume tier entirely rather than wrapping everything in a lone “Volume I” node. Multi-volume and archival series add the outer grouping tier; the builder handles both because grouping and leaf nodes are the same type.
  • Unpaginated inserts and loose plates. Foldouts, errata slips, and inserted plates are genuine Canvases but rarely belong in the reading outline. Give their Range a ["no-nav"] behavior so the viewer keeps them navigable by direct link yet out of the table of contents, exactly as the Plates node does above.
  • Continuous scrolls. A single rolled scroll or a horizontally-stitched panorama is one logical unit, not a paged book. Set the Manifest behavior to ["continuous"] (or ["individuals"] for unbound single leaves) instead of ["paged"]; the Range tree is unchanged, only the rendering hint differs.
  • Mismatched Canvas ids. The most frequent breakage is a Range id that almost matches — a trailing slash, an http vs https scheme, or a Canvas that was renumbered after the tree was written. Because add_structures validates every reference against the live items set, these surface as a RangeReferenceError at build time. Never hand-edit Range ids to “fix” a mismatch; regenerate the tree from the canonical id function.
  • Ranges spanning volumes. A themed sub-collection (all the maps across three volumes, say) can be modeled as an additional top-level Range whose leaf references pull Canvases from several volumes. Ranges are a graph over the Canvases, not a partition, so one Canvas may appear in more than one Range.

Validation

The invariant that matters is simple: every Canvas id anywhere in the Range tree must resolve to a Canvas in the Manifest’s items. This pytest walks the emitted structures recursively and asserts exactly that, plus the start-Canvas guarantee, with no server or viewer required:

python
# test_ranges.py  ->  run with:  pytest -q test_ranges.py
import pytest

from build_ranges import RangeNode, RangeReferenceError, add_structures


def _manifest(n: int) -> dict:
    base = "https://example.org/iiif/obj"
    return {"id": base, "type": "Manifest",
            "items": [{"id": f"{base}/canvas/p{i}", "type": "Canvas"} for i in range(1, n + 1)]}


def _referenced_canvas_ids(structures: list[dict]) -> set[str]:
    found: set[str] = set()
    for rng in structures:
        for item in rng.get("items", []):
            if item.get("type") == "Canvas":
                found.add(item["id"])
            elif item.get("type") == "Range":
                found |= _referenced_canvas_ids([item])
    return found


def test_every_range_reference_exists_in_items():
    m = _manifest(4)
    ids = [c["id"] for c in m["items"]]
    tree = [RangeNode("Vol I", children=[RangeNode("Ch 1", canvas_ids=ids[:2]),
                                         RangeNode("Ch 2", canvas_ids=ids[2:])])]
    add_structures(m, tree)
    known = set(ids)
    assert _referenced_canvas_ids(m["structures"]) <= known   # no dangling references


def test_dangling_reference_raises():
    m = _manifest(2)
    tree = [RangeNode("Ch 1", canvas_ids=["https://example.org/iiif/obj/canvas/p99"])]
    with pytest.raises(RangeReferenceError):
        add_structures(m, tree)


def test_start_canvas_must_be_a_real_canvas():
    m = _manifest(3)
    with pytest.raises(RangeReferenceError):
        add_structures(m, [RangeNode("Ch 1", canvas_ids=[m["items"][0]["id"]])],
                       start_canvas_id="https://example.org/iiif/obj/canvas/p50")

A green run confirms that the tree never references a Canvas outside items, that a typo’d id is rejected rather than shipped, and that the start Canvas is always a real Canvas. Save the builder as build_ranges.py so the import resolves.

Standards Alignment

The structures property, the Range type, and the start property are all defined by the IIIF Presentation API 3.0. A Range’s items may contain nested Ranges, Canvas references, or SpecificResource fragments that target part of a Canvas, which is how a table of contents entry can point at a region of a page rather than a whole leaf. The behavior values used here — paged, continuous, individuals at the Manifest level and no-nav at the Range level — are drawn directly from the specification’s behavior vocabulary, and each is a rendering hint a conforming viewer honours. This structure work is the delivery-side expression of hierarchy that begins in the collections system: the object identity resolved during building IIIF Presentation Manifests supplies the Canvas ids the Range tree references, and the parent IIIF delivery stage is where these manifests are published for viewers to consume.

FAQ

Do IIIF Ranges duplicate the Canvas image data?

No. A Range references a Canvas by its id with a lightweight {"id": ..., "type": "Canvas"} object; the Canvas body — its width, height, and painting annotations — lives exactly once, in the Manifest’s items array. The entire structures tree is navigation metadata measured in kilobytes, independent of the object’s tile size, which is why one Canvas can appear in several Ranges without cost.

What behavior do I put on a Range to build a table of contents?

Nothing special — a nested Range tree with label on each Range is the table of contents, and conforming viewers render it automatically. Behavior is used the other way around: apply ["no-nav"] to a Range you want kept out of the outline (loose plates, inserts), and set Manifest-level behavior such as ["paged"] or ["continuous"] to control how the pages themselves are laid out.

How do I choose the start Canvas for a multi-volume object?

Set the Manifest’s start property to the Canvas the reader should land on — often the title page of the first volume rather than its front board, or the opening leaf of the volume most requested. It must be a Canvas that exists in items; add_structures enforces that and raises if the id is wrong, so a bad start reference can never ship silently.

Can one Range span Canvases from two different volumes?

Yes. Ranges form a graph over the Canvases, not a strict partition, so a themed Range — every map or every plate across the whole object — can reference Canvases from multiple volumes, and those Canvases still appear in their own volume Ranges too. A single Canvas belonging to more than one Range is valid and common.