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 Annotation → Image 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.
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.
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.
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 moreImageAssetentries. 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/widthmust be real integers on the Canvas and body — never0,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. Fetchinfo.jsonfrom the Image API service and readwidth/heightfrom 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.idshould be a concrete, fetchable image (often the Image APIfull/max/0/default.jpg), while theserviceblock advertises deep zoom. Omitting the service still validates, but the viewer loses tiled panning; omitting a realbody.idand 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. Keepensure_ascii=Falseonjson.dumpsso diacritics and non-Latin scripts serialize as UTF-8, not\uXXXXescapes. - Rights and attribution. The
rightsandrequiredStatementproperties 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.
# 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.
Related
- Building IIIF Presentation Manifests — parent manifest topic area
- Adding Rights & requiredStatement Blocks to IIIF Manifests — attaching rights and attribution
- Modeling Multi-Volume Objects as IIIF Ranges — sequencing many Canvases
- How to Structure JSON-LD for Museum Objects — the source record shape
- IIIF Image Delivery & Manifest Generation — delivery stage overview