Source code for iiisight.content_state

"""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, )