"""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 Link:
"""An external link: ``homepage``, ``rendering``, ``seeAlso``, or ``logo``."""
id: str
type: str | None = None
label: LanguageMap | None = None
format: str | None = None
profile: str | None = None
language: tuple[str, ...] = ()
[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)