"""IIIF Content State API 1.0: decode and encode shareable references.
A content state points at a IIIF resource (a manifest, a canvas, a region of a
canvas) in a form that can be passed around — most commonly a base64url-encoded
(padding-stripped) JSON annotation, but also a plain resource URI or inline JSON.
:func:`decode` turns any of those into a :class:`ContentState`, whose
:attr:`~ContentState.reference` can be handed to ``client.resolve`` to fetch the
containing document. :func:`encode` produces the compact base64url token.
"""
import base64
import binascii
import json
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from .errors import IIISightError
from .model import Reference
_PRESENTATION_CONTEXT = "http://iiif.io/api/presentation/3/context.json"
[docs]
class ContentStateError(IIISightError):
"""A content-state token could not be decoded."""
[docs]
@dataclass(frozen=True)
class ContentState:
"""A decoded content state.
Attributes:
target_id: The referenced resource id (a canvas, manifest, etc.).
target_type: Its type, if known.
part_of: The containing manifest id, if the target is within one.
region: A selector/fragment value (e.g. ``xywh=0,0,10,10``), if any.
raw: The decoded annotation, when the token carried one.
"""
target_id: str
target_type: str | None = None
part_of: str | None = None
region: str | None = None
raw: dict[str, Any] | None = None
@property
def reference(self) -> Reference:
"""A :class:`Reference` to the fetchable document.
Prefers the containing manifest (``part_of``) when known — a canvas URI is
often not directly dereferenceable — else the target itself.
"""
if self.part_of:
return Reference(id=self.part_of, type="Manifest")
return Reference(id=self.target_id, type=self.target_type or "Manifest")
[docs]
def decode(token: str) -> ContentState:
"""Decode a content-state token into a :class:`ContentState`."""
data = _decode_payload(token)
if isinstance(data, str):
base, _, fragment = data.partition("#")
return ContentState(target_id=base, region=fragment or None)
return _from_annotation(data)
[docs]
def encode(
target_id: str,
*,
target_type: str = "Canvas",
part_of: str | None = None,
region: str | None = None,
) -> str:
"""Encode a reference as a base64url content-state token (padding stripped)."""
target: dict[str, Any] = {
"id": f"{target_id}#{region}" if region else target_id,
"type": target_type,
}
if part_of:
target["partOf"] = [{"id": part_of, "type": "Manifest"}]
annotation = {
"@context": _PRESENTATION_CONTEXT,
"id": "https://iiisight.example/content-state",
"type": "Annotation",
"motivation": ["contentState"],
"target": target,
}
payload = json.dumps(annotation, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def _decode_payload(token: str) -> str | dict[str, Any]:
text = token.strip()
if text.startswith(("http://", "https://")):
return text
if text.startswith("{"):
return _load_json(text.encode("utf-8"))
padded = text + "=" * (-len(text) % 4)
try:
raw = base64.urlsafe_b64decode(padded)
except (binascii.Error, ValueError) as exc:
raise ContentStateError(f"not valid base64url: {exc}") from exc
return _load_json(raw)
def _load_json(raw: bytes) -> dict[str, Any]:
try:
data = json.loads(raw)
except (ValueError, UnicodeDecodeError) as exc:
raise ContentStateError(f"content state was not valid JSON: {exc}") from exc
if not isinstance(data, dict):
raise ContentStateError("content state JSON must be an object")
return data
def _from_annotation(data: dict[str, Any]) -> ContentState:
target = data.get("target")
if isinstance(target, str):
base, _, fragment = target.partition("#")
return ContentState(target_id=base, region=fragment or None, raw=data)
if isinstance(target, Mapping):
return _from_target_object(target, data)
# No target: treat the annotation itself as the referenced resource.
return ContentState(
target_id=str(data.get("id") or data.get("@id") or ""),
target_type=data.get("type"),
raw=data,
)
def _from_target_object(target: Mapping[str, Any], raw: dict[str, Any]) -> ContentState:
target_id = str(target.get("id") or target.get("@id") or target.get("source") or "")
base, _, fragment = target_id.partition("#")
region = fragment or None
selector = target.get("selector")
if isinstance(selector, Mapping) and selector.get("value"):
region = str(selector["value"])
part_of = None
within = target.get("partOf")
first = within[0] if isinstance(within, list) and within else within
if isinstance(first, Mapping):
part_of = first.get("id") or first.get("@id")
return ContentState(
target_id=base,
target_type=target.get("type"),
part_of=part_of,
region=region,
raw=raw,
)