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 ADDED
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .DS_Store
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,4 @@
1
+ # apud
2
+
3
+ Core library of [apud](https://github.com/cadeos/apud): IIIF Image/Presentation API parsing,
4
+ model-sized region requests, W3C Web Annotations and IIIF Content State citations.
@@ -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,3 @@
1
+ """apud: IIIF-grounded, citable vision for historical documents."""
2
+
3
+ __version__ = "0.0.1"
@@ -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)