IIIF compliance¶
iiiris targets IIIF Image API 3.0 Level 2 with 2.x compatibility for migration from existing deployments. Both versions are first-class: distinct parsers, distinct info.json shapes, validated against their respective spec grammars.
References: - IIIF Image API 3.0 - IIIF Image API 2.1
URL routes¶
| Method | Path | Purpose |
|---|---|---|
| GET | /iiif/3/{identifier}/info.json |
3.0 image information |
| GET | /iiif/3/{identifier}/{region}/{size}/{rotation}/{quality.format} |
3.0 image request |
| GET | /iiif/2/{identifier}/info.json |
2.x image information |
| GET | /iiif/2/{identifier}/{region}/{size}/{rotation}/{quality.format} |
2.x image request |
{identifier} may contain forward slashes — e.g.
/iiif/3/photos/cat.jpg/full/max/0/default.jpg resolves to the identifier
photos/cat.jpg. The router treats the last four URL segments of an image
request as region/size/rotation/quality.format; everything before is the
identifier. For info.json, everything before the trailing info.json is
the identifier.
Parameter coverage¶
Region (both versions)¶
full— entire imagesquare— largest centred square (2.1.1+; supported in v2 too)x,y,w,h— pixel coordinatespct:x,y,w,h— percentage coordinates
Size¶
| Token | v3 | v2 |
|---|---|---|
max |
✓ | ✓ |
full |
— | ✓ (v2 spelling, equivalent to max) |
^max |
✓ (upscaling) | rejected (v3-only ^ prefix) |
w, |
✓ | ✓ |
,h |
✓ | ✓ |
pct:n |
✓ | ✓ |
w,h |
✓ (forced; may distort) | ✓ |
!w,h |
✓ (confined; preserves aspect) | ✓ |
^… (upscale) |
✓ on any of the above | rejected |
Without ^, requests that would require upscaling return 400 with a
"use ^ to upscale" message.
Rotation¶
n(degrees),!n(mirror then rotate)- v3:
n ∈ [0, 360), decimal allowed - v2:
n ∈ [0, 360](closed range; 360 normalised to 0 internally)
Quality¶
| Token | v3 | v2 | Pipeline behavior |
|---|---|---|---|
default |
✓ | ✓ | no-op |
color |
✓ | ✓ | no-op |
gray |
✓ | ✓ | vips_colourspace(BW) |
native |
rejected | ✓ | normalised to default |
bitonal |
✓ | ✓ | ToColorSpace(BW) → Linear(a=10000, b=-1.28e6) → Cast(UChar) (clipping does the threshold) |
Format¶
jpg, png, tif, webp for both v2 and v3. (Source ingestion is
broader: libvips reads JP2, GIF, TIFF, etc.)
JPEG output is quality 90, baseline (sequential — ~3× faster to encode
than progressive). Set image.jpeg_progressive: true for progressive
(interlaced) output; see configuration.md.
Metadata, colour, and orientation¶
Metadata/orientation is governed by image.metadata (default
strip); colour is governed independently by image.color (default
unset, which derives from image.metadata). See
configuration.md "Colour policy" and
specs/color-policy.md for the full
derivation table.
| Aspect | metadata: strip (default) |
metadata: preserve |
|---|---|---|
| EXIF / XMP / IPTC (camera, serial, GPS, captions) | removed | kept |
| EXIF orientation | baked into the pixels (autorotate); tag cleared | kept as a tag; pixels not rotated |
info.json width/height |
oriented dimensions | raw header dimensions |
| Aspect | color: srgb (derived default when metadata: strip) |
color: respect-icc (derived default when metadata: preserve) |
|---|---|---|
| Colour source (≥ 3 bands) | normalized to sRGB, ~0.5 KB micro ICC profile embedded | embedded profile kept verbatim, no transform |
| Grayscale source (≤ 2 bands) | stays sGray, never promoted to RGB | stays sGray, profile (if any) kept |
| CMYK source | converted to sRGB | also converted to sRGB — never negotiable, in either colour policy |
image.color can be set explicitly to override the derivation —
e.g. metadata: strip + color: respect-icc strips EXIF/GPS while
keeping a wide-gamut profile.
Autorotate needs a libvips built with libexif — that is the only way
libvips can read the EXIF orientation tag. Every shipped artifact has it;
if you build libvips yourself or relink the distroless image, verify with
deploy/distroless/smoke-orientation.sh <image>. Without libexif every
image reads as orientation 1, and both the render and info.json silently
behave as if the source were upright (the bug fixed in the release after
v0.12.2).
The IIIF rotation parameter (the /{rotation}/ path segment) is
independent of EXIF-orientation autorotate: in strip mode the image is
first made upright, then the requested rotation is applied on top.
info.json¶
v3 shape (flat fields)¶
{
"@context": "http://iiif.io/api/image/3/context.json",
"id": "https://host/iiif/3/sample.jpg",
"type": "ImageService3",
"protocol": "http://iiif.io/api/image",
"profile": "level2",
"width": 1024,
"height": 768,
"extraFormats": ["png", "tif", "webp"],
"extraQualities": ["color", "gray"],
"extraFeatures": ["sizeUpscaling", "rotationArbitrary", "jsonldMediaType", "cors"],
"preferredFormats": ["jpg"]
}
v2 shape (profile array)¶
{
"@context": "http://iiif.io/api/image/2/context.json",
"@id": "https://host/iiif/2/sample.jpg",
"@type": "iiif:Image",
"protocol": "http://iiif.io/api/image",
"width": 1024,
"height": 768,
"profile": [
"http://iiif.io/api/image/2/level2.json",
{
"formats": ["jpg", "png", "tif", "webp"],
"qualities": ["default", "native", "color", "gray"],
"supports": ["regionSquare", "rotationArbitrary", "mirroring", "jsonldMediaType", "cors"]
}
]
}
Single source of truth¶
Per-version Supported* slices in internal/iiif/v{2,3}/ are consumed by
both the parsers (for validation) and the info.json builders (for
advertising). The advertised feature set therefore cannot drift from what
the pipeline actually implements — change the slice, both sides update.
HTTP status codes¶
The full transport contract (routes, middleware, cache/CORS semantics) is
specs/http-interface.md; the parameter-level
detail behind a 400 is below.
| Condition | Status |
|---|---|
| Parser rejects path syntax (bad region, unknown quality, missing format, etc.) | 400 |
Pipeline rejects values (region origin outside source, size requires upscale without ^) |
400 |
image.max_pixel_area exceeded by source |
400 |
image.max_output_area exceeded by output |
400 |
| Source rejects the identifier as malformed (path escape, NUL byte, Windows drive letter or alternate data stream, reserved DOS device name) | 400 |
| Identifier not found in the configured source | 404 |
| Auth denied | 401 (or as supplied by the Authorizer) |
| vips decode failure, encode failure, source I/O error | 500 |
Error bodies are fixed strings (invalid identifier, not found,
internal server error). The underlying error — which names the
identifier, and for HTTP/S3 sources the upstream URL or bucket and key —
goes to the log, not to the client that sent the request.
| If-None-Match / If-Modified-Since matches cached entry | 304 |
info.json fields¶
iiiris emits the following info.json fields when configured. Zero-valued limits are omitted (advertisement matches what the pipeline enforces, in both directions):
| Field | Source | Notes |
|---|---|---|
maxWidth / maxHeight / maxArea (v3 flat; v2 in profile-extras) |
image.max_output_width / max_output_height / max_output_area |
Advertised when > 0 and enforced by the pipeline output guard. |
tiles (single entry, square) |
image.tile_size (default 512) |
scaleFactors computed from source dims. Hint only — iiiris generates any size on demand. |
extraFormats / extraQualities / extraFeatures |
per-version Supported* slices |
Single source of truth shared with the parsers. |
service (auth trees: 2.0 + 1.0 side-by-side) |
auth.BuildAdvertisement + auth.BuildAdvertisementV1 per matched profile |
Emitted only when the identifier matches an auth.rules entry. 2.0 entry first (AuthProbeService2 → AuthAccessService2 → AuthAccessTokenService2 + AuthLogoutService2); 1.0 entry second (http://iiif.io/api/auth/1/{clickthrough,login,kiosk,external} access service with nested token + logout). header-backend profiles emit the 2.0 probe service only — no 2.0 access service (no interactive flow) and no 1.0 entry (1.0 has no probe-only mode). See iiif-auth.md. |
IIIF Authorization Flow conformance¶
iiiris implements the IIIF Authorization Flow API 2.0 and
advertises the older IIIF Authentication API 1.0 service tree
side-by-side for migration compatibility. The four 2.0 services
(probe, access, access-token, logout) are mounted under /iiif/auth/;
the smaller 1.0 surface (token, login, logout — 1.0 has no probe) is
mounted under /iiif/auth/v1/. Both versions share the same cookie
and the same session store; the login and logout handlers are reused
verbatim. Only the advertised service tree and the token-response
shape differ. Both info.json versions (v2 and v3) carry both auth
service trees.
| Auth 2.0 feature | Status |
|---|---|
AuthProbeService2 + AuthProbeResult2 envelope |
✓ |
AuthAccessService2 (clickthrough / active / kiosk / external profiles) |
✓ |
AuthAccessTokenService2 (postMessage iframe flow, opaque tokens) |
✓ |
AuthLogoutService2 |
✓ |
substitute ref on denied probe (degraded-size variant or hook-supplied alternate identifier) |
✓ |
Service-tree advertisement on info.json |
✓ (v2 and v3) |
| OIDC, external-redirect, header-trust, and built-in access services | ✓ (per-profile access_service.backend) |
| Auth 1.0 feature | Status |
|---|---|
Access service (clickthrough / login / kiosk / external profiles) |
✓ shares the v2 login handler under /iiif/auth/v1/login/... |
| Token service (postMessage payload, 1.0 success + error shapes) | ✓ /iiif/auth/v1/token |
| Logout service | ✓ shares the v2 logout handler under /iiif/auth/v1/logout |
Service-tree advertisement on info.json (alongside the 2.0 tree) |
✓ BuildAdvertisementV1; second entry in the service array |
No community-blessed Auth 2.0 validator exists today. In its place the
iiif-auth-validate CI stage runs a project-local conformance harness
(tools/iiif-auth-conformance) against a sample auth-enabled
iiirisd. The harness drives the spec-defined endpoints (probe,
access, access-token, logout) and asserts the response shapes
(@context, type, status, payload fields). The stage is
load-bearing — a regression in any of the four interaction patterns
fails the pipeline. See
iiif-auth.md.
URL path normalisation¶
Requests whose URL path contains runs of / (e.g. host//iiif/3/{id}/info.json,
which naive server + path concatenation in clients produces) are folded to a
single / by the slash-collapse middleware before routing. iiiris matches the
behaviour of nginx, Apache, and most reverse proxies; without it, Go's
stdlib ServeMux returns a 301 redirect, which trips clients that don't follow.
Content negotiation for info.json¶
When a client sends Accept: application/ld+json, iiiris serves info.json with
Content-Type: application/ld+json;profile="http://iiif.io/api/image/{2,3}/context.json".
Without that Accept header the default is application/json. The cors and
jsonldMediaType entries in v3 extraFeatures (and the v2 profile's supports
list) match this behaviour.
CORS¶
All IIIF responses (info.json and image bodies) emit
Access-Control-Allow-Origin: *. This matches the cors advertisement in
both v3 extraFeatures and the v2 profile.
IIIF validator coverage¶
The CI iiif-validate job runs the Python iiif-validator against a
purpose-built fixture (testdata/validator-squares.jpg — a 1000×1000
colored grid that matches the palette hardcoded in
iiif_validator.validator; regenerate with make testdata-validator).
Versions pinned in CI:
iiif-validator==1.0.5— the only published PyPI version (since 2019).Pillow<12— defensive pin. iiif-validator has no release-time pin on its transitive deps, so a future Pillow major could re-break the decode path without warning. Holding Pillow on 11.x is the cheapest protection.
The iiirisd HTTP listener in the validator jobs is bound to :18180
(not the default :8080) because shell-executor runners on macOS
Docker Desktop frequently have a developer service squatting on
:8080; --network host would otherwise let iiirisd silently lose
the bind race and the validator would talk to that other service.
Against iiirisd on main with these pins, the validator reports:
- v2.0 (21 tests, 2 fail) —
baseurl_redirect(validator's Python raisesNameErrorconstructing the result),info_json(validator assertsidmatches the requested URL byte-for-byte; iiiris emits the canonical service URL per IIIF spec § 5.3). - v3.0 (24 tests, 3 fail) — same two, plus
size_noup(validator'sValidatorError.__init__is called with the wrong positional-argument count when iiirisd correctly rejects an upscale-without-^per v3 spec).
All three are upstream iiif-validator bugs / spec-disagreements, not
iiirisd issues. The stage stays allow_failure: true; the
v{2,3}-validation.log artifacts capture full output for reviewers.
1.0 decision: this posture is accepted for 1.0 — the validator is
unmaintained (pinned v1.0.5, no release since 2019), so blocking on an
upstream fix for non-iiiris failures is an indefinite wait. Recorded in the
1.0 checklist in ROADMAP.md.
Standards-compliance gaps¶
- IIIF Image API 3.0 validator stage in CI is
allow_failure: true— the Pythoniiif-validatortool's v3 coverage is in flux (see above for the three known-bad tests). v2 stage previously claimed strict but ran zero tests because the validator has no tests tagged2.1; we now use--version=2.0to actually exercise the v2 surface. sizesarray (pre-rendered size hints) is not emitted; iiiris generates any size on demand so it would be a hint only.rights/servicesblocks are not generated.- JPEG 2000 / HTJ2K encode is Part 1 only. OpenJPEG (the JP2 codec
iiiris uses) implements HTJ2K decode in 2.5.0+ but has no HTJ2K
encoder (upstream issue #1487).
iiiris can therefore consume HTJ2K (
.jph) sources but if asked to produce a JP2 output (/iiif/3/{id}/full/max/0/default.jp2) the response is classic Part 1, not HTJ2K. In practice IIIF clients almost never requestjp2output; the typical flow consumes JP2/JPH and emits JPEG/PNG/WebP.
iiiris transparently accepts both forms of JP2K source content: the
JP2 box-wrapped form (00 00 00 0C 6A 50… signature) and the raw
codestream form (SOC marker FF 4F FF 51…, the default from
OpenJPH's ojph_compress). govips's Go-side magic-byte gate only
recognises the boxed form; for raw codestreams the internal/image
package parses the SIZ marker and synthesises a minimal JP2 envelope
(signature + ftyp + jp2h(ihdr) + jp2c) so the buffer reaches
vips_jp2kload_buffer, which then decodes either Part 1 or HTJ2K
uniformly.