apud 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apud-0.0.1/.gitignore +9 -0
- apud-0.0.1/PKG-INFO +19 -0
- apud-0.0.1/README.md +4 -0
- apud-0.0.1/pyproject.toml +23 -0
- apud-0.0.1/src/apud/__init__.py +3 -0
- apud-0.0.1/src/apud/annotations.py +79 -0
- apud-0.0.1/src/apud/client.py +53 -0
- apud-0.0.1/src/apud/iiif/__init__.py +21 -0
- apud-0.0.1/src/apud/iiif/image.py +124 -0
- apud-0.0.1/src/apud/iiif/manifest.py +112 -0
- apud-0.0.1/tests/conftest.py +28 -0
- apud-0.0.1/tests/samples.py +78 -0
- apud-0.0.1/tests/test_annotations.py +39 -0
- apud-0.0.1/tests/test_image.py +58 -0
- apud-0.0.1/tests/test_manifest.py +27 -0
apud-0.0.1/.gitignore
ADDED
apud-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: apud
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: IIIF-grounded, citable vision for historical documents: region crops for VLMs, W3C annotations out
|
|
5
|
+
Author: cadeos
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Keywords: digital-humanities,htr,iiif,manuscripts,vlm,web-annotation
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
11
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# apud
|
|
17
|
+
|
|
18
|
+
Core library of [apud](https://github.com/cadeos/apud): IIIF Image/Presentation API parsing,
|
|
19
|
+
model-sized region requests, W3C Web Annotations and IIIF Content State citations.
|
apud-0.0.1/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "apud"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "IIIF-grounded, citable vision for historical documents: region crops for VLMs, W3C annotations out"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "cadeos" }]
|
|
9
|
+
keywords = ["iiif", "htr", "manuscripts", "digital-humanities", "vlm", "web-annotation"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
14
|
+
"Topic :: Text Processing :: Linguistic",
|
|
15
|
+
]
|
|
16
|
+
dependencies = ["httpx>=0.27"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/apud"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""W3C Web Annotations anchored to IIIF canvases, plus IIIF Content State links for citation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote, unquote
|
|
9
|
+
|
|
10
|
+
from apud.iiif.image import Region
|
|
11
|
+
|
|
12
|
+
_FRAGMENT_SPEC = "http://www.w3.org/TR/media-frags/"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _target(canvas_id: str, manifest_id: str, region: Region) -> dict[str, Any]:
|
|
16
|
+
return {
|
|
17
|
+
"type": "SpecificResource",
|
|
18
|
+
"source": {
|
|
19
|
+
"id": canvas_id,
|
|
20
|
+
"type": "Canvas",
|
|
21
|
+
"partOf": [{"id": manifest_id, "type": "Manifest"}],
|
|
22
|
+
},
|
|
23
|
+
"selector": {
|
|
24
|
+
"type": "FragmentSelector",
|
|
25
|
+
"conformsTo": _FRAGMENT_SPEC,
|
|
26
|
+
"value": f"xywh={region.xywh}",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def transcription_annotation(
|
|
32
|
+
annotation_id: str,
|
|
33
|
+
canvas_id: str,
|
|
34
|
+
manifest_id: str,
|
|
35
|
+
region: Region,
|
|
36
|
+
text: str,
|
|
37
|
+
*,
|
|
38
|
+
language: str | None = None,
|
|
39
|
+
generator: str | None = None,
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""A transcription of ``region`` (canvas coordinates) as a Presentation 3 annotation."""
|
|
42
|
+
body: dict[str, Any] = {"type": "TextualBody", "value": text, "format": "text/plain"}
|
|
43
|
+
if language:
|
|
44
|
+
body["language"] = language
|
|
45
|
+
annotation: dict[str, Any] = {
|
|
46
|
+
"id": annotation_id,
|
|
47
|
+
"type": "Annotation",
|
|
48
|
+
"motivation": "supplementing",
|
|
49
|
+
"body": body,
|
|
50
|
+
"target": _target(canvas_id, manifest_id, region),
|
|
51
|
+
}
|
|
52
|
+
if generator:
|
|
53
|
+
annotation["generator"] = generator
|
|
54
|
+
return annotation
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def annotation_page(page_id: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"@context": "http://iiif.io/api/presentation/3/context.json",
|
|
60
|
+
"id": page_id,
|
|
61
|
+
"type": "AnnotationPage",
|
|
62
|
+
"items": items,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def encode_content_state(canvas_id: str, manifest_id: str, region: Region) -> str:
|
|
67
|
+
"""Encode a IIIF Content State pointing at a canvas region, for ``?iiif-content=`` links."""
|
|
68
|
+
state = {
|
|
69
|
+
"id": f"{canvas_id}#xywh={region.xywh}",
|
|
70
|
+
"type": "Canvas",
|
|
71
|
+
"partOf": [{"id": manifest_id, "type": "Manifest"}],
|
|
72
|
+
}
|
|
73
|
+
uri_encoded = quote(json.dumps(state, separators=(",", ":")), safe="-_.!~*'()")
|
|
74
|
+
return base64.urlsafe_b64encode(uri_encoded.encode()).decode().rstrip("=")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decode_content_state(encoded: str) -> dict[str, Any]:
|
|
78
|
+
padded = encoded + "=" * (-len(encoded) % 4)
|
|
79
|
+
return json.loads(unquote(base64.urlsafe_b64decode(padded).decode()))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Thin HTTP client for IIIF endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from apud import __version__
|
|
10
|
+
from apud.iiif.image import ImageService, Region, RegionRequest, parse_info
|
|
11
|
+
from apud.iiif.manifest import Manifest, parse_manifest
|
|
12
|
+
|
|
13
|
+
USER_AGENT = f"apud/{__version__} (+https://github.com/cadeos/apud)"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IIIFClient:
|
|
17
|
+
def __init__(self, timeout: float = 30.0, client: httpx.Client | None = None) -> None:
|
|
18
|
+
self._http = client or httpx.Client(
|
|
19
|
+
timeout=timeout, follow_redirects=True, headers={"User-Agent": USER_AGENT}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def __enter__(self) -> IIIFClient:
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def __exit__(self, *exc: object) -> None:
|
|
26
|
+
self.close()
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
self._http.close()
|
|
30
|
+
|
|
31
|
+
def _json(self, url: str) -> dict[str, Any]:
|
|
32
|
+
response = self._http.get(url)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json()
|
|
35
|
+
|
|
36
|
+
def manifest(self, url: str) -> Manifest:
|
|
37
|
+
return parse_manifest(self._json(url))
|
|
38
|
+
|
|
39
|
+
def image_service(self, service_id: str) -> ImageService:
|
|
40
|
+
url = (
|
|
41
|
+
service_id
|
|
42
|
+
if service_id.endswith("info.json")
|
|
43
|
+
else f"{service_id.rstrip('/')}/info.json"
|
|
44
|
+
)
|
|
45
|
+
return parse_info(self._json(url))
|
|
46
|
+
|
|
47
|
+
def region(
|
|
48
|
+
self, service: ImageService, region: Region, max_side: int
|
|
49
|
+
) -> tuple[RegionRequest, bytes]:
|
|
50
|
+
request = service.region_request(region, max_side)
|
|
51
|
+
response = self._http.get(request.url)
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
return request, response.content
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from apud.iiif.image import ImageService, Region, RegionRequest, UnsupportedRequest, parse_info
|
|
2
|
+
from apud.iiif.manifest import (
|
|
3
|
+
Canvas,
|
|
4
|
+
Manifest,
|
|
5
|
+
canvas_to_image,
|
|
6
|
+
image_to_canvas,
|
|
7
|
+
parse_manifest,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Canvas",
|
|
12
|
+
"ImageService",
|
|
13
|
+
"Manifest",
|
|
14
|
+
"Region",
|
|
15
|
+
"RegionRequest",
|
|
16
|
+
"UnsupportedRequest",
|
|
17
|
+
"canvas_to_image",
|
|
18
|
+
"image_to_canvas",
|
|
19
|
+
"parse_info",
|
|
20
|
+
"parse_manifest",
|
|
21
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""IIIF Image API: parse info.json (v2 and v3) and build region requests sized for a model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_LEVEL_RE = re.compile(r"level([012])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UnsupportedRequest(ValueError):
|
|
14
|
+
"""The image service cannot serve the request at its declared compliance level."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Region:
|
|
19
|
+
"""A rectangle in full-resolution image pixels."""
|
|
20
|
+
|
|
21
|
+
x: int
|
|
22
|
+
y: int
|
|
23
|
+
w: int
|
|
24
|
+
h: int
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.w <= 0 or self.h <= 0:
|
|
28
|
+
raise ValueError(f"region must have positive size, got {self.w}x{self.h}")
|
|
29
|
+
if self.x < 0 or self.y < 0:
|
|
30
|
+
raise ValueError(f"region origin must be non-negative, got {self.x},{self.y}")
|
|
31
|
+
|
|
32
|
+
def clamp(self, width: int, height: int) -> Region:
|
|
33
|
+
"""Crop the region to the image bounds, as an IIIF server would."""
|
|
34
|
+
if self.x >= width or self.y >= height:
|
|
35
|
+
raise ValueError("region lies entirely outside the image")
|
|
36
|
+
return Region(self.x, self.y, min(self.w, width - self.x), min(self.h, height - self.y))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def xywh(self) -> str:
|
|
40
|
+
return f"{self.x},{self.y},{self.w},{self.h}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class RegionRequest:
|
|
45
|
+
"""A ready-to-fetch region URL plus the scale needed to map model output back to the image."""
|
|
46
|
+
|
|
47
|
+
url: str
|
|
48
|
+
region: Region
|
|
49
|
+
width: int
|
|
50
|
+
height: int
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def scale(self) -> float:
|
|
54
|
+
"""Requested pixels per full-resolution pixel (<= 1, servers are never asked to upscale)."""
|
|
55
|
+
return self.width / self.region.w
|
|
56
|
+
|
|
57
|
+
def to_image(self, x: float, y: float) -> tuple[float, float]:
|
|
58
|
+
"""Map a point in the returned crop to full-resolution image coordinates."""
|
|
59
|
+
return self.region.x + x / self.scale, self.region.y + y / self.scale
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ImageService:
|
|
64
|
+
id: str
|
|
65
|
+
width: int
|
|
66
|
+
height: int
|
|
67
|
+
version: int
|
|
68
|
+
level: int
|
|
69
|
+
sizes: list[tuple[int, int]] = field(default_factory=list)
|
|
70
|
+
max_width: int | None = None
|
|
71
|
+
max_height: int | None = None
|
|
72
|
+
max_area: int | None = None
|
|
73
|
+
|
|
74
|
+
def region_request(self, region: Region, max_side: int, fmt: str = "jpg") -> RegionRequest:
|
|
75
|
+
"""Build a region URL whose longer side is at most ``max_side``, honouring server limits.
|
|
76
|
+
|
|
77
|
+
Uses pixel regions and explicit ``w,h`` sizes, the forms required from Level 1 upward.
|
|
78
|
+
"""
|
|
79
|
+
if self.level < 1:
|
|
80
|
+
raise UnsupportedRequest(
|
|
81
|
+
"Level 0 services do not support region requests; stitch tiles or use `sizes`"
|
|
82
|
+
)
|
|
83
|
+
region = region.clamp(self.width, self.height)
|
|
84
|
+
limit = max_side
|
|
85
|
+
if self.max_width is not None:
|
|
86
|
+
limit = min(limit, self.max_width, self.max_height or self.max_width)
|
|
87
|
+
if self.max_area is not None:
|
|
88
|
+
limit = min(limit, math.isqrt(self.max_area))
|
|
89
|
+
factor = min(1.0, limit / max(region.w, region.h))
|
|
90
|
+
w = max(1, math.floor(region.w * factor))
|
|
91
|
+
h = max(1, math.floor(region.h * factor))
|
|
92
|
+
url = f"{self.id.rstrip('/')}/{region.xywh}/{w},{h}/0/default.{fmt}"
|
|
93
|
+
return RegionRequest(url=url, region=region, width=w, height=h)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_info(info: dict[str, Any]) -> ImageService:
|
|
97
|
+
"""Parse an Image API v2 or v3 info.json document."""
|
|
98
|
+
context = info.get("@context", "")
|
|
99
|
+
contexts = context if isinstance(context, list) else [context]
|
|
100
|
+
version = 3 if any("image/3" in str(c) for c in contexts) else 2
|
|
101
|
+
service_id = info.get("id") or info.get("@id")
|
|
102
|
+
if not service_id:
|
|
103
|
+
raise ValueError("info.json has no id")
|
|
104
|
+
sizes = [(s["width"], s["height"]) for s in info.get("sizes", [])]
|
|
105
|
+
return ImageService(
|
|
106
|
+
id=service_id,
|
|
107
|
+
width=int(info["width"]),
|
|
108
|
+
height=int(info["height"]),
|
|
109
|
+
version=version,
|
|
110
|
+
level=_parse_level(info.get("profile")),
|
|
111
|
+
sizes=sizes,
|
|
112
|
+
max_width=info.get("maxWidth"),
|
|
113
|
+
max_height=info.get("maxHeight"),
|
|
114
|
+
max_area=info.get("maxArea"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_level(profile: Any) -> int:
|
|
119
|
+
# v3: "level2"; v2: ["http://iiif.io/api/image/2/level2.json", {...}]
|
|
120
|
+
candidates = profile if isinstance(profile, list) else [profile]
|
|
121
|
+
for candidate in candidates:
|
|
122
|
+
if isinstance(candidate, str) and (match := _LEVEL_RE.search(candidate)):
|
|
123
|
+
return int(match.group(1))
|
|
124
|
+
return 0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""IIIF Presentation API: lenient extraction of canvases from v2 and v3 manifests.
|
|
2
|
+
|
|
3
|
+
Real-world manifests are often inconsistent (mixed v2/v3 keys, canvas sizes that differ from
|
|
4
|
+
the image), so this module reads only what it needs and does not validate the whole document.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from apud.iiif.image import Region
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Canvas:
|
|
17
|
+
id: str
|
|
18
|
+
width: int
|
|
19
|
+
height: int
|
|
20
|
+
label: str | None
|
|
21
|
+
image_service: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Manifest:
|
|
26
|
+
id: str
|
|
27
|
+
label: str | None
|
|
28
|
+
canvases: list[Canvas]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_manifest(data: dict[str, Any]) -> Manifest:
|
|
32
|
+
manifest_id = data.get("id") or data.get("@id")
|
|
33
|
+
if not manifest_id:
|
|
34
|
+
raise ValueError("manifest has no id")
|
|
35
|
+
if "items" in data:
|
|
36
|
+
raw_canvases = data["items"]
|
|
37
|
+
to_canvas = _canvas_v3
|
|
38
|
+
else:
|
|
39
|
+
sequences = data.get("sequences") or [{}]
|
|
40
|
+
raw_canvases = sequences[0].get("canvases", [])
|
|
41
|
+
to_canvas = _canvas_v2
|
|
42
|
+
return Manifest(
|
|
43
|
+
id=manifest_id,
|
|
44
|
+
label=_label(data.get("label")),
|
|
45
|
+
canvases=[to_canvas(c) for c in raw_canvases],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def image_to_canvas(region: Region, image_width: int, image_height: int, canvas: Canvas) -> Region:
|
|
50
|
+
"""Rescale a region from image pixels to canvas coordinates (they can legitimately differ)."""
|
|
51
|
+
sx = canvas.width / image_width
|
|
52
|
+
sy = canvas.height / image_height
|
|
53
|
+
return Region(
|
|
54
|
+
round(region.x * sx), round(region.y * sy), round(region.w * sx), round(region.h * sy)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def canvas_to_image(region: Region, image_width: int, image_height: int, canvas: Canvas) -> Region:
|
|
59
|
+
"""Rescale a region from canvas coordinates to image pixels."""
|
|
60
|
+
sx = image_width / canvas.width
|
|
61
|
+
sy = image_height / canvas.height
|
|
62
|
+
return Region(
|
|
63
|
+
round(region.x * sx), round(region.y * sy), round(region.w * sx), round(region.h * sy)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _canvas_v3(c: dict[str, Any]) -> Canvas:
|
|
68
|
+
service = None
|
|
69
|
+
for page in c.get("items", []):
|
|
70
|
+
for anno in page.get("items", []):
|
|
71
|
+
service = _service_id(anno.get("body", {}))
|
|
72
|
+
if service:
|
|
73
|
+
break
|
|
74
|
+
if service:
|
|
75
|
+
break
|
|
76
|
+
return Canvas(c["id"], int(c["width"]), int(c["height"]), _label(c.get("label")), service)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _canvas_v2(c: dict[str, Any]) -> Canvas:
|
|
80
|
+
service = None
|
|
81
|
+
for image in c.get("images", []):
|
|
82
|
+
service = _service_id(image.get("resource", {}))
|
|
83
|
+
if service:
|
|
84
|
+
break
|
|
85
|
+
return Canvas(c["@id"], int(c["width"]), int(c["height"]), _label(c.get("label")), service)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _service_id(resource: dict[str, Any] | list[Any]) -> str | None:
|
|
89
|
+
if isinstance(resource, list):
|
|
90
|
+
resource = resource[0] if resource else {}
|
|
91
|
+
services = resource.get("service", [])
|
|
92
|
+
for service in services if isinstance(services, list) else [services]:
|
|
93
|
+
if isinstance(service, dict) and (sid := service.get("id") or service.get("@id")):
|
|
94
|
+
return sid
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _label(label: Any) -> str | None:
|
|
99
|
+
# v3 language map {"en": ["..."]}, v2 plain string or list of {"@value": ...}
|
|
100
|
+
if label is None:
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(label, str):
|
|
103
|
+
return label
|
|
104
|
+
if isinstance(label, dict):
|
|
105
|
+
for values in label.values():
|
|
106
|
+
if values:
|
|
107
|
+
return str(values[0])
|
|
108
|
+
return None
|
|
109
|
+
if isinstance(label, list) and label:
|
|
110
|
+
first = label[0]
|
|
111
|
+
return first.get("@value") if isinstance(first, dict) else str(first)
|
|
112
|
+
return None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
7
|
+
|
|
8
|
+
from samples import V2_INFO, V2_MANIFEST, V3_INFO, V3_MANIFEST
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def v3_info():
|
|
13
|
+
return dict(V3_INFO)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def v2_info():
|
|
18
|
+
return dict(V2_INFO)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def v3_manifest():
|
|
23
|
+
return V3_MANIFEST
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def v2_manifest():
|
|
28
|
+
return V2_MANIFEST
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Sample IIIF documents shared by the core and MCP test suites."""
|
|
2
|
+
|
|
3
|
+
V3_INFO = {
|
|
4
|
+
"@context": "http://iiif.io/api/image/3/context.json",
|
|
5
|
+
"id": "https://iiif.example.org/iiif/3/page1",
|
|
6
|
+
"type": "ImageService3",
|
|
7
|
+
"profile": "level2",
|
|
8
|
+
"width": 4000,
|
|
9
|
+
"height": 6000,
|
|
10
|
+
"maxWidth": 3000,
|
|
11
|
+
"sizes": [{"width": 500, "height": 750}],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
V2_INFO = {
|
|
15
|
+
"@context": "http://iiif.io/api/image/2/context.json",
|
|
16
|
+
"@id": "https://iiif.example.org/iiif/2/page1",
|
|
17
|
+
"profile": ["http://iiif.io/api/image/2/level1.json", {"formats": ["jpg"]}],
|
|
18
|
+
"width": 2000,
|
|
19
|
+
"height": 3000,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
V3_MANIFEST = {
|
|
23
|
+
"@context": "http://iiif.io/api/presentation/3/context.json",
|
|
24
|
+
"id": "https://iiif.example.org/manifest.json",
|
|
25
|
+
"type": "Manifest",
|
|
26
|
+
"label": {"en": ["Test codex"]},
|
|
27
|
+
"items": [
|
|
28
|
+
{
|
|
29
|
+
"id": "https://iiif.example.org/canvas/1",
|
|
30
|
+
"type": "Canvas",
|
|
31
|
+
"label": {"none": ["1r"]},
|
|
32
|
+
"width": 2000,
|
|
33
|
+
"height": 3000,
|
|
34
|
+
"items": [
|
|
35
|
+
{
|
|
36
|
+
"type": "AnnotationPage",
|
|
37
|
+
"items": [
|
|
38
|
+
{
|
|
39
|
+
"type": "Annotation",
|
|
40
|
+
"motivation": "painting",
|
|
41
|
+
"body": {
|
|
42
|
+
"id": "https://iiif.example.org/iiif/3/page1/full/max/0/default.jpg",
|
|
43
|
+
"type": "Image",
|
|
44
|
+
"service": [
|
|
45
|
+
{
|
|
46
|
+
"id": "https://iiif.example.org/iiif/3/page1",
|
|
47
|
+
"type": "ImageService3",
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
V2_MANIFEST = {
|
|
60
|
+
"@context": "http://iiif.io/api/presentation/2/context.json",
|
|
61
|
+
"@id": "https://iiif.example.org/v2/manifest.json",
|
|
62
|
+
"label": "Old codex",
|
|
63
|
+
"sequences": [
|
|
64
|
+
{
|
|
65
|
+
"canvases": [
|
|
66
|
+
{
|
|
67
|
+
"@id": "https://iiif.example.org/v2/canvas/1",
|
|
68
|
+
"label": "1r",
|
|
69
|
+
"width": 1000,
|
|
70
|
+
"height": 1500,
|
|
71
|
+
"images": [
|
|
72
|
+
{"resource": {"service": {"@id": "https://iiif.example.org/iiif/2/page1"}}}
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from apud.annotations import (
|
|
2
|
+
annotation_page,
|
|
3
|
+
decode_content_state,
|
|
4
|
+
encode_content_state,
|
|
5
|
+
transcription_annotation,
|
|
6
|
+
)
|
|
7
|
+
from apud.iiif import Region
|
|
8
|
+
|
|
9
|
+
CANVAS = "https://iiif.example.org/canvas/1"
|
|
10
|
+
MANIFEST = "https://iiif.example.org/manifest.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_transcription_annotation_targets_canvas_fragment():
|
|
14
|
+
anno = transcription_annotation(
|
|
15
|
+
"urn:a1", CANVAS, MANIFEST, Region(1, 2, 3, 4), "Nel mezzo", language="it"
|
|
16
|
+
)
|
|
17
|
+
assert anno["motivation"] == "supplementing"
|
|
18
|
+
assert anno["body"] == {
|
|
19
|
+
"type": "TextualBody",
|
|
20
|
+
"value": "Nel mezzo",
|
|
21
|
+
"format": "text/plain",
|
|
22
|
+
"language": "it",
|
|
23
|
+
}
|
|
24
|
+
assert anno["target"]["source"]["id"] == CANVAS
|
|
25
|
+
assert anno["target"]["source"]["partOf"] == [{"id": MANIFEST, "type": "Manifest"}]
|
|
26
|
+
assert anno["target"]["selector"]["value"] == "xywh=1,2,3,4"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_annotation_page_wraps_items():
|
|
30
|
+
page = annotation_page("urn:p", [{"id": "urn:a"}])
|
|
31
|
+
assert page["type"] == "AnnotationPage" and page["items"] == [{"id": "urn:a"}]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_content_state_round_trip():
|
|
35
|
+
encoded = encode_content_state(CANVAS, MANIFEST, Region(10, 20, 30, 40))
|
|
36
|
+
assert "=" not in encoded and "+" not in encoded and "/" not in encoded
|
|
37
|
+
state = decode_content_state(encoded)
|
|
38
|
+
assert state["id"] == f"{CANVAS}#xywh=10,20,30,40"
|
|
39
|
+
assert state["partOf"][0]["id"] == MANIFEST
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from apud.iiif import Region, UnsupportedRequest, parse_info
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_parse_v3(v3_info):
|
|
7
|
+
service = parse_info(v3_info)
|
|
8
|
+
assert (service.version, service.level) == (3, 2)
|
|
9
|
+
assert service.max_width == 3000
|
|
10
|
+
assert service.sizes == [(500, 750)]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_parse_v2_profile_list(v2_info):
|
|
14
|
+
service = parse_info(v2_info)
|
|
15
|
+
assert (service.version, service.level) == (2, 1)
|
|
16
|
+
assert service.id == "https://iiif.example.org/iiif/2/page1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_missing_profile_is_level0(v3_info):
|
|
20
|
+
del v3_info["profile"]
|
|
21
|
+
assert parse_info(v3_info).level == 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_region_request_downscales_to_max_side(v3_info):
|
|
25
|
+
request = parse_info(v3_info).region_request(Region(100, 200, 2000, 1000), max_side=1000)
|
|
26
|
+
assert (
|
|
27
|
+
request.url
|
|
28
|
+
== "https://iiif.example.org/iiif/3/page1/100,200,2000,1000/1000,500/0/default.jpg"
|
|
29
|
+
)
|
|
30
|
+
assert request.scale == 0.5
|
|
31
|
+
assert request.to_image(10, 20) == (120, 240)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_region_request_never_upscales(v3_info):
|
|
35
|
+
request = parse_info(v3_info).region_request(Region(0, 0, 300, 200), max_side=2000)
|
|
36
|
+
assert (request.width, request.height) == (300, 200)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_region_request_respects_server_limits(v3_info):
|
|
40
|
+
v3_info["maxArea"] = 1_000_000
|
|
41
|
+
request = parse_info(v3_info).region_request(Region(0, 0, 4000, 4000), max_side=5000)
|
|
42
|
+
assert max(request.width, request.height) == 1000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_region_is_clamped_to_image(v3_info):
|
|
46
|
+
request = parse_info(v3_info).region_request(Region(3900, 5900, 500, 500), max_side=2000)
|
|
47
|
+
assert request.region == Region(3900, 5900, 100, 100)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_level0_rejects_regions(v3_info):
|
|
51
|
+
v3_info["profile"] = "level0"
|
|
52
|
+
with pytest.raises(UnsupportedRequest):
|
|
53
|
+
parse_info(v3_info).region_request(Region(0, 0, 10, 10), max_side=100)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_invalid_region():
|
|
57
|
+
with pytest.raises(ValueError):
|
|
58
|
+
Region(0, 0, 0, 10)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from apud.iiif import Region, canvas_to_image, image_to_canvas, parse_manifest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_parse_v3_manifest(v3_manifest):
|
|
5
|
+
manifest = parse_manifest(v3_manifest)
|
|
6
|
+
assert manifest.label == "Test codex"
|
|
7
|
+
[canvas] = manifest.canvases
|
|
8
|
+
assert canvas.label == "1r"
|
|
9
|
+
assert canvas.image_service == "https://iiif.example.org/iiif/3/page1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_parse_v2_manifest(v2_manifest):
|
|
13
|
+
manifest = parse_manifest(v2_manifest)
|
|
14
|
+
[canvas] = manifest.canvases
|
|
15
|
+
assert (canvas.id, canvas.width, canvas.height) == (
|
|
16
|
+
"https://iiif.example.org/v2/canvas/1",
|
|
17
|
+
1000,
|
|
18
|
+
1500,
|
|
19
|
+
)
|
|
20
|
+
assert canvas.image_service == "https://iiif.example.org/iiif/2/page1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_canvas_image_round_trip(v3_manifest):
|
|
24
|
+
[canvas] = parse_manifest(v3_manifest).canvases # 2000x3000 canvas
|
|
25
|
+
image_region = canvas_to_image(Region(100, 150, 200, 300), 4000, 6000, canvas)
|
|
26
|
+
assert image_region == Region(200, 300, 400, 600)
|
|
27
|
+
assert image_to_canvas(image_region, 4000, 6000, canvas) == Region(100, 150, 200, 300)
|