Skip to content

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 image
  • square — largest centred square (2.1.1+; supported in v2 too)
  • x,y,w,h — pixel coordinates
  • pct: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 raises NameError constructing the result), info_json (validator asserts id matches 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's ValidatorError.__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 Python iiif-validator tool'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 tagged 2.1; we now use --version=2.0 to actually exercise the v2 surface.
  • sizes array (pre-rendered size hints) is not emitted; iiiris generates any size on demand so it would be a hint only.
  • rights / services blocks 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 request jp2 output; 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.