Operational Context

A Python developer runs the batch publish job that turns every cleared object record into a delivery manifest, and the job “succeeds” — files are written, HTTP 200s are logged — yet a fraction of them render as a blank viewer with no image, no label, and no error the operator can see. This is the defining failure of manifest generation: a IIIF Presentation 3.0 document that is valid JSON but not a valid Manifest is silently rejected by the client. Mirador, the Universal Viewer, and Clover do not throw; they simply decline to paint a resource they cannot resolve, so a manifest missing its @context, carrying a lowercase type, using a bare string where a language map belongs, or holding a Canvas with no height/width, disappears from the collection with no stack trace to chase.

This page resolves that exact task: emitting one spec-valid v3 Manifest from a single object record, and eliminating the four or five structural mistakes that make an otherwise well-formed document unusable. It sits inside the building IIIF Presentation manifests topic area and feeds the wider IIIF image delivery and manifest generation stage — the downstream surface that exposes the records the rest of the pipeline has ingested, persisted, and cleared for access.

Root Cause Analysis

Every one of these failures traces to the same origin: developers carry a v2 mental model into a v3 document. The IIIF Presentation API changed shape between 2.1 and 3.0, and the changes are structural, not cosmetic.

The @context and type casing. A v3 Manifest MUST declare "@context": "http://iiif.io/api/presentation/3/context.json" at the top level, and every resource’s type is a PascalCase term — Manifest, Canvas, AnnotationPage, Annotation, Image. v2 used @type with prefixed values like sc:Manifest and dctypes:Image; paste those into a v3 document and the client cannot expand the JSON-LD, so it treats the whole tree as untyped noise.

Labels are language maps, not strings. The single most common defect. In v2, label was a bare string. In v3, label, summary, and every human-readable field is a language map: a BCP-47 language tag mapped to a list of strings, e.g. {"en": ["Portrait of a Collector"]}. The escape hatch for language-neutral values is the tag "none". A viewer handed "label": "Portrait" finds no map to read and renders nothing where the title should be.

Canvas dimensions and the painting annotation. A Canvas that will display an image MUST carry integer height and width, because the Canvas is a coordinate space the viewer lays the image onto. The image itself is attached through an AnnotationPage holding an Annotation whose motivation is exactly "painting" — that motivation is what says “this resource is the visible content of the Canvas.” Use "supplementing" (correct for OCR text or a transcription, wrong for the image) and the image is loaded as a side resource that never paints.

Every id must dereference. In v3 the property is id (not @id), and it must be a real, resolvable HTTP(S) URI. Opaque values like urn:obj:1 or a bare accession number break the Canvas and Annotation identity the viewer relies on to wire targets together. The Manifest id is the URL the manifest is served from; each Canvas id is a URL beneath it.

Canonical Solution

The builder below turns one record into a spec-valid Manifest with a single Canvas per image, each Canvas carrying an AnnotationPage → painting AnnotationImage body that points at the object’s IIIF Image API service. It is deterministic and dependency-free, so it drops straight into a batch publish loop.

python
from __future__ import annotations

from dataclasses import dataclass, field

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


@dataclass
class ImageAsset:
    # One digitized image. `id` is the full URL of a real image resource; `service`
    # is the IIIF Image API base (no /info.json, no region/size path segments).
    id: str
    width: int              # pixel width — REQUIRED on both the body and the Canvas
    height: int             # pixel height
    service: str            # Image API service base, e.g. https://iiif.museum.org/obj-1
    media_type: str = "image/jpeg"


@dataclass
class ObjectRecord:
    accession: str          # museum accession number — used only to build id URLs
    title: str              # human title; becomes a language map, never a bare string
    language: str = "en"    # BCP-47 tag for the title's language map
    images: list[ImageAsset] = field(default_factory=list)


def lang_map(value: str, lang: str) -> dict[str, list[str]]:
    # v3 replaced v2's bare-string label with a language map: BCP-47 tag -> LIST of
    # strings. Use "none" for language-neutral values. Emitting a plain string here
    # is the number-one reason a v3 viewer shows an object with no visible title.
    return {lang: [value]}


def build_canvas(canvas_base: str, index: int, img: ImageAsset, lang: str) -> dict:
    canvas_id = f"{canvas_base}/canvas/p{index}"
    page_id = f"{canvas_id}/annopage/1"
    anno_id = f"{page_id}/anno/1"
    return {
        "id": canvas_id,                      # dereferenceable URL, not an opaque id
        "type": "Canvas",                     # PascalCase — "canvas" is not valid
        "height": img.height,                 # Canvas dimensions are REQUIRED for images
        "width": img.width,
        "label": lang_map(f"p. {index}", lang),
        "items": [
            {
                "id": page_id,
                "type": "AnnotationPage",
                "items": [
                    {
                        "id": anno_id,
                        "type": "Annotation",
                        "motivation": "painting",   # "painting" == the image IS the content
                        "body": {
                            "id": img.id,
                            "type": "Image",        # resource type, not "dctypes:Image"
                            "format": img.media_type,
                            "height": img.height,
                            "width": img.width,
                            "service": [
                                {
                                    "id": img.service,
                                    "type": "ImageService3",
                                    "profile": "level1",
                                }
                            ],
                        },
                        "target": canvas_id,        # paint the body ONTO this Canvas
                    }
                ],
            }
        ],
    }


def build_manifest(rec: ObjectRecord, base_url: str) -> dict:
    if not rec.images:
        # A Manifest with an empty `items` array is structurally invalid: a viewer
        # has nothing to page through. Fail loudly here, before the file is written.
        raise ValueError(f"{rec.accession}: a Manifest needs at least one Canvas")
    object_base = f"{base_url.rstrip('/')}/{rec.accession}"
    return {
        "@context": PRESENTATION_CONTEXT,     # single string context — MUST be present
        "id": f"{object_base}/manifest",      # the URL this manifest is served from
        "type": "Manifest",
        "label": lang_map(rec.title, rec.language),
        "items": [
            build_canvas(object_base, i, img, rec.language)
            for i, img in enumerate(rec.images, start=1)
        ],
    }

Wiring it into a publish pass is a json.dumps per record; because the builder is pure, re-running the job overwrites each manifest byte-for-byte, so the pass is idempotent.

python
import json
from pathlib import Path


def publish(records: list[ObjectRecord], base_url: str, out_dir: Path) -> None:
    for rec in records:
        manifest = build_manifest(rec, base_url)
        assert_publishable(manifest)          # gate every document before it is written
        target = out_dir / rec.accession / "manifest.json"
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2))

The nesting the code assembles is the part most developers get wrong, because a v2 manifest attached images directly to a Canvas while v3 routes them through two intermediate layers. The diagram traces the required path from the top-level Manifest down to the painted image, and the target reference that ties the annotation back to its Canvas.

The required Manifest to Canvas to AnnotationPage to painting Annotation to Image nesting in IIIF v3 Left to right: a Manifest with @context, id, type and a label language map holds a Canvas with required height and width; the Canvas holds an AnnotationPage; the AnnotationPage holds an Annotation with motivation painting whose body is an Image with an ImageService3 service and whose target points back to the Canvas. A dashed anti-pattern box shows a bare string label that a v3 viewer silently rejects. Two layers separate a Manifest from the image it paints target = Canvas id Manifest @context (v3) id · dereferenceable label {lang:[..]} items[ ] Canvas type: Canvas height (required) width (required) items[ ] AnnotationPage type: AnnotationPage holds content annotations items[ ] Annotation motivation: "painting" target · body Image body type: Image format: image/jpeg service: ImageService3 items[0] items[0] items[0] body Anti-pattern "label": "Portrait" bare string · v3 viewer shows no title

Edge Cases and Variants

  • Single image vs. multi-Canvas. A single-image object is one Canvas; a book, folio, or multi-view object is one Canvas per view, in reading order. The builder already iterates rec.images, so a paged object needs no code change — only more ImageAsset entries. Sequencing those pages into named structures (front matter, plates, volumes) is a separate concern handled by modeling multi-volume objects as IIIF ranges.
  • Born-digital vs. scanned. A scanned object has authoritative pixel dimensions from the Image API info.json; a born-digital asset (a native TIFF, a rendered 3D still) may only know its own header dimensions. Either way, height/width must be real integers on the Canvas and body — never 0, null, or a guessed placeholder, which collapses the coordinate space.
  • Missing dimensions. If a record reaches the builder without width/height, do not emit the Canvas with zeros. Fetch info.json from the Image API service and read width/height from it, or route the record to the same quarantine state a schema failure would take, exactly as the ingestion stage does for malformed rows.
  • JPEG body vs. Image API service. The body.id should be a concrete, fetchable image (often the Image API full/max/0/default.jpg), while the service block advertises deep zoom. Omitting the service still validates, but the viewer loses tiled panning; omitting a real body.id and pointing only at the service base is the more damaging error, because the fallback image never resolves.
  • Non-Latin and multilingual titles. A record with titles in two languages populates one language map with two tags — {"en": ["..."], "ar": ["..."]} — rather than concatenating them into one string. Keep ensure_ascii=False on json.dumps so diacritics and non-Latin scripts serialize as UTF-8, not \uXXXX escapes.
  • Rights and attribution. The rights and requiredStatement properties are omitted above to keep the structure clear; they are mandatory for most published objects and are added in adding rights and requiredStatement blocks to IIIF manifests.

Validation

Do not trust a 200 from the write step — assert the structural invariants before the file leaves the job. The check below proves the five properties a silent-rejection bug hides behind: the top-level keys exist, type is exact, label is a map, every Canvas has integer dimensions, and the content annotation is a painting annotation.

python
# test_manifest.py  ->  run with:  pytest -q test_manifest.py
REQUIRED_MANIFEST_KEYS = {"@context", "id", "type", "label", "items"}


def assert_publishable(manifest: dict) -> None:
    missing = REQUIRED_MANIFEST_KEYS - manifest.keys()
    assert not missing, f"manifest missing {missing}"
    assert manifest["type"] == "Manifest", "type must be exactly 'Manifest'"
    assert isinstance(manifest["label"], dict), "label must be a language map, not a string"
    assert manifest["items"], "a Manifest needs at least one Canvas"
    for canvas in manifest["items"]:
        assert canvas["type"] == "Canvas"
        assert isinstance(canvas.get("height"), int) and canvas["height"] > 0
        assert isinstance(canvas.get("width"), int) and canvas["width"] > 0
        anno = canvas["items"][0]["items"][0]
        assert anno["motivation"] == "painting", "content annotation must be painting"
        assert anno["target"] == canvas["id"], "annotation must target its Canvas"
        assert anno["body"]["type"] == "Image"


def _record() -> ObjectRecord:
    return ObjectRecord(
        accession="1994.7.1",
        title="Portrait of a Collector",
        images=[ImageAsset(
            id="https://iiif.museum.org/1994.7.1/full/max/0/default.jpg",
            width=4000, height=3000,
            service="https://iiif.museum.org/1994.7.1",
        )],
    )


def test_manifest_is_publishable():
    m = build_manifest(_record(), "https://iiif.museum.org")
    assert_publishable(m)                       # raises AssertionError on any defect


def test_label_is_language_map_not_string():
    m = build_manifest(_record(), "https://iiif.museum.org")
    assert m["label"] == {"en": ["Portrait of a Collector"]}


def test_empty_images_is_rejected_not_emitted():
    import pytest
    rec = ObjectRecord(accession="1994.7.2", title="Untitled", images=[])
    with pytest.raises(ValueError):
        build_manifest(rec, "https://iiif.museum.org")

A green run confirms the manifest carries its context and PascalCase type, that the label serialized as a map rather than a string, that each Canvas has positive integer dimensions, that the content annotation is a painting annotation targeting its own Canvas, and that an object with no images fails at build time instead of shipping an empty viewer.

Standards Alignment

The document produced here conforms to the IIIF Presentation API 3.0, whose required-properties table mandates @context, id, type, and a label language map on every Manifest, and integer height/width on any Canvas presenting an image. Content resources are attached with the W3C Web Annotation Data Model — the motivation: painting and AnnotationPage structure come directly from that model — and the image service block references the IIIF Image API 3.0 via ImageService3. The label and summary language-map form is the same construct the rest of the delivery stage relies on, and the shape of the source record it consumes is defined upstream in how to structure JSON-LD for museum objects; getting that record right is what makes a clean manifest a mechanical transform rather than a rescue operation.

FAQ

Why does my manifest validate as JSON but show a blank viewer?

Valid JSON is not a valid Manifest. The viewer parses the document, fails to find a property it requires — most often a missing @context, a lowercase type, or a Canvas with no height/width — and declines to render rather than throwing. Run the manifest through the official IIIF validator or the assert_publishable check above; the assertion that fires names the exact missing invariant.

What is the difference between a v2 and a v3 label?

A v2 label was a plain string; a v3 label is a language map — a BCP-47 tag mapped to a list of strings, like {"en": ["Portrait"]}. Use the tag "none" for language-neutral values. Emitting a bare string is the most common v3 mistake and produces an object that displays with no visible title.

Why must the annotation motivation be exactly “painting”?

motivation: painting is the Web Annotation term that means “this resource is the visual content of the target Canvas.” Only painting annotations are rendered onto the Canvas surface. supplementing is correct for OCR text or a transcription layer but wrong for the image itself — use it for the image and the viewer treats the picture as a side resource that never paints.

Can the image body id be the Image API service base URL?

No. The service block holds the Image API base for deep zoom, but body.id must be a concrete, fetchable image URL — typically the full/max/0/default.jpg derivative. A viewer that cannot load tiles falls back to body.id; if that points at the service base instead of an image, the fallback resolves to nothing and the Canvas stays empty.