"""IIIF integration for Django's ``ImageField``.
Defines :class:`IIIFField` (an ``ImageField`` subclass) whose field files expose
an ``.iiif`` accessor. That accessor returns an :class:`IIIFObject` carrying one
IIIF Image API URL per configured profile, plus the ``info``/``identifier`` URLs
and the generated ``info_document`` / ``manifest`` documents.
Profiles are configured in ``settings.IIIF_PROFILES`` and may be a plain ``dict``,
a :class:`Profile` instance, or a callable returning either (see
:func:`resolve_profile`).
Optionally, ``settings.IIIF_AUTH`` describes an IIIF Authorization Flow 2.0 probe
service (a :class:`ProbeService`, a ``dict``, or a callable returning either or
``None``); when set, its ``service`` block is embedded in the generated
``info_document`` / ``manifest`` for access-controlled images (see
:func:`resolve_auth`). ``settings.IIIF_INFO`` (a ``dict``, an :class:`InfoExtras`,
or a callable → either/``None``) declares optional Image API ``info.json``
properties — ``sizes``, ``tiles``, size limits, ``rights``, and v3 capability
lists — that djiiif passes through into the generated document (see
:func:`resolve_info`). ``settings.IIIF_NAVPLACE`` (a callable → GeoJSON / GEOS
geometry / ``None``, resolved by the optional :mod:`djiiif.geo` module) adds a
navPlace ``FeatureCollection`` to the generated manifest for geolocated images.
The module also provides IIIF Content State API 1.0 helpers
(:func:`encode_content_state` / :func:`decode_content_state` /
:func:`build_content_state`, plus :meth:`IIIFObject.content_state`) for building
the shareable ``iiif-content=`` deep links that open an image — optionally zoomed
to a region — in a manifest-aware viewer, and IIIF Change Discovery API 1.0
builders (:class:`Activity` / :func:`resolve_activity` / :func:`build_activity` /
:func:`build_ordered_collection` / :func:`build_collection_page`) that back the
opt-in activity-stream views in :mod:`djiiif.views`.
W3C Web Annotation and IIIF Content Search 2.0 builders (:class:`Annotation` /
:func:`resolve_annotation` / :func:`build_annotation` /
:func:`build_annotation_page` / :func:`build_search_service` /
:func:`build_search_response`) back the opt-in annotation-serving and
search views in :mod:`djiiif.views`, which draw their content from the
project-owned ``IIIF_ANNOTATIONS_BACKEND`` / ``IIIF_SEARCH_BACKEND`` callables.
"""
import base64
import json
from dataclasses import dataclass, fields
from datetime import datetime
from functools import cached_property
from urllib.parse import quote, unquote
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db.models import ImageField
from django.db.models.fields.files import ImageFieldFile
# @context URI advertised in a generated info.json, keyed by Image API version.
IIIF_CONTEXTS: dict[int, str] = {
2: "http://iiif.io/api/image/2/context.json",
3: "http://iiif.io/api/image/3/context.json",
}
# @context URI for a generated Presentation API manifest (always version 3).
PRESENTATION_CONTEXT = "http://iiif.io/api/presentation/3/context.json"
# @context array for a manifest carrying the navPlace extension — the extension
# context first, the Presentation context last, as the extension requires.
NAVPLACE_CONTEXT = [
"http://iiif.io/api/extension/navplace/context.json",
PRESENTATION_CONTEXT,
]
# Keys every fully-resolved profile spec must provide.
PROFILE_KEYS = ("host", "region", "size", "rotation", "quality", "format")
def urljoin(parts):
"""Join URL parts with single slashes, preserving a trailing slash.
Args:
parts: A non-empty list of URL fragments. Interior fragments are
stripped of surrounding slashes; the final fragment keeps any
trailing slash so callers can build directory-style URLs.
Returns:
The joined URL string.
Raises:
ValueError: If ``parts`` is empty.
"""
if len(parts) == 0:
raise ValueError("urljoin needs a list of at least length 1")
return "/".join([x.strip("/") for x in parts[0:-1]] + [parts[-1].lstrip("/")])
[docs]
def encode_identifier(name: str) -> str:
"""Percent-encode a field-file name into a IIIF identifier segment.
The IIIF Image API treats the identifier as a single path segment, so every
character in the reserved to-encode set (``/ ? # [ ] @ %`` and friends) must
be percent-encoded — not just ``/``. ``quote(safe="")`` encodes everything
outside the unreserved set, which is a superset of the required behavior and
keeps ordinary filenames (``foo.jpg``) untouched while correctly encoding
``a/b.jpg`` → ``a%2Fb.jpg`` and names containing spaces, ``?``, ``#``, etc.
Args:
name: The stored file name (e.g. ``"uploads/photo.jpg"``).
Returns:
The encoded identifier segment.
"""
return quote(name, safe="")
[docs]
@dataclass(frozen=True)
class Profile:
"""A typed, IIIF Image API 3.0-aware image profile.
A structured alternative to the raw ``dict`` profile shape, with 3.0-friendly
defaults (``size="max"``) and helpers for the two 3.0 features that are easy
to get wrong as hand-written strings: mirroring (``!`` rotation prefix) and
upscaling (``^`` size prefix). Existing ``dict`` and callable profiles keep
working unchanged; this is purely an additive, opt-in convenience.
Attributes:
host: Base URL of the IIIF image server.
region: IIIF region parameter (``"full"``, ``"square"``, ``x,y,w,h``…).
size: IIIF size parameter (``"max"``, ``"w,"``, ``"w,h"``…). If
``upscale`` is set and this does not already start with ``^``, a
``^`` prefix is added when the spec is built.
rotation: IIIF rotation in degrees as a string. If ``mirror`` is set and
this does not already start with ``!``, a ``!`` prefix is added.
quality: IIIF quality (``"default"``, ``"color"``, ``"gray"``…).
format: IIIF output format extension (``"jpg"``, ``"png"``, ``"webp"``…).
mirror: When true, mirror the image (adds the ``!`` rotation prefix).
upscale: When true, permit upscaling beyond the region (adds the ``^``
size prefix).
"""
host: str
region: str = "full"
size: str = "max"
rotation: str = "0"
quality: str = "default"
format: str = "jpg"
mirror: bool = False
upscale: bool = False
[docs]
def as_spec(self) -> dict[str, str]:
"""Resolve this profile to the plain ``dict`` spec the builders consume.
Folds :attr:`mirror` and :attr:`upscale` into the ``rotation`` and
``size`` strings, guarding against double-prefixing when the caller
already supplied a ``!``/``^`` prefix.
Returns:
A dict with the keys in :data:`PROFILE_KEYS`.
"""
rotation = self.rotation
if self.mirror and not rotation.startswith("!"):
rotation = f"!{rotation}"
size = self.size
if self.upscale and not size.startswith("^"):
size = f"^{size}"
return {
"host": self.host,
"region": self.region,
"size": size,
"rotation": rotation,
"quality": self.quality,
"format": self.format,
}
[docs]
def resolve_profile(profile, parent) -> dict[str, str]:
"""Normalize any supported profile shape into a plain spec ``dict``.
Accepts the three configured shapes — a :class:`Profile`, a callable
receiving ``parent`` and returning a ``Profile`` or ``dict``, or a plain
``dict`` — and returns a uniform ``dict`` for URL assembly.
Args:
profile: The value from ``settings.IIIF_PROFILES`` for one profile name.
parent: The :class:`IIIFFieldFile` passed to callable profiles.
Returns:
A resolved spec dict containing the keys in :data:`PROFILE_KEYS`.
Raises:
ImproperlyConfigured: If ``profile`` (or a callable's return value) is
not a ``Profile``, callable, or ``dict``.
"""
if isinstance(profile, Profile):
return profile.as_spec()
if isinstance(profile, dict):
return profile
if callable(profile):
resolved = profile(parent)
if isinstance(resolved, Profile):
return resolved.as_spec()
if isinstance(resolved, dict):
return resolved
raise ImproperlyConfigured(
"An IIIF_PROFILES callable must return a dict or Profile, got "
f"{type(resolved).__name__}."
)
raise ImproperlyConfigured(
"Each IIIF_PROFILES entry must be a dict, Profile, or callable, got "
f"{type(profile).__name__}."
)
def _language_map(value: str | list[str] | dict | None) -> dict | None:
"""Coerce a label-ish value into a IIIF language map.
Accepts a plain string or list of strings (wrapped under the ``"none"``
language key) or an already-formed language map (returned unchanged).
Args:
value: A string, list of strings, language-map ``dict``, or ``None``.
Returns:
A language map ``dict``, or ``None`` if ``value`` is ``None``.
"""
if value is None:
return None
if isinstance(value, dict):
return value
if isinstance(value, str):
return {"none": [value]}
return {"none": list(value)}
[docs]
@dataclass(frozen=True)
class LogoutService:
"""An Authorization Flow 2.0 logout service (``AuthLogoutService2``)."""
id: str
label: str | list[str] | dict | None = None
[docs]
def as_dict(self) -> dict:
"""Serialize to the nested Auth 2.0 service ``dict``."""
service = {"id": self.id, "type": "AuthLogoutService2"}
label = _language_map(self.label)
if label is not None:
service["label"] = label
return service
[docs]
@dataclass(frozen=True)
class TokenService:
"""An Authorization Flow 2.0 access token service (``AuthAccessTokenService2``)."""
id: str
[docs]
def as_dict(self) -> dict:
"""Serialize to the nested Auth 2.0 service ``dict``."""
return {"id": self.id, "type": "AuthAccessTokenService2"}
[docs]
@dataclass(frozen=True)
class AccessService:
"""An Authorization Flow 2.0 access service (``AuthAccessService2``).
Attributes:
id: The access service URI.
profile: The interaction pattern — ``"active"`` (interactive login),
``"kiosk"`` (automatic), or ``"external"`` (ambient/IP).
label / heading / note / confirm_label: ``active``-pattern UI text; each
accepts a plain string, list of strings, or a IIIF language map.
token: The nested :class:`TokenService`.
logout: The optional nested :class:`LogoutService`.
"""
id: str
profile: str = "active"
label: str | list[str] | dict | None = None
heading: str | list[str] | dict | None = None
note: str | list[str] | dict | None = None
confirm_label: str | list[str] | dict | None = None
token: TokenService | None = None
logout: LogoutService | None = None
[docs]
def as_dict(self) -> dict:
"""Serialize to the nested Auth 2.0 service ``dict``."""
service = {"id": self.id, "type": "AuthAccessService2", "profile": self.profile}
for key, value in (
("label", self.label),
("heading", self.heading),
("note", self.note),
("confirmLabel", self.confirm_label),
):
language_map = _language_map(value)
if language_map is not None:
service[key] = language_map
nested = [s.as_dict() for s in (self.token, self.logout) if s is not None]
if nested:
service["service"] = nested
return service
[docs]
@dataclass(frozen=True)
class ProbeService:
"""An Authorization Flow 2.0 probe service (``AuthProbeService2``).
The top of the nested auth service block and the value clients look for in an
``info.json`` / manifest. Use as an ``IIIF_AUTH`` value or return it from an
``IIIF_AUTH`` callable.
Attributes:
id: The probe service URI.
access: The nested :class:`AccessService`.
"""
id: str
access: AccessService | None = None
[docs]
def as_dict(self) -> dict:
"""Serialize to the nested Auth 2.0 service ``dict``."""
service = {"id": self.id, "type": "AuthProbeService2"}
if self.access is not None:
service["service"] = [self.access.as_dict()]
return service
[docs]
def resolve_auth(parent) -> dict | None:
"""Resolve ``settings.IIIF_AUTH`` to a probe-service ``dict`` for an image.
Mirrors :func:`resolve_profile`: the setting may be a :class:`ProbeService`,
a raw ``dict``, a callable receiving the field file and returning either (or
``None`` for a public image), or unset. Empty/public cases resolve to
``None`` so no auth block is emitted.
Args:
parent: The :class:`IIIFFieldFile` passed to callable configs.
Returns:
The nested probe-service ``dict``, or ``None`` when there is no auth.
Raises:
ImproperlyConfigured: If the (resolved) value is not a ``ProbeService``,
``dict``, or ``None``.
"""
auth = getattr(settings, "IIIF_AUTH", None)
if auth is None:
return None
if callable(auth):
auth = auth(parent)
if auth is None:
return None
if isinstance(auth, ProbeService):
return auth.as_dict()
if isinstance(auth, dict):
return auth
raise ImproperlyConfigured(
"IIIF_AUTH must be a ProbeService, dict, callable, or None, got "
f"{type(auth).__name__}."
)
[docs]
def image_url(spec: dict[str, str], identifier: str) -> str:
"""Assemble a full IIIF Image API request URL from a resolved spec.
Args:
spec: A resolved profile spec (keys in :data:`PROFILE_KEYS`).
identifier: An already-encoded identifier segment (see
:func:`encode_identifier`).
Returns:
The ``{host}/{identifier}/{region}/{size}/{rotation}/{quality}.{format}``
URL.
"""
return urljoin(
[
spec["host"],
identifier,
spec["region"],
spec["size"],
spec["rotation"],
f"{spec['quality']}.{spec['format']}",
]
)
def _api_version(version: int | None) -> int:
"""Return the effective Image API version, validated against known contexts.
Args:
version: An explicit version, or ``None`` to read
``settings.IIIF_IMAGE_API_VERSION`` (default ``3``).
Returns:
The resolved version (``2`` or ``3``).
Raises:
ImproperlyConfigured: If the resolved version is not a known context.
"""
if version is None:
version = getattr(settings, "IIIF_IMAGE_API_VERSION", 3)
if version not in IIIF_CONTEXTS:
raise ImproperlyConfigured(
f"IIIF_IMAGE_API_VERSION must be one of {sorted(IIIF_CONTEXTS)}, got {version!r}."
)
return version
def _compliance_level(level: str | None) -> str:
"""Return the advertised compliance level.
Args:
level: An explicit level, or ``None`` to read
``settings.IIIF_COMPLIANCE_LEVEL`` (default ``"level2"``).
Returns:
The compliance level string (e.g. ``"level2"``).
"""
if level is None:
level = getattr(settings, "IIIF_COMPLIANCE_LEVEL", "level2")
return level
def _require_auth_v3(auth: dict | None, version: int) -> None:
"""Guard: an Auth 2.0 block may only be emitted at Image API version 3.
Args:
auth: The resolved auth block, or ``None``.
version: The resolved Image API version.
Raises:
ImproperlyConfigured: If ``auth`` is set while ``version`` is not 3
(Authorization Flow 2.0 pairs with Image/Presentation API 3).
"""
if auth is not None and version != 3:
raise ImproperlyConfigured(
"IIIF_AUTH (Authorization Flow 2.0) requires IIIF_IMAGE_API_VERSION = 3; "
f"got version {version}."
)
# Optional ``info.json`` properties, in canonical snake_case → emitted spec key.
# Iteration order is the spec-conventional grouping (sizes/tiles, then limits,
# then rights, then the extra-capability lists), used when emitting.
INFO_KEYS: dict[str, str] = {
"sizes": "sizes",
"tiles": "tiles",
"max_width": "maxWidth",
"max_height": "maxHeight",
"max_area": "maxArea",
"rights": "rights",
"preferred_formats": "preferredFormats",
"extra_qualities": "extraQualities",
"extra_formats": "extraFormats",
"extra_features": "extraFeatures",
}
# The v3-only keys (everything except sizes/tiles, which v2 shares).
_INFO_V2_KEYS = frozenset({"sizes", "tiles"})
# spec camelCase alias → canonical snake_case (so spec-literal keys are accepted).
_INFO_ALIASES: dict[str, str] = {
emitted: canonical for canonical, emitted in INFO_KEYS.items() if emitted != canonical
}
def _normalize_info_keys(info: dict) -> dict:
"""Normalize an ``IIIF_INFO`` dict to canonical snake_case keys.
Accepts both snake_case (``max_width``) and spec-literal camelCase
(``maxWidth``) keys, normalized to the snake_case form used internally.
Args:
info: The raw ``IIIF_INFO`` dict.
Returns:
A dict keyed by canonical snake_case names.
Raises:
ImproperlyConfigured: If a key is unrecognized, or the same property is
given under both its snake_case and camelCase spellings.
"""
normalized: dict = {}
for key, value in info.items():
if key in INFO_KEYS:
canonical = key
elif key in _INFO_ALIASES:
canonical = _INFO_ALIASES[key]
else:
raise ImproperlyConfigured(
f"Unknown IIIF_INFO key {key!r}. Allowed keys: {', '.join(INFO_KEYS)}."
)
if canonical in normalized:
raise ImproperlyConfigured(
f"IIIF_INFO gives {canonical!r} under both its snake_case and camelCase "
"spellings; supply it once."
)
normalized[canonical] = value
return normalized
[docs]
def resolve_info(parent) -> dict:
"""Resolve ``settings.IIIF_INFO`` to a normalized extras ``dict`` for an image.
Mirrors :func:`resolve_auth`: the setting may be a ``dict``, an
:class:`InfoExtras`, a callable returning either (or ``None``), or unset.
Args:
parent: The value passed to a callable config. On model paths this is the
:class:`IIIFFieldFile`; on view paths it is the decoded storage name
(``str``) — both expose the identity a callable branches on. The
documented callable signature is therefore ``parent: IIIFFieldFile | str``.
Returns:
A normalized extras dict (empty when unset or resolved to ``None``).
Raises:
ImproperlyConfigured: If the (resolved) value is not a ``dict``,
``InfoExtras``, or ``None``, or carries an unknown/duplicate key.
"""
info = getattr(settings, "IIIF_INFO", None)
if info is None:
return {}
if callable(info):
info = info(parent)
if info is None:
return {}
if isinstance(info, InfoExtras):
return info.as_dict()
if not isinstance(info, dict):
raise ImproperlyConfigured(
"IIIF_INFO must be a dict, InfoExtras, callable, or None, got "
f"{type(info).__name__}."
)
return _normalize_info_keys(info)
def _apply_info_extras(document: dict, extras: dict, version: int) -> None:
"""Emit resolved ``info.json`` extras into ``document`` for the API version.
v3 emits every present key at top level with its spec (camelCase) name, in
the spec-conventional order of :data:`INFO_KEYS`. v2 emits only ``sizes`` /
``tiles`` and rejects any v3-only key.
Args:
document: The document being built; mutated in place.
extras: A normalized extras dict (canonical snake_case keys).
version: The resolved Image API version.
Raises:
ImproperlyConfigured: If a v3-only key is present at version 2.
"""
if version == 2:
v3_only = sorted(set(extras) - _INFO_V2_KEYS)
if v3_only:
raise ImproperlyConfigured(
f"IIIF_INFO key(s) {', '.join(v3_only)} are only valid at "
"IIIF_IMAGE_API_VERSION 3; version 2 supports only sizes and tiles."
)
for canonical, emitted in INFO_KEYS.items():
if canonical in extras:
document[emitted] = extras[canonical]
[docs]
def build_info_document(
id_url: str,
width: int,
height: int,
*,
version: int | None = None,
level: str | None = None,
auth: dict | None = None,
extras: dict | None = None,
) -> dict:
"""Build a spec-conformant IIIF Image API ``info.json`` document.
Args:
id_url: The image service base URI (``{host}/{identifier}``).
width: Image width in pixels.
height: Image height in pixels.
version: Image API version (``2`` or ``3``); defaults to
``settings.IIIF_IMAGE_API_VERSION`` (``3``).
level: Advertised compliance level; defaults to
``settings.IIIF_COMPLIANCE_LEVEL`` (``"level2"``).
auth: An optional resolved Authorization Flow 2.0 probe-service ``dict``
(see :func:`resolve_auth`). When present it is added to the document's
``service`` array; only valid at version 3.
extras: An optional normalized extras dict (see :func:`resolve_info`) of
declarative properties — ``sizes``, ``tiles``, size limits, ``rights``,
preferred/extra capabilities. v2 accepts only ``sizes``/``tiles``.
Returns:
The ``info.json`` document as a dict, ready for ``JsonResponse``.
Raises:
ImproperlyConfigured: If ``version`` is unknown, ``auth`` is set while not
on version 3, or an ``extras`` v3-only key is present at version 2.
"""
version = _api_version(version)
level = _compliance_level(level)
_require_auth_v3(auth, version)
extras = extras or {}
if version == 2:
document = {
"@context": IIIF_CONTEXTS[2],
"@id": id_url,
"protocol": "http://iiif.io/api/image",
"profile": [f"http://iiif.io/api/image/2/{level}.json"],
"width": width,
"height": height,
}
_apply_info_extras(document, extras, version)
return document
document = {
"@context": IIIF_CONTEXTS[3],
"id": id_url,
"type": "ImageService3",
"protocol": "http://iiif.io/api/image",
"profile": level,
"width": width,
"height": height,
}
_apply_info_extras(document, extras, version)
if auth is not None:
document["service"] = [auth]
return document
def _manifest_uri(id_url: str) -> str:
"""Return the synthetic Manifest URI derived from an image service base URI.
The single derivation point shared by :func:`build_manifest` and
:func:`build_content_state` (via :meth:`IIIFObject.content_state`) so the two
can never drift.
Args:
id_url: The image service base URI (``{host}/{identifier}``).
Returns:
The ``{id_url}/manifest`` URI.
"""
return urljoin([id_url, "manifest"])
def _canvas_uri(id_url: str, index: int = 1) -> str:
"""Return the synthetic Canvas URI derived from an image service base URI.
Shared derivation point (see :func:`_manifest_uri`); the 1-based ``index``
matches the single-image manifest's ``{id_url}/canvas/1``.
Args:
id_url: The image service base URI (``{host}/{identifier}``).
index: The 1-based canvas index.
Returns:
The ``{id_url}/canvas/{index}`` URI.
"""
return urljoin([id_url, "canvas", str(index)])
# The descriptive-property kwargs the manifest/collection builders accept, mapped
# to their IIIF (camelCase) property names. Used both to validate a descriptor
# bag (reject typos loudly) and to build the property fragment.
DESCRIPTOR_KEYS = ("metadata", "rights", "required_statement", "summary", "thumbnail", "nav_date")
def _reject_unknown_descriptors(descriptors: dict) -> None:
"""Reject a descriptor bag carrying keys the builders do not understand.
The descriptor bag is a plain kwarg dict (not a fixed-shape spec object), so
an unrecognized key is almost always a typo (``metdata``, ``rght``). Failing
loudly here mirrors the ``IIIF_AUTH``-at-v2 posture.
Args:
descriptors: The descriptor kwargs (e.g. from ``IIIF_MANIFEST_DESCRIPTORS``
or a direct builder call).
Raises:
ImproperlyConfigured: If any key is not in :data:`DESCRIPTOR_KEYS`.
"""
unknown = set(descriptors) - set(DESCRIPTOR_KEYS)
if unknown:
raise ImproperlyConfigured(
f"Unknown manifest descriptor key(s): {', '.join(sorted(unknown))}. "
f"Allowed keys are: {', '.join(DESCRIPTOR_KEYS)}."
)
def _metadata_pairs(metadata: list) -> list[dict]:
"""Coerce descriptive ``metadata`` into IIIF ``{label, value}`` pairs.
Args:
metadata: A list whose items are either ``(label, value)`` pairs (each
side coerced via :func:`_language_map`) or already-formed
``{"label": …, "value": …}`` dicts (passed through unchanged).
Returns:
The list of language-mapped metadata pair dicts.
"""
pairs = []
for item in metadata:
if isinstance(item, dict):
pairs.append(item)
else:
label, value = item
pairs.append({"label": _language_map(label), "value": _language_map(value)})
return pairs
def _label_value(pair) -> dict:
"""Coerce a ``(label, value)`` pair into a IIIF label/value dict.
Args:
pair: A ``(label, value)`` tuple (each side coerced via
:func:`_language_map`) or an already-formed dict (passed through).
Returns:
A ``{"label": …, "value": …}`` dict.
"""
if isinstance(pair, dict):
return pair
label, value = pair
return {"label": _language_map(label), "value": _language_map(value)}
def _thumbnail(thumbnail: str | list) -> list:
"""Coerce a ``thumbnail`` into the IIIF thumbnail list shape.
Args:
thumbnail: A single URL string (wrapped as ``[{"id": …, "type":
"Image"}]``) or an already-formed list of thumbnail dicts.
Returns:
The thumbnail list.
"""
if isinstance(thumbnail, str):
return [{"id": thumbnail, "type": "Image"}]
return thumbnail
def _descriptive_properties(descriptors: dict) -> dict:
"""Build the IIIF descriptive-property fragment from a descriptor bag.
Every property is emitted only when its key is present and non-``None``, so an
empty (or all-``None``) bag contributes nothing and leaves the surrounding
document byte-identical to one built without descriptors.
Args:
descriptors: A validated descriptor bag (keys in :data:`DESCRIPTOR_KEYS`).
Returns:
A dict of IIIF descriptive properties (``metadata``, ``summary``,
``requiredStatement``, ``rights``, ``navDate``, ``thumbnail``) in spec
property order, ready to merge into a Manifest or Collection.
"""
props: dict = {}
metadata = descriptors.get("metadata")
if metadata is not None:
props["metadata"] = _metadata_pairs(metadata)
summary = descriptors.get("summary")
if summary is not None:
props["summary"] = _language_map(summary)
required_statement = descriptors.get("required_statement")
if required_statement is not None:
props["requiredStatement"] = _label_value(required_statement)
rights = descriptors.get("rights")
if rights is not None:
props["rights"] = rights
nav_date = descriptors.get("nav_date")
if nav_date is not None:
props["navDate"] = nav_date.isoformat() if isinstance(nav_date, datetime) else nav_date
thumbnail = descriptors.get("thumbnail")
if thumbnail is not None:
props["thumbnail"] = _thumbnail(thumbnail)
return props
def resolve_manifest_descriptors(parent) -> dict:
"""Resolve ``settings.IIIF_MANIFEST_DESCRIPTORS`` to a descriptor bag.
Mirrors :func:`resolve_auth`: the setting may be a plain ``dict`` of
descriptor kwargs, a callable receiving the field file and returning such a
``dict`` (or ``None`` for no descriptors), or unset. This is how per-image
descriptive metadata flows into :attr:`IIIFObject.manifest` without djiiif
knowing the model.
Args:
parent: The :class:`IIIFFieldFile` passed to a callable config.
Returns:
A validated descriptor bag (empty when unset or resolved to ``None``).
Raises:
ImproperlyConfigured: If the (resolved) value is neither a ``dict`` nor
``None``, or carries an unknown descriptor key.
"""
hook = getattr(settings, "IIIF_MANIFEST_DESCRIPTORS", None)
if hook is None:
return {}
descriptors = hook(parent) if callable(hook) else hook
if descriptors is None:
return {}
if not isinstance(descriptors, dict):
raise ImproperlyConfigured(
"IIIF_MANIFEST_DESCRIPTORS must be a dict, a callable returning a dict "
f"or None, or unset; got {type(descriptors).__name__}."
)
_reject_unknown_descriptors(descriptors)
return descriptors
def _normalize_image_spec(image) -> tuple[str, int, int, object]:
"""Normalize one :func:`build_multi_manifest` image spec.
Args:
image: Either a ``(service_id_url, width, height)`` tuple or a dict with
``id`` / ``width`` / ``height`` keys and an optional per-canvas
``label``.
Returns:
A ``(service_id_url, width, height, label)`` tuple, where ``label`` is
``None`` when unspecified.
"""
if isinstance(image, dict):
return image["id"], image["width"], image["height"], image.get("label")
service_id_url, width, height = image
return service_id_url, width, height, None
def _build_canvas(
manifest_id_url: str,
index: int,
service_id_url: str,
width: int,
height: int,
*,
label,
version: int,
level: str,
auth: dict | None,
) -> dict:
"""Assemble one Canvas (with its painting annotation) for a manifest.
The single canvas-assembly code path shared by every manifest — the
single-image :func:`build_manifest` is just this called once. Canvas,
annotation-page, and annotation ``id`` URIs derive from ``manifest_id_url``
with the 1-based ``index``; the image body and its service derive from this
image's own ``service_id_url`` (which equals ``manifest_id_url`` in the
single-image case).
Args:
manifest_id_url: The manifest's image service base URI, the stem for the
synthetic canvas/annotation URIs.
index: The 1-based canvas index.
service_id_url: This image's own service base URI.
width: Image width in pixels.
height: Image height in pixels.
label: Optional per-canvas label (coerced via :func:`_language_map`);
omitted from the canvas when ``None``.
version: The resolved Image API version (``2`` or ``3``).
level: The resolved compliance level.
auth: The resolved probe-service ``dict`` applied to the image body, or
``None``.
Returns:
The Canvas dict.
"""
# v3 uses the "max" size keyword; v2 uses "full" for a full-resolution image.
full_size = "max" if version == 3 else "full"
image_id = urljoin([service_id_url, "full", full_size, "0", "default.jpg"])
if version == 2:
service = {
"@id": service_id_url,
"@type": "ImageService2",
"profile": f"http://iiif.io/api/image/2/{level}.json",
}
else:
service = {"id": service_id_url, "type": "ImageService3", "profile": level}
# The probe service is declared on the access-controlled resource — here the
# image annotation body — alongside its image service.
body_services = [service] if auth is None else [service, auth]
canvas_id = _canvas_uri(manifest_id_url, index)
canvas: dict = {"id": canvas_id, "type": "Canvas"}
if label is not None:
canvas["label"] = _language_map(label)
canvas["width"] = width
canvas["height"] = height
canvas["items"] = [
{
"id": urljoin([canvas_id, "page", "1"]),
"type": "AnnotationPage",
"items": [
{
"id": urljoin([canvas_id, "annotation", "1"]),
"type": "Annotation",
"motivation": "painting",
"target": canvas_id,
"body": {
"id": image_id,
"type": "Image",
"format": "image/jpeg",
"width": width,
"height": height,
"service": body_services,
},
}
],
}
]
return canvas
def _navplace_with_feature_ids(nav_place: dict, manifest_id: str) -> dict:
"""Return a copy of a navPlace FeatureCollection with feature ids filled in.
The navPlace extension recommends an ``id`` on each Feature; any Feature
lacking one gets a synthesized ``{manifest_id}/navplace/feature/{n}`` id
(1-based), matching djiiif's synthetic-URI scheme. Caller data is not mutated
— the Features are shallow-copied first.
Args:
nav_place: The resolved navPlace ``FeatureCollection`` dict.
manifest_id: The manifest URI, the stem for synthesized Feature ids.
Returns:
A new ``FeatureCollection`` dict with every Feature carrying an ``id``.
"""
features = []
for index, feature in enumerate(nav_place.get("features", []), start=1):
feature = dict(feature)
feature.setdefault("id", urljoin([manifest_id, "navplace", "feature", str(index)]))
features.append(feature)
return {**nav_place, "features": features}
[docs]
def build_multi_manifest(
id_url: str,
images,
*,
label,
version: int | None = None,
level: str | None = None,
auth: dict | None = None,
nav_place: dict | None = None,
**descriptors,
) -> dict:
"""Build a multi-canvas IIIF Presentation API 3.0 Manifest.
Presents several images as one manifest — recto/verso, a paged object, detail
shots — by repeating the single-canvas structure with per-canvas indexed
``id`` URIs. The document is always Presentation 3.0; each embedded image
service reflects the Image API ``version``.
Args:
id_url: The manifest's image service base URI (``{host}/{identifier}``),
the stem for the synthetic manifest/canvas URIs.
images: A sequence of per-canvas specs — ``(service_id_url, width,
height)`` tuples, or dicts with ``id`` / ``width`` / ``height`` and an
optional ``label`` (see :func:`_normalize_image_spec`).
label: Manifest label (coerced via :func:`_language_map`).
version: Image API version of the embedded image services (``2`` or
``3``); defaults to ``settings.IIIF_IMAGE_API_VERSION`` (``3``).
level: Advertised compliance level; defaults to
``settings.IIIF_COMPLIANCE_LEVEL`` (``"level2"``).
auth: An optional resolved Authorization Flow 2.0 probe-service ``dict``
applied to **every** image body; only valid at version 3.
nav_place: An optional GeoJSON ``FeatureCollection`` dict (see
:func:`djiiif.geo.resolve_navplace`) emitted as the manifest's
``navPlace``. When present, ``@context`` becomes the two-element
navPlace array and Features gain synthesized ids.
**descriptors: Optional descriptive properties (keys in
:data:`DESCRIPTOR_KEYS`) emitted at the manifest top level.
Returns:
The manifest as a dict, ready for ``JsonResponse``.
Raises:
ImproperlyConfigured: If ``version`` is unknown, ``auth`` is set while not
on version 3, or a descriptor key is unknown.
"""
version = _api_version(version)
level = _compliance_level(level)
_require_auth_v3(auth, version)
_reject_unknown_descriptors(descriptors)
canvases = []
for index, image in enumerate(images, start=1):
service_id_url, width, height, canvas_label = _normalize_image_spec(image)
canvases.append(
_build_canvas(
id_url,
index,
service_id_url,
width,
height,
label=canvas_label,
version=version,
level=level,
auth=auth,
)
)
manifest_id = _manifest_uri(id_url)
manifest = {
"@context": PRESENTATION_CONTEXT if nav_place is None else NAVPLACE_CONTEXT,
"id": manifest_id,
"type": "Manifest",
"label": _language_map(label),
}
manifest.update(_descriptive_properties(descriptors))
if nav_place is not None:
manifest["navPlace"] = _navplace_with_feature_ids(nav_place, manifest_id)
manifest["items"] = canvases
return manifest
[docs]
def build_manifest(
id_url: str,
width: int,
height: int,
*,
label,
version: int | None = None,
level: str | None = None,
auth: dict | None = None,
nav_place: dict | None = None,
**descriptors,
) -> dict:
"""Build a minimal single-image IIIF Presentation API 3.0 Manifest.
A thin wrapper over :func:`build_multi_manifest` for the common one-image
case: the manifest wraps one image on one canvas so it opens directly in
viewers like Mirador or OpenSeadragon. With no ``descriptors`` its output is
identical to the historical single-image manifest.
Args:
id_url: The image service base URI (``{host}/{identifier}``), reused as
the image service ``id`` and as the stem for the synthetic URIs.
width: Image width in pixels.
height: Image height in pixels.
label: Human-readable label for the manifest (coerced via
:func:`_language_map`).
version: Image API version of the embedded image service (``2`` or
``3``); defaults to ``settings.IIIF_IMAGE_API_VERSION`` (``3``).
level: Advertised compliance level; defaults to
``settings.IIIF_COMPLIANCE_LEVEL`` (``"level2"``).
auth: An optional resolved Authorization Flow 2.0 probe-service ``dict``
(see :func:`resolve_auth`). When present it is added to the
access-controlled image body's ``service`` array; only valid at
version 3.
nav_place: An optional GeoJSON ``FeatureCollection`` (see
:func:`djiiif.geo.resolve_navplace`) emitted as the manifest's
``navPlace``; switches ``@context`` to the navPlace array.
**descriptors: Optional descriptive properties (keys in
:data:`DESCRIPTOR_KEYS`) emitted at the manifest top level.
Returns:
The manifest as a dict, ready for ``JsonResponse``.
Raises:
ImproperlyConfigured: If ``version`` is unknown, ``auth`` is set while not
on version 3, or a descriptor key is unknown.
"""
return build_multi_manifest(
id_url,
[(id_url, width, height)],
label=label,
version=version,
level=level,
auth=auth,
nav_place=nav_place,
**descriptors,
)
[docs]
def build_collection(id_url: str, items, *, label, **descriptors) -> dict:
"""Build a IIIF Presentation API 3.0 Collection of manifest references.
A Collection groups manifests for browsing — the natural rendering of a
Django queryset ("all photos in this album"). It embeds only *references* to
each manifest (id/type/label/thumbnail), never the manifests themselves, so
even a large collection stays a small response.
Args:
id_url: The Collection's own ``id`` URI.
items: A sequence of member entries — ``(manifest_url, label)`` or
``(manifest_url, label, thumbnail)`` tuples, or already-formed member
dicts (passed through). ``label`` is coerced via
:func:`_language_map`; ``thumbnail`` via :func:`_thumbnail`.
label: Collection label (coerced via :func:`_language_map`).
**descriptors: Optional descriptive properties (keys in
:data:`DESCRIPTOR_KEYS`) emitted at the collection top level.
Returns:
The Collection as a dict, ready for ``JsonResponse``.
Raises:
ImproperlyConfigured: If a descriptor key is unknown.
"""
_reject_unknown_descriptors(descriptors)
collection = {
"@context": PRESENTATION_CONTEXT,
"id": id_url,
"type": "Collection",
"label": _language_map(label),
}
collection.update(_descriptive_properties(descriptors))
collection["items"] = [_collection_item(item) for item in items]
return collection
def _collection_item(item) -> dict:
"""Build one Collection member reference.
Args:
item: A ``(manifest_url, label)`` or ``(manifest_url, label, thumbnail)``
tuple, or an already-formed member dict (passed through).
Returns:
A ``{"id", "type": "Manifest", "label"[, "thumbnail"]}`` reference dict.
"""
if isinstance(item, dict):
return item
manifest_url, label, *rest = item
entry = {"id": manifest_url, "type": "Manifest", "label": _language_map(label)}
if rest:
entry["thumbnail"] = _thumbnail(rest[0])
return entry
[docs]
def encode_content_state(state: dict | str) -> str:
"""Encode a content state for use as an ``iiif-content`` query parameter.
Implements the IIIF Content State API 1.0 §6 encoding: serialize to JSON
(when given a ``dict``), percent-encode the UTF-8 string exactly as
JavaScript's ``encodeURIComponent`` does, base64url-encode that, and strip the
``=`` padding. The percent-encoding step is part of the spec — the resulting
string round-trips through any viewer's ``decodeURIComponent``.
Args:
state: A content-state ``dict`` (e.g. from :func:`build_content_state`)
or a bare resource-URI ``str`` (the spec's trivial form).
Returns:
The URL-safe, unpadded encoded string.
"""
text = state if isinstance(state, str) else json.dumps(state, separators=(",", ":"))
# encodeURIComponent leaves A-Z a-z 0-9 - _ . ! ~ * ' ( ) unescaped; quote
# already treats the alphanumerics and -_.~ as safe, so only the punctuation
# differs from quote's default reserved set.
percent_encoded = quote(text, safe="!~*'()")
encoded = base64.urlsafe_b64encode(percent_encoded.encode("utf-8")).decode("ascii")
return encoded.rstrip("=")
[docs]
def decode_content_state(encoded: str) -> dict | str:
"""Decode an ``iiif-content`` value back into a content state.
The inverse of :func:`encode_content_state`: restore the stripped base64url
padding, base64url-decode, reverse the percent-encoding, and ``json.loads``
the result. A payload that is not JSON (a bare resource-URI string) is
returned verbatim.
Args:
encoded: The encoded string from an ``iiif-content`` parameter.
Returns:
The decoded content-state ``dict``, or the bare URI ``str`` for a
non-JSON payload.
"""
padding = "=" * (-len(encoded) % 4)
percent_encoded = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
text = unquote(percent_encoded)
try:
return json.loads(text)
except json.JSONDecodeError:
return text
def _format_xywh(xywh: str | tuple[int, int, int, int]) -> str:
"""Normalize an ``xywh`` region into the ``x,y,w,h`` media-fragment string.
Args:
xywh: A preformatted ``"x,y,w,h"`` string (used verbatim) or a 4-tuple
of ints (formatted to ``x,y,w,h``).
Returns:
The ``x,y,w,h`` string.
"""
if isinstance(xywh, str):
return xywh
return ",".join(str(v) for v in xywh)
[docs]
def build_content_state(
manifest_id: str,
*,
canvas_id: str | None = None,
xywh: str | tuple[int, int, int, int] | None = None,
) -> dict:
"""Build a IIIF Content State API 1.0 annotation targeting a resource.
Produces the spec's simplified target forms (Content State API 1.0 §3.2):
with only ``manifest_id`` the state targets the Manifest; with ``canvas_id``
it targets that Canvas and carries a ``partOf`` back to the Manifest; ``xywh``
appends an ``#xywh=`` media-fragment to the canvas id to select a region.
Args:
manifest_id: The Manifest URI (e.g. ``{host}/{identifier}/manifest``).
canvas_id: The Canvas URI to target within the manifest; omit to target
the whole Manifest.
xywh: An optional region as an ``x,y,w,h`` string or 4-tuple of ints.
Ignored when ``canvas_id`` is ``None``.
Returns:
The content-state ``dict``, ready for :func:`encode_content_state`.
"""
if canvas_id is None:
return {"id": manifest_id, "type": "Manifest"}
target_id = canvas_id if xywh is None else f"{canvas_id}#xywh={_format_xywh(xywh)}"
return {
"id": target_id,
"type": "Canvas",
"partOf": [{"id": manifest_id, "type": "Manifest"}],
}
# @context for the IIIF Change Discovery API. The discovery context re-exports the
# ActivityStreams 2.0 vocabulary; both are listed (the array form the brief
# specifies) so an AS2-only consumer still resolves the activity terms.
DISCOVERY_CONTEXT = [
"http://iiif.io/api/discovery/1/context.json",
"https://www.w3.org/ns/activitystreams",
]
[docs]
@dataclass(frozen=True)
class Activity:
"""One IIIF Change Discovery activity over a IIIF resource.
The typed, opt-in shape for an ``IIIF_ACTIVITY_SOURCE`` entry — a structured
alternative to a plain ``dict`` with the same keys and defaults, normalized by
:func:`resolve_activity` (the ``Profile``/``ProbeService`` dual-shape
precedent).
Attributes:
object_id: The activity object's URL (a manifest or collection).
end_time: The modification timestamp — an aware ``datetime`` (serialized
ISO 8601) or a preformed ISO 8601 string.
type: The activity type — ``"Update"`` (default) or ``"Create"`` (both
level-1). ``"Delete"`` is level-2 and out of scope here.
object_type: The object's IIIF type — ``"Manifest"`` (default) or
``"Collection"``.
"""
object_id: str
end_time: datetime | str
type: str = "Update"
object_type: str = "Manifest"
[docs]
def resolve_activity(entry) -> dict:
"""Normalize an ``IIIF_ACTIVITY_SOURCE`` entry to a plain dict.
Mirrors :func:`resolve_profile`: an entry may be an :class:`Activity` or a
plain ``dict`` (with ``object_id`` / ``end_time`` required and ``type`` /
``object_type`` optional). Both normalize to a dict carrying every key with
defaults applied.
Args:
entry: An :class:`Activity` or a ``dict`` from the activity source.
Returns:
A dict with keys ``object_id``, ``end_time``, ``type``, ``object_type``.
Raises:
ImproperlyConfigured: If ``entry`` is neither an ``Activity`` nor a
``dict``.
"""
if isinstance(entry, Activity):
return {
"object_id": entry.object_id,
"end_time": entry.end_time,
"type": entry.type,
"object_type": entry.object_type,
}
if isinstance(entry, dict):
return {
"object_id": entry["object_id"],
"end_time": entry["end_time"],
"type": entry.get("type", "Update"),
"object_type": entry.get("object_type", "Manifest"),
}
raise ImproperlyConfigured(
f"Each IIIF_ACTIVITY_SOURCE entry must be an Activity or dict, got "
f"{type(entry).__name__}."
)
[docs]
def build_activity(
object_id: str,
end_time: datetime | str,
*,
activity_type: str = "Update",
object_type: str = "Manifest",
) -> dict:
"""Build one ActivityStreams activity for an ``orderedItems`` list.
Args:
object_id: The activity object's URL (a manifest or collection).
end_time: The modification timestamp — an aware ``datetime`` (serialized
via ``.isoformat()``) or a preformed ISO 8601 string.
activity_type: The activity type (``"Update"`` / ``"Create"``).
object_type: The object's IIIF type (``"Manifest"`` / ``"Collection"``).
Returns:
The activity dict — ``{"type", "object": {"id", "type"}, "endTime"}``.
No ``@context`` (activities are always embedded in a page).
"""
end = end_time.isoformat() if isinstance(end_time, datetime) else end_time
return {
"type": activity_type,
"object": {"id": object_id, "type": object_type},
"endTime": end,
}
[docs]
def build_ordered_collection(
id_url: str, total: int, first_url: str, last_url: str
) -> dict:
"""Build the Change Discovery ``OrderedCollection`` entry point.
Args:
id_url: The collection's own URL.
total: ``totalItems`` — the activity count across all pages.
first_url: URL of the first ``OrderedCollectionPage``.
last_url: URL of the last ``OrderedCollectionPage``.
Returns:
The ``OrderedCollection`` dict, ready for ``JsonResponse``.
"""
return {
"@context": DISCOVERY_CONTEXT,
"id": id_url,
"type": "OrderedCollection",
"totalItems": total,
"first": {"id": first_url, "type": "OrderedCollectionPage"},
"last": {"id": last_url, "type": "OrderedCollectionPage"},
}
[docs]
def build_collection_page(
id_url: str,
collection_url: str,
activities: list[dict],
*,
prev_url: str | None = None,
next_url: str | None = None,
start_index: int | None = None,
) -> dict:
"""Build one Change Discovery ``OrderedCollectionPage``.
Args:
id_url: This page's own URL.
collection_url: URL of the owning ``OrderedCollection`` (emitted as
``partOf``).
activities: The page's activities (see :func:`build_activity`), in
ascending ``endTime`` order.
prev_url: URL of the previous page; omitted on the first page.
next_url: URL of the next page; omitted on the last page.
start_index: 0-based index of this page's first item within the whole
stream; omitted when ``None``.
Returns:
The ``OrderedCollectionPage`` dict, ready for ``JsonResponse``.
"""
page = {
"@context": DISCOVERY_CONTEXT,
"id": id_url,
"type": "OrderedCollectionPage",
"partOf": {"id": collection_url, "type": "OrderedCollection"},
}
if prev_url is not None:
page["prev"] = {"id": prev_url, "type": "OrderedCollectionPage"}
if next_url is not None:
page["next"] = {"id": next_url, "type": "OrderedCollectionPage"}
if start_index is not None:
page["startIndex"] = start_index
page["orderedItems"] = activities
return page
# @context for a served AnnotationPage: the W3C Web Annotation context plus the
# IIIF Presentation 3 context (the pairing viewers expect for non-painting
# annotations referenced from a manifest's Canvas ``annotations``).
ANNOTATION_CONTEXT = [
"http://www.w3.org/ns/anno.jsonld",
"http://iiif.io/api/presentation/3/context.json",
]
# @context for a Content Search 2.0 response (a search-flavored AnnotationPage).
SEARCH_CONTEXT = "http://iiif.io/api/search/2/context.json"
# Every field of the shared Annotation shape, with its default. resolve_annotation
# fills these for a plain-dict entry so the builders can read a uniform dict.
_ANNOTATION_DEFAULTS: dict = {
"text": None,
"motivation": "supplementing",
"xywh": None,
"language": None,
"format": "text/plain",
"id": None,
"canvas_id": None,
"exact": None,
"before": None,
"after": None,
}
[docs]
@dataclass(frozen=True)
class Annotation:
"""A W3C Web Annotation over a canvas — the shared annotation/search hit type.
One typed, opt-in shape for both the annotation-serving backend
(``IIIF_ANNOTATIONS_BACKEND``) and the search backend
(``IIIF_SEARCH_BACKEND``): a search hit *is* an annotation plus snippet
context. A plain ``dict`` with the same keys works too, normalized by
:func:`resolve_annotation` (the ``Profile``/``ProbeService`` dual-shape
precedent).
Attributes:
text: The body value — a string wrapped as a ``TextualBody``, or a
preformed ``body`` ``dict`` for a non-textual body.
motivation: The annotation motivation; ``"supplementing"`` (default,
the transcription/OCR case), ``"commenting"``, ``"tagging"``, …
xywh: Optional ``x,y,w,h`` region on the target canvas.
language: Optional ``TextualBody`` language.
format: ``TextualBody`` format (default ``"text/plain"``).
id: Optional stable annotation id; synthesized when absent.
canvas_id: The target canvas URI — required for a search hit (which
locates itself on a canvas); unused when serving an annotation page
(the canvas is implied by the page URL).
exact: The matched substring for a search snippet (defaults to the query
``q`` when absent).
before: Text preceding the match (the snippet ``prefix``).
after: Text following the match (the snippet ``suffix``).
"""
text: str | dict | None = None
motivation: str = "supplementing"
xywh: str | None = None
language: str | None = None
format: str = "text/plain"
id: str | None = None
canvas_id: str | None = None
exact: str | None = None
before: str | None = None
after: str | None = None
[docs]
def resolve_annotation(entry) -> dict:
"""Normalize an annotation/search-hit entry to a plain dict.
Mirrors :func:`resolve_profile`: an entry may be an :class:`Annotation` or a
plain ``dict`` (any subset of the annotation keys; missing keys take their
defaults). Both normalize to a dict carrying every key in
:data:`_ANNOTATION_DEFAULTS`.
Args:
entry: An :class:`Annotation` or a ``dict``.
Returns:
A dict with every annotation key populated.
Raises:
ImproperlyConfigured: If ``entry`` is neither an ``Annotation`` nor a
``dict``.
"""
if isinstance(entry, Annotation):
return {key: getattr(entry, key) for key in _ANNOTATION_DEFAULTS}
if isinstance(entry, dict):
return {**_ANNOTATION_DEFAULTS, **entry}
raise ImproperlyConfigured(
"Each annotation/search entry must be an Annotation or dict, got "
f"{type(entry).__name__}."
)
def _annotation_body(ann: dict) -> dict:
"""Build an annotation ``body`` from a resolved annotation dict.
Args:
ann: A resolved annotation dict (see :func:`resolve_annotation`).
Returns:
The ``text`` verbatim if it is already a preformed ``body`` dict;
otherwise a ``TextualBody`` wrapping the string value (with ``language``
when set).
"""
text = ann["text"]
if isinstance(text, dict):
return text
body = {"type": "TextualBody", "value": text, "format": ann["format"]}
if ann["language"] is not None:
body["language"] = ann["language"]
return body
[docs]
def build_annotation(page_url: str, index: int, canvas_id: str | None, item) -> dict:
"""Build one W3C Annotation targeting a canvas.
Args:
page_url: The owning AnnotationPage / search URL, the stem for a
synthesized id.
index: The 1-based item index (used for the synthesized id).
canvas_id: The target canvas URI; when ``None`` the annotation's own
``canvas_id`` is used (the search-hit case).
item: An :class:`Annotation` or annotation ``dict``.
Returns:
The Annotation dict — ``{id, type, motivation, body, target}`` — where
``target`` is the canvas URI, plus an ``#xywh=`` fragment when the item
carries a region.
"""
ann = resolve_annotation(item)
anno_id = ann["id"] or urljoin([page_url, "anno", str(index)])
target_canvas = canvas_id if canvas_id is not None else ann["canvas_id"]
target = target_canvas if not ann["xywh"] else f"{target_canvas}#xywh={ann['xywh']}"
return {
"id": anno_id,
"type": "Annotation",
"motivation": ann["motivation"],
"body": _annotation_body(ann),
"target": target,
}
[docs]
def build_annotation_page(page_url: str, canvas_id: str, items) -> dict:
"""Build a W3C ``AnnotationPage`` of annotations over one canvas.
Args:
page_url: This page's own URL (also the ``id``).
canvas_id: The canvas every annotation targets.
items: An iterable of :class:`Annotation` or annotation ``dict`` entries.
Returns:
The ``AnnotationPage`` dict, ready for ``JsonResponse``. An empty
``items`` yields a spec-valid empty page.
"""
return {
"@context": ANNOTATION_CONTEXT,
"id": page_url,
"type": "AnnotationPage",
"items": [
build_annotation(page_url, i, canvas_id, item)
for i, item in enumerate(items, start=1)
],
}
[docs]
def build_search_service(search_url: str) -> dict:
"""Build the ``SearchService2`` advertisement block.
Args:
search_url: The Content Search 2.0 endpoint URL.
Returns:
The ``{"id", "type": "SearchService2"}`` service dict.
"""
return {"id": search_url, "type": "SearchService2"}
[docs]
def build_search_response(
search_url: str, q: str, hits, *, ignored=()
) -> dict:
"""Build a Content Search 2.0 response (a search-flavored ``AnnotationPage``).
Emits the matching annotations fully embedded in ``items`` and a match block
in ``annotations`` — an ``AnnotationPage`` of ``contextualizing`` annotations,
each targeting a ``SpecificResource`` (the matched annotation) via a
``TextQuoteSelector`` (``prefix``/``exact``/``suffix``). ``exact`` defaults to
the query ``q``.
Args:
search_url: The search endpoint URL (the response ``id`` appends
``?q=``).
q: The query string.
hits: An iterable of :class:`Annotation` or hit ``dict`` entries; each
supplies its own ``canvas_id``.
ignored: Request parameter names the server did not act on, echoed in the
``ignored`` list.
Returns:
The search ``AnnotationPage`` dict, ready for ``JsonResponse``.
"""
items = []
match_items = []
for index, hit in enumerate(hits, start=1):
item = build_annotation(search_url, index, None, hit)
items.append(item)
ann = resolve_annotation(hit)
match_items.append(
{
"id": urljoin([search_url, "match", str(index)]),
"type": "Annotation",
"motivation": "contextualizing",
"target": {
"type": "SpecificResource",
"source": item["id"],
"selector": {
"type": "TextQuoteSelector",
"prefix": ann["before"] or "",
"exact": ann["exact"] if ann["exact"] is not None else q,
"suffix": ann["after"] or "",
},
},
}
)
response = {
"@context": SEARCH_CONTEXT,
"id": f"{search_url}?q={q}",
"type": "AnnotationPage",
}
if ignored:
response["ignored"] = list(ignored)
response["items"] = items
if match_items:
response["annotations"] = [
{
"id": urljoin([search_url, "annotations"]),
"type": "AnnotationPage",
"items": match_items,
}
]
return response
[docs]
class IIIFObject(object):
"""The ``.iiif`` accessor for a field file: profile URLs and documents.
Constructed fresh on each ``IIIFFieldFile.iiif`` access. For a populated
field it eagerly sets one attribute per ``settings.IIIF_PROFILES`` name (the
assembled Image API URL) plus ``info`` (the external ``info.json`` URL) and
``identifier`` (the plain ``{host}/{identifier}`` base URI). For an empty or
unset field every URL attribute is the empty string.
The generated documents — :attr:`info_document` and :attr:`manifest` — are
lazy ``cached_property`` values because, unlike the URL attributes, they read
the image's pixel dimensions from storage. Constructing an ``IIIFObject``
therefore never touches the file.
"""
def __init__(self, parent):
"""Assemble the profile/``info``/``identifier`` URLs for ``parent``.
Args:
parent: The :class:`IIIFFieldFile` this accessor belongs to. Stored
as ``self._parent`` so the lazy document properties can read its
``width``/``height``.
"""
self._parent = parent
# Remember the configured profile names (in order) so as_dict() can
# collect their URLs without re-reading settings.
self._profile_names = list(settings.IIIF_PROFILES)
# Encode the identifier once; it is shared by every URL below. Empty for
# an unset field, which drives the empty-string branch throughout.
identifier = encode_identifier(parent.name) if parent.name else ""
# Track the host from the last resolved profile so the info/identifier
# URLs can reuse it. Starts as None so an empty IIIF_PROFILES (or an
# unset field) yields empty strings instead of raising.
host: str | None = None
for name, profile in settings.IIIF_PROFILES.items():
if identifier:
spec = resolve_profile(profile, parent)
host = spec["host"]
setattr(self, name, image_url(spec, identifier))
else:
setattr(self, name, "")
if identifier and host:
self.info = urljoin([host, identifier, "info.json"])
self.identifier = urljoin([host, identifier])
else:
self.info = ""
self.identifier = ""
[docs]
def as_dict(self, *, include_meta: bool = False) -> dict[str, str]:
"""Return the profile URLs as a plain ``dict``, keyed by profile name.
Handy for JSON APIs (see :mod:`djiiif.serializers`) and for iterating
profiles in a template. For an empty/unset field every value is ``""``.
Args:
include_meta: When true, also include the ``info`` and ``identifier``
URLs under those keys.
Returns:
A ``{profile_name: url}`` mapping, optionally with ``info`` and
``identifier`` entries appended.
"""
data = {name: getattr(self, name) for name in self._profile_names}
if include_meta:
data["info"] = self.info
data["identifier"] = self.identifier
return data
@cached_property
def info_document(self) -> dict | None:
"""The IIIF Image API ``info.json`` **document** (not its URL).
Distinct from :attr:`info`, which returns the *URL* of an external
``info.json`` served by an image server. This builds the document here
from the image's own ``width``/``height`` and the :attr:`identifier`
base URI, so a view can serve a minimal ``info.json`` without a separate
image server (e.g. ``JsonResponse(field.iiif.info_document)``).
Accessing this reads the image from storage; the eager URL attributes
never do. Shape is controlled by ``settings.IIIF_IMAGE_API_VERSION``
(default ``3``) and ``settings.IIIF_COMPLIANCE_LEVEL`` (default
``"level2"``). When ``settings.IIIF_AUTH`` resolves to a probe service
for this image, its Authorization Flow 2.0 ``service`` block is included;
when ``settings.IIIF_INFO`` resolves to extras for this image (see
:func:`resolve_info`), those declarative properties (``sizes``, ``tiles``,
limits, ``rights``, …) are emitted too.
Returns:
The ``info.json`` document, or ``None`` for an empty/unset field.
"""
if not self.identifier:
return None
return build_info_document(
self.identifier,
self._parent.width,
self._parent.height,
auth=resolve_auth(self._parent),
extras=resolve_info(self._parent),
)
@cached_property
def manifest(self) -> dict | None:
"""A minimal single-image IIIF Presentation API 3.0 Manifest.
Wraps this image in a one-canvas manifest suitable for Mirador or
OpenSeadragon. The label defaults to the file's base name. Like
:attr:`info_document`, accessing this reads the image dimensions from
storage. When ``settings.IIIF_AUTH`` resolves to a probe service for this
image, its Authorization Flow 2.0 ``service`` block is attached to the
image body. When ``settings.IIIF_MANIFEST_DESCRIPTORS`` resolves to a
descriptor bag for this image (see :func:`resolve_manifest_descriptors`),
its descriptive properties (``metadata``, ``rights``, …) are emitted at
the manifest top level. When ``settings.IIIF_NAVPLACE`` resolves to a
geometry for this image (see :func:`djiiif.geo.resolve_navplace`), its
``navPlace`` FeatureCollection is emitted (with the navPlace ``@context``).
Returns:
The manifest document, or ``None`` for an empty/unset field.
"""
if not self.identifier:
return None
# Imported lazily so the core module never imports the optional geo bridge.
from djiiif.geo import resolve_navplace
label = self._parent.name.rsplit("/", 1)[-1]
return build_manifest(
self.identifier,
self._parent.width,
self._parent.height,
label=label,
auth=resolve_auth(self._parent),
nav_place=resolve_navplace(self._parent),
**resolve_manifest_descriptors(self._parent),
)
[docs]
def content_state(
self,
*,
xywh: str | tuple[int, int, int, int] | None = None,
encoded: bool = True,
) -> str | dict | None:
"""Build a IIIF Content State for a shareable deep link to this image.
Targets this image's own Canvas (with a ``partOf`` back to its Manifest),
deriving both URIs the same way :attr:`manifest` does — so the state opens
the exact image the manifest describes. Pass ``xywh`` to open zoomed to a
region. Unlike :attr:`manifest`, this reads nothing from storage.
Args:
xywh: An optional region as an ``x,y,w,h`` string or 4-tuple of ints;
omit for a whole-image state.
encoded: When true (default), return the URL-safe encoded string
ready to drop into ``?iiif-content=``; when false, return the raw
content-state ``dict``.
Returns:
The encoded string (``""`` for an empty/unset field), or the raw
``dict`` (``None`` for an empty/unset field) when ``encoded`` is
false.
"""
if not self.identifier:
return "" if encoded else None
state = build_content_state(
_manifest_uri(self.identifier),
canvas_id=_canvas_uri(self.identifier),
xywh=xywh,
)
return encode_content_state(state) if encoded else state
[docs]
class IIIFFieldFile(ImageFieldFile):
"""An ``ImageFieldFile`` that exposes an ``.iiif`` accessor."""
@property
def iiif(self) -> IIIFObject:
"""Return a freshly built :class:`IIIFObject` for this field file."""
return IIIFObject(self)
[docs]
class IIIFField(ImageField):
"""An ``ImageField`` whose field files carry IIIF URLs via ``.iiif``."""
attr_class = IIIFFieldFile