Within the IIIF image delivery and manifest generation stage, this guide covers the one transformation everything downstream depends on: turning a single persisted museum object record into a spec-valid IIIF Presentation API 3.0 Manifest. The Manifest is the document a viewer loads — it declares the object’s label, its metadata pairs, its rights, and the Canvas that positions the image. Get its shape wrong and a technically well-formed JSON document still renders as a blank or broken object in Mirador or Universal Viewer, because those viewers enforce the Presentation 3.0 structure, not merely JSON syntax.

The problem is deceptively narrow. A record that already validated at ingestion and resolved its authority URIs still cannot be published directly: IIIF requires language maps rather than bare strings, a Canvas sized in pixels, a painting annotation whose target points back at that Canvas, and a rights value drawn from a fixed set of licence vocabularies. This guide specifies each of those constructions as a composable Python function, assembles them into a Manifest with a strict Pydantic v2 model, and proves the result round-trips against the spec before it ever reaches a viewer.

Workflow Context

By the time a record reaches manifest construction it has already been validated, persisted, and had its rights policy decided. Manifest building is a pure, deterministic projection: given one object row and a resolved image service base URL, it emits exactly one Manifest document, with no network calls and no side effects. That purity is what makes it testable and cacheable — the same record always produces byte-identical JSON, so a manifest can be regenerated on demand or materialized to object storage without coordination.

The shape of the source row is defined by designing museum object schemas, not by this stage. Manifest building reads that persisted shape — accession number, title, maker, date, medium, image dimensions, and a resolved rights URI — and never reaches back to re-derive any of it. Keeping the projection one-directional means a bulk re-render of ten thousand manifests after a viewer upgrade touches only this code path, not the ingestion or taxonomy layers. The two deeper guides in this section split the work: one on generating IIIF v3 manifests with Python covers the full model and serialization, and one on adding rights and requiredStatement blocks covers the fields a viewer displays and enforces.

Prerequisites

Before building manifests, confirm the following are in place:

  • Python 3.9+ for the PEP 604 | union syntax and dict[str, list[str]] builtins used in the language-map types below.
  • pydantic (v2) for the Manifest model; this guide uses the v2 API (model_config, ConfigDict, model_validate, model_dump) — see the Pydantic documentation for the alias and serialization behaviour the model relies on.
  • A target IIIF viewer to render against — Mirador 3 or Universal Viewer 4, both of which consume the IIIF Presentation API 3.0 structure.
  • A resolved image service base URL — the id of a level-1 or level-2 IIIF Image API 3.0 service for each image, without which the viewer cannot request tiles or a thumbnail. Configuring that endpoint is the subject of configuring the IIIF Image API.
  • Image pixel dimensions (width, height) for every image, read from the persisted record or the image service’s info.json; the Canvas is sized from these and a wrong value skews the viewer’s coordinate space.
  • A resolved rights URI on the record — a single RightsStatements.org or Creative Commons URI decided by the licensing stage, not free text.

Schema and Spec Reference

A Presentation 3.0 Manifest has a small set of required and strongly-recommended fields. The table below maps each to its role and the Python type this guide models it with. The distinction that trips up most first implementations is the language map: label, every metadata entry, and requiredStatement are objects keyed by BCP 47 language tag whose values are arrays of strings — never a bare string.

Field Requirement Type in this model Purpose
@context required str (fixed) Declares the Presentation 3.0 context URI
id required str (HTTPS URI) Dereferenceable identity of the Manifest
type required str = "Manifest" Resource class discriminator
label required language map Human-readable object title shown in the viewer
items required list[Canvas] The Canvases the viewer paints images onto
metadata recommended list[MetadataEntry] Label/value pairs displayed in the info panel
rights recommended str (single URI) Licence/rights statement the viewer enforces
requiredStatement recommended MetadataEntry Attribution the viewer must always display
thumbnail recommended list[ContentResource] Preview image for collection and search results

These fields map to a nested resource model. The LanguageMap alias captures the keyed-array shape once; every user-facing text field reuses it. The model sets extra="forbid" so an unmapped key from a drifting builder surfaces immediately, and aliases @context so the Python attribute stays a valid identifier.

python
from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field

# BCP 47 tag -> list of string values. Use "none" when no language applies.
LanguageMap = dict[str, list[str]]

PRESENTATION_CONTEXT = "http://iiif.io/api/presentation/3/context.json"


class ImageService3(BaseModel):
    id: str
    type: str = "ImageService3"
    profile: str = "level1"


class ContentResource(BaseModel):
    """An image body or thumbnail."""
    id: str
    type: str = "Image"
    format: str | None = None
    height: int | None = None
    width: int | None = None
    service: list[ImageService3] | None = None


class MetadataEntry(BaseModel):
    label: LanguageMap
    value: LanguageMap


class Annotation(BaseModel):
    id: str
    type: str = "Annotation"
    motivation: str = "painting"
    body: ContentResource
    target: str


class AnnotationPage(BaseModel):
    id: str
    type: str = "AnnotationPage"
    items: list[Annotation] = Field(default_factory=list)


class Canvas(BaseModel):
    id: str
    type: str = "Canvas"
    label: LanguageMap | None = None
    height: int
    width: int
    items: list[AnnotationPage] = Field(default_factory=list)


class Manifest(BaseModel):
    """Presentation API 3.0 Manifest for a single museum object."""
    model_config = ConfigDict(populate_by_name=True, extra="forbid")

    context: str = Field(default=PRESENTATION_CONTEXT, alias="@context")
    id: str
    type: str = "Manifest"
    label: LanguageMap
    metadata: list[MetadataEntry] = Field(default_factory=list)
    rights: str | None = None
    required_statement: MetadataEntry | None = Field(default=None, alias="requiredStatement")
    thumbnail: list[ContentResource] | None = None
    items: list[Canvas] = Field(default_factory=list)

Serializing with model_dump(by_alias=True, exclude_none=True) emits @context and requiredStatement under their spec keys and omits any recommended field the record did not supply, so an object with no thumbnail never writes a null the viewer must tolerate.

Step-by-Step Implementation

1. Build label and metadata as language maps

Every user-facing string becomes a language map. A small helper keeps the keyed-array shape in one place, and the metadata builder assembles the info-panel pairs, using the "none" key for values that carry no meaningful language — accession numbers and dates — and "en" for descriptive prose.

python
def language_map(value: str, lang: str = "en") -> LanguageMap:
    """Wrap a plain string as a single-language IIIF language map."""
    return {lang: [value]}


def build_metadata(record: dict[str, object]) -> list[MetadataEntry]:
    """Assemble the info-panel metadata pairs from a persisted record."""
    pairs = [
        ("Accession Number", record["accession_number"], "none"),
        ("Maker", record.get("creator") or "Unknown", "en"),
        ("Date", record.get("date_created") or "Undated", "none"),
        ("Medium", record.get("medium"), "en"),
    ]
    return [
        MetadataEntry(label=language_map(label), value=language_map(str(value), lang))
        for label, value, lang in pairs
        if value
    ]

Edge cases. A record migrated from a CSV export often stores the maker as an empty string rather than a true null; the record.get("creator") or "Unknown" guard collapses both to a single displayed value. For records harvested from LIDO XML or an API payload with genuinely multilingual titles, extend language_map to accept a {lang: [values]} dict directly rather than a single string, so a bilingual label carries both "en" and "fr" keys. Never emit an empty language map — a label of {} fails the viewer’s display logic.

2. Build the image body and its Image API service

The painting annotation’s body is a ContentResource pointing at a concrete image request URL, and it embeds an ImageService3 whose id is the base the viewer appends region, size, and rotation parameters to when zooming.

python
def build_image_body(
    image_base: str, image_id: str, width: int, height: int
) -> ContentResource:
    """Build the painting body plus its IIIF Image API service."""
    service_id = f"{image_base}/{image_id}"
    return ContentResource(
        id=f"{service_id}/full/max/0/default.jpg",
        type="Image",
        format="image/jpeg",
        width=width,
        height=height,
        service=[ImageService3(id=service_id, profile="level1")],
    )

Edge cases. The service_id must be the exact URL the Image API answers info.json at; a trailing slash or a wrong casing breaks tile requests silently while the /full/max/ body still loads, so the object appears but will not deep-zoom. If the source is a static JPEG with no Image API behind it, omit the service list entirely — a viewer falls back to the single body.id image — but you then lose tiling. Set profile to "level2" only if the service genuinely supports arbitrary-size requests.

3. Build the Canvas and its painting AnnotationPage

The Canvas is the spatial frame; the image is painted onto it by an Annotation with motivation: "painting" whose target is the Canvas id. That annotation lives inside an AnnotationPage, which lives inside the Canvas items. The Canvas dimensions must match the image body’s pixel dimensions.

python
def build_canvas(
    manifest_id: str, index: int, body: ContentResource, label: str
) -> Canvas:
    """Build one Canvas with a painting annotation targeting itself."""
    canvas_id = f"{manifest_id}/canvas/p{index}"
    annotation = Annotation(
        id=f"{canvas_id}/annotation/p{index}-image",
        body=body,
        target=canvas_id,
    )
    page = AnnotationPage(id=f"{canvas_id}/page/p{index}", items=[annotation])
    return Canvas(
        id=canvas_id,
        label=language_map(label),
        width=body.width or 0,
        height=body.height or 0,
        items=[page],
    )

Edge cases. The single most common validation failure is a target that does not equal the enclosing Canvas id — the viewer then has an image with nowhere to paint it and renders nothing. Keep the id minting in one place, as above, so the Canvas and its annotation cannot drift. A single-image object has one Canvas; a multi-page object (a book, a portfolio) has one Canvas per page and, for structured navigation, IIIF Ranges — covered in structuring IIIF Collections and Ranges. A Canvas with zero-valued width/height is invalid; treat a missing dimension as a hard error, not a default.

4. Assemble the Manifest with rights, requiredStatement, and thumbnail

The top-level assembly stitches the pieces together and attaches the recommended fields. The rights value passes through unchanged from the record — this stage never invents it — and requiredStatement carries the attribution the viewer is obliged to keep on screen.

python
def build_manifest(record: dict[str, object], image_base: str) -> Manifest:
    """Project one persisted museum record into a Presentation 3.0 Manifest."""
    accession = str(record["accession_number"])
    manifest_id = f"https://iiif.example.org/manifest/{accession}"
    body = build_image_body(
        image_base,
        str(record["image_id"]),
        int(record["image_width"]),
        int(record["image_height"]),
    )
    canvas = build_canvas(manifest_id, 1, body, str(record["title"]))

    thumbnail = ContentResource(
        id=f"{image_base}/{record['image_id']}/full/!300,300/0/default.jpg",
        type="Image",
        format="image/jpeg",
        service=[ImageService3(id=f"{image_base}/{record['image_id']}")],
    )
    attribution = str(record.get("attribution") or "Provided by the museum")
    return Manifest(
        id=manifest_id,
        label=language_map(str(record["title"])),
        metadata=build_metadata(record),
        rights=record.get("rights"),  # a single CC or RightsStatements.org URI
        required_statement=MetadataEntry(
            label=language_map("Attribution"),
            value=language_map(attribution),
        ),
        thumbnail=[thumbnail],
        items=[canvas],
    )

Edge cases. The id must be an HTTPS URI that resolves to this very document; a relative path or an http:// URI is a frequent cause of viewers refusing to load it under a secure origin. If record["rights"] is None because the licensing stage has not yet decided, omit the field — exclude_none=True handles that — rather than guessing a permissive licence. For an API-sourced record where dimensions are absent, fetch info.json before building rather than defaulting the Canvas size.

5. Serialize to spec-conformant JSON

The final step lowers the model to the exact JSON a viewer expects. by_alias=True restores @context and requiredStatement; exclude_none=True drops unset recommended fields.

python
import json


def render_manifest(record: dict[str, object], image_base: str) -> str:
    """Return the serialized Presentation 3.0 Manifest as a JSON string."""
    manifest = build_manifest(record, image_base)
    payload = manifest.model_dump(by_alias=True, exclude_none=True)
    return json.dumps(payload, indent=2, ensure_ascii=False)

Edge cases. Keep ensure_ascii=False so diacritics in a maker’s name serialize as UTF-8 rather than escaped sequences — both are valid JSON, but the readable form eases debugging and matches what harvesters expect. The serialized document is deterministic, so materializing it to object storage under the accession number lets an edge cache serve it without re-running the builder.

Record to spec-valid IIIF Manifest data flow A persisted museum object record flows left to right through four build stages. First it is projected into label and metadata language maps. Next a Canvas is built carrying a painting annotation whose target is the Canvas itself. These are assembled into a Manifest, whose rights field is supplied from below by a resolved RightsStatements.org or Creative Commons URI. Finally the Manifest is serialized and validated against the Presentation 3.0 structure, producing spec-conformant JSON a viewer can render. Object record to spec-valid Manifest — a deterministic projection Object record persisted CMS row Label & metadata language maps BCP 47 · "none" Canvas + annotation painting · target Assemble Manifest @context · items Serialize & validate conformant JSON Rights URI RightsStatements · CC valid rights

Rights and Access Routing

At this stage the rights field is where publication policy becomes machine-enforceable. It is a single string — one URI drawn from RightsStatements.org or a Creative Commons licence or public-domain tool — and a conformant viewer both displays it and enforces it: a Mirador instance uses it to decide whether a download action is offered, and an aggregator uses it to filter what it will re-expose. Because it is a single value, the licensing decision must already be resolved before the manifest is built; this stage never classifies, it only carries the URI the record supplies.

That URI does not originate here. It is assigned upstream by mapping RightsStatements.org to collection fields, which turns legacy free-text rights columns into resolvable URIs. If that mapping left a record’s rights unresolved, the correct behaviour here is to omit the rights field and hold the manifest back from publication — never to default to a permissive licence, which would expose an in-copyright asset with a public download affordance. Attribution obligations that must always stay on screen belong in requiredStatement, not rights; the full treatment of both fields, including how a NoC statement differs from a CC BY licence in what the viewer permits, is the subject of adding rights and requiredStatement blocks.

Verification and Testing

Prove the manifest’s shape with assert-based tests before pointing a viewer at it. The test below builds a manifest from a representative record and checks the four invariants a viewer actually enforces: the context and top-level type, the language-map form of label, the painting annotation’s self-target, and a clean serialization round-trip.

python
def test_manifest_shape() -> None:
    record = {
        "accession_number": "1994.007",
        "title": "Portrait of a Collector",
        "creator": "Unknown",
        "date_created": "1898",
        "medium": "Oil on canvas",
        "image_id": "1994-007",
        "image_width": 4000,
        "image_height": 5200,
        "rights": "https://creativecommons.org/publicdomain/mark/1.0/",
        "attribution": "Collection of the Example Museum",
    }
    manifest = build_manifest(record, "https://images.example.org/iiif/3")
    payload = manifest.model_dump(by_alias=True, exclude_none=True)

    # @context and top-level type are mandatory in Presentation 3.0.
    assert payload["@context"] == "http://iiif.io/api/presentation/3/context.json"
    assert payload["type"] == "Manifest"
    # label is a language map, never a bare string.
    assert payload["label"] == {"en": ["Portrait of a Collector"]}
    # The single Canvas carries a painting annotation that targets itself.
    canvas = payload["items"][0]
    annotation = canvas["items"][0]["items"][0]
    assert annotation["motivation"] == "painting"
    assert annotation["target"] == canvas["id"]
    # Canvas dimensions match the image body.
    assert (canvas["width"], canvas["height"]) == (4000, 5200)
    # Serialized form re-validates against the model.
    Manifest.model_validate(payload)


if __name__ == "__main__":
    test_manifest_shape()
    print("manifest OK")

For a document-level check, serialize a manifest and confirm it is well-formed JSON, then run it through the official IIIF Presentation validator, which catches structural violations a Python model cannot express:

bash
python -m sync.iiif build --record 1994.007 > manifest.json
python -m json.tool manifest.json > /dev/null && echo "well-formed JSON"
# then submit manifest.json to the hosted validator at iiif.io/api/presentation/validator/

A clean run reports well-formed JSON and passes the validator’s required-field and reference checks. Any failure there — a dangling target, a bare-string label, a missing Canvas dimension — is a structural bug to fix before a viewer ever sees the document.

Where to Go Deeper

FAQ

Why does my manifest render blank in a viewer even though the JSON is valid?

Valid JSON is not the same as a valid Manifest. The usual cause is a painting annotation whose target does not equal its enclosing Canvas id, so the viewer has an image with no Canvas to paint it onto and shows nothing. Mint the Canvas id once and reuse it for the annotation target, as build_canvas does, then confirm with the annotation["target"] == canvas["id"] assertion.

Do I really need width and height on every Canvas?

Yes for image content. The Canvas dimensions define the coordinate space the viewer paints into and drive its zoom and navigation, so a missing or zero-valued width/height produces an unusable Canvas. Read the pixel dimensions from the persisted record or the Image API info.json, and treat an absent dimension as a hard error rather than defaulting it.

Should every label and metadata value carry a language code?

Every value must be a language map, but not every value needs a real language. Use a BCP 47 tag such as "en" for descriptive prose that a translation could apply to, and the reserved key "none" for values with no linguistic content — accession numbers, dates, dimensions. What you must never emit is a bare string; label: "Portrait" is invalid where label: {"en": ["Portrait"]} is correct.

Where does the rights value come from, and what if it is wrong or missing?

The rights field carries a single RightsStatements.org or Creative Commons URI resolved upstream by the licensing stage — this builder only passes it through. If the record’s rights are unresolved, omit the field and hold the manifest back from publication; never default to a permissive licence, because a conformant viewer uses rights to decide whether to offer a download, and a wrong value can expose an in-copyright asset.