Source code for iiisight.model

"""The normalized IIIF model — a v3-shaped consumer subset of the Presentation
API, produced by :func:`iiisight.normalize.normalize`.

Every type is a frozen dataclass and every collection field is a tuple defaulting
to empty (never ``None``), so tolerant traversal never trips on a missing list.
Derived views (:attr:`Canvas.images`, :attr:`Canvas.image_service`) are pure
properties over the parsed fields — they never perform I/O.
"""

import math
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any

from .diagnostics import Diagnostic
from .errors import ImageServiceNotLoaded, UnknownScaleFactor
from .language import LanguageMap

# --- Value types ------------------------------------------------------------


[docs] @dataclass(frozen=True) class MetadataEntry: """A single ``metadata`` (or ``requiredStatement``) label/value pair.""" label: LanguageMap value: LanguageMap
[docs] @dataclass(frozen=True) class Agent: """A ``provider`` agent.""" id: str type: str = "Agent" label: LanguageMap | None = None homepage: tuple[Link, ...] = () logo: tuple["ContentResource", ...] = () see_also: tuple[Link, ...] = ()
[docs] @dataclass(frozen=True) class Selector: """A target selector (fragment ``xywh``/``t``, point, SVG, or Image API).""" type: str value: str | None = None conforms_to: str | None = None
[docs] @dataclass(frozen=True) class SpecificResource: """A reference to part of a resource: a source id plus an optional selector.""" source: str selector: Selector | None = None
[docs] @dataclass(frozen=True) class Reference: """An unresolved pointer (a collection or range item): id/type/label only.""" id: str type: str label: LanguageMap | None = None
[docs] @dataclass(frozen=True) class Service: """A non-image service on a resource (search, autocomplete, auth, …). Image API services attach to a :class:`ContentResource` as :class:`ImageService`; this generic type carries the manifest/canvas-level services (e.g. a Content Search endpoint) so callers can discover them. """ id: str type: str | None = None profile: str | None = None label: LanguageMap | None = None context: str | None = None raw: Mapping[str, Any] | None = None """The original service JSON, preserved for specialized consumers (e.g. auth, whose nested services and fields fall outside this consumer subset)."""
# --- Image API --------------------------------------------------------------
[docs] @dataclass(frozen=True) class Size: """A discrete available image size from ``info.json`` ``sizes``.""" width: int height: int
[docs] @dataclass(frozen=True) class TileSet: """A ``tiles`` entry: tile dimensions plus the scale factors it serves.""" width: int height: int | None scale_factors: tuple[int, ...]
[docs] @dataclass(frozen=True) class Tile: """A single computed tile request (region/size/URL).""" region: str size: str url: str
[docs] @dataclass(frozen=True) class ImageService: """An Image API service attached to a content resource. Carries only what the manifest embedded (usually ``id`` + profile) until ``client.load_info()`` sets :attr:`loaded` and populates the rest from ``info.json``. Phase 1 produces the unloaded stub; the loaded fields and the URL/tile helpers arrive in Phase 4. """ id: str api_version: int compliance_level: str | None = None loaded: bool = False protocol: str | None = None width: int | None = None height: int | None = None max_width: int | None = None max_height: int | None = None max_area: int | None = None sizes: tuple[Size, ...] = () tiles: tuple[TileSet, ...] = () preferred_formats: tuple[str, ...] = () extra_formats: tuple[str, ...] = () extra_qualities: tuple[str, ...] = () extra_features: tuple[str, ...] = ()
[docs] def url( self, region: str = "full", size: str = "max", rotation: str = "0", quality: str = "default", fmt: str | None = None, ) -> str: """Build an Image API URL for this service. ``size="max"`` emits ``full`` for a v2 service and ``max`` for v3 — the most common v2/v3 footgun — and other size tokens pass through unchanged. Args: region: Region token (``full``, ``square``, ``x,y,w,h``, ``pct:...``). size: Size token (``max``/``full``, ``w,``, ``,h``, ``w,h``, ``!w,h``, ``pct:n``). rotation: Rotation token. quality: Quality token. fmt: Format extension; defaults to a preferred format, else ``jpg``. Returns: The assembled ``{id}/{region}/{size}/{rotation}/{quality}.{fmt}`` URL. """ chosen_format = fmt or (self.preferred_formats[0] if self.preferred_formats else "jpg") return f"{self.id}/{region}/{self._size_token(size)}/{rotation}/{quality}.{chosen_format}"
[docs] def thumbnail_url(self, width: int) -> str: """Return a full-image URL near ``width`` px, preferring an advertised size. When ``sizes`` are advertised (the level-0-safe path), the smallest size at least ``width`` wide is used, else the largest available; otherwise a plain ``{width},`` size is requested. """ if self.sizes: ordered = sorted(self.sizes, key=lambda s: s.width) chosen = next((s for s in ordered if s.width >= width), ordered[-1]) return self.url(region="full", size=f"{chosen.width},") return self.url(region="full", size=f"{width},")
[docs] def tile_grid( self, scale_factor: int, quality: str = "default", fmt: str | None = None ) -> list[Tile]: """Enumerate the tile requests covering the whole image at ``scale_factor``. Requires a service populated by ``client.load_info()``. Raises: ImageServiceNotLoaded: If ``info.json`` has not been loaded. UnknownScaleFactor: If no tileset advertises ``scale_factor``. """ if not self.loaded or self.width is None or self.height is None: raise ImageServiceNotLoaded("call client.load_info() before requesting tiles") tileset = self._tileset_for(scale_factor) chosen_format = fmt or (self.preferred_formats[0] if self.preferred_formats else "jpg") tile_width = tileset.width tile_height = tileset.height or tileset.width step_x = tile_width * scale_factor step_y = tile_height * scale_factor columns = math.ceil(self.width / step_x) rows = math.ceil(self.height / step_y) tiles: list[Tile] = [] for row in range(rows): for column in range(columns): region_x = column * step_x region_y = row * step_y region_w = min(step_x, self.width - region_x) region_h = min(step_y, self.height - region_y) size_w = math.ceil(region_w / scale_factor) size_h = math.ceil(region_h / scale_factor) region = ( "full" if region_w == self.width and region_h == self.height else f"{region_x},{region_y},{region_w},{region_h}" ) size = self._canonical_size(size_w, size_h) url = f"{self.id}/{region}/{size}/0/{quality}.{chosen_format}" tiles.append(Tile(region=region, size=size, url=url)) return tiles
[docs] def tile_pyramid(self) -> list[Tile]: """Enumerate tiles across every advertised scale factor (the full pyramid).""" factors: list[int] = [] for tileset in self.tiles: for factor in tileset.scale_factors: if factor not in factors: factors.append(factor) pyramid: list[Tile] = [] for factor in sorted(factors): pyramid.extend(self.tile_grid(factor)) return pyramid
def _size_token(self, size: str) -> str: if size in ("max", "full"): return "full" if self.api_version == 2 else "max" return size def _tileset_for(self, scale_factor: int) -> "TileSet": for tileset in self.tiles: if scale_factor in tileset.scale_factors: return tileset raise UnknownScaleFactor(f"scale factor {scale_factor} is not advertised by {self.id}") def _canonical_size(self, size_w: int, size_h: int) -> str: if self.width is not None and self.height is not None: if size_w >= self.width and size_h >= self.height: return "full" if self.api_version == 2 else "max" return f"{size_w},"
# --- Content & annotations --------------------------------------------------
[docs] @dataclass(frozen=True) class ContentResource: """A painting/supplementing body: image, A/V, text, dataset, or model.""" id: str type: str format: str | None = None height: int | None = None width: int | None = None duration: float | None = None label: LanguageMap | None = None language: tuple[str, ...] = () service: tuple[ImageService, ...] = () services: tuple[Service, ...] = () """Non-image services on the body (e.g. an auth probe service)."""
[docs] @dataclass(frozen=True) class TextualBody: """An inline textual annotation body (e.g. a comment).""" value: str = "" type: str = "TextualBody" format: str | None = None language: tuple[str, ...] = ()
[docs] @dataclass(frozen=True) class Annotation: """A Web Annotation: a motivation, a body, and a target.""" id: str type: str = "Annotation" motivation: tuple[str, ...] = () body: "ContentResource | TextualBody | tuple[ContentResource | TextualBody, ...] | None" = None target: "str | SpecificResource | None" = None
[docs] @dataclass(frozen=True) class AnnotationPage: """A page of annotations.""" id: str type: str = "AnnotationPage" items: tuple[Annotation, ...] = ()
def _iter_bodies( body: "ContentResource | TextualBody | tuple | None", ) -> "Iterable[ContentResource | TextualBody]": """Yield each body of an annotation, flattening the single/tuple/None cases.""" if body is None: return if isinstance(body, tuple): yield from body else: yield body # --- Descriptive base -------------------------------------------------------
[docs] @dataclass(frozen=True) class Described: """The Presentation "descriptive properties" shared by most resources.""" id: str type: str label: LanguageMap | None = None metadata: tuple[MetadataEntry, ...] = () summary: LanguageMap | None = None required_statement: MetadataEntry | None = None rights: str | None = None provider: tuple[Agent, ...] = () thumbnail: tuple[ContentResource, ...] = () homepage: tuple[Link, ...] = () rendering: tuple[Link, ...] = () see_also: tuple[Link, ...] = () part_of: tuple[Reference, ...] = () services: tuple[Service, ...] = () behavior: tuple[str, ...] = () nav_date: str | None = None raw: Mapping[str, Any] | None = None """The parsed JSON this resource was normalized from, preserved for consumers that need the source verbatim (e.g. a harvester archiving the original metadata). v2 inputs are upgraded to v3 first, so ``raw`` reflects the v3-shaped document the model was built from."""
# --- Structural types -------------------------------------------------------
[docs] @dataclass(frozen=True) class Canvas(Described): """A view/page: spatial (``height``/``width``) and/or temporal (``duration``).""" height: int | None = None width: int | None = None duration: float | None = None items: tuple[AnnotationPage, ...] = () annotations: tuple[AnnotationPage, ...] = () @property def images(self) -> list[ContentResource]: """Painting-annotation image bodies on this canvas, in document order.""" result: list[ContentResource] = [] for page in self.items: for annotation in page.items: if "painting" not in annotation.motivation: continue for body in _iter_bodies(annotation.body): if isinstance(body, ContentResource) and body.type == "Image": result.append(body) return result @property def image_service(self) -> ImageService | None: """The first Image API service on this canvas's images, if any.""" for image in self.images: if image.service: return image.service[0] return None
[docs] @dataclass(frozen=True) class Range(Described): """A structural range (table-of-contents node).""" items: tuple["Canvas | Range | SpecificResource | Reference", ...] = () start: "Canvas | SpecificResource | None" = None
[docs] @dataclass(frozen=True) class Manifest(Described): """A single object: its canvases, structures, and manifest-level annotations.""" canvases: tuple[Canvas, ...] = () structures: tuple[Range, ...] = () annotations: tuple[AnnotationPage, ...] = () start: "Canvas | SpecificResource | None" = None viewing_direction: str | None = None diagnostics: tuple[Diagnostic, ...] = field(default_factory=tuple)
[docs] @dataclass(frozen=True) class Collection(Described): """A grouping of manifests and/or sub-collections (often as references).""" items: tuple["Manifest | Collection | Reference", ...] = () viewing_direction: str | None = None diagnostics: tuple[Diagnostic, ...] = field(default_factory=tuple)