Claude commited on
Commit
c7d5985
·
unverified ·
1 Parent(s): e2ec8a2

Sprint 5: PAGE XML serializer and dual export

Browse files

Complete the second native export format, achieving the architecture's
core goal of dual ALTO + PAGE XML output from a single CanonicalDocument.

PAGE XML serializer (lxml):
- Namespace: http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15
- Mapping: TextRegion→TextRegion, TextLine→TextLine, Word→Word
- <Coords points="x,y ..."> uses polygon when available, falls back to
rectangle from bbox — preserves PaddleOCR's original quadrilateral
- <ReadingOrder>/<OrderedGroup>/<RegionRefIndexed> from page.reading_order
- <TextEquiv><Unicode> at region, line, and word levels
- BlockRole→PAGE type mapping (heading, paragraph, footnote, etc.)
- Confidence as @conf attribute on Word elements
- <Metadata> with Creator, Created, LastChange

Integration test validates:
- Same canonical doc → both ALTO and PAGE produce correct output
- Same word count and text content in both formats
- ALTO uses HPOS/VPOS/WIDTH/HEIGHT, PAGE uses Coords/points
- PAGE preserves polygon geometry, ALTO uses axis-aligned bbox
- Export eligibility checks pass before serialization

22 new tests (PAGE serializer: 19, dual export integration: 3)
353 total tests passing.

https://claude.ai/code/session_01Cuzvc9Pjfo5u46eT3ta2Cg

src/app/serializers/page_xml.py CHANGED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PAGE XML serializer — deterministic conversion from CanonicalDocument to PAGE XML.
2
+
3
+ This serializer follows the same rules as the ALTO serializer (see AGENTS.md §6):
4
+ - No model calls, no segmentation reconstruction, no text correction
5
+ - No coordinate invention, no export eligibility decisions
6
+ - Pure deterministic mapping from validated CanonicalDocument
7
+
8
+ PAGE XML mapping:
9
+ Page → <Page>
10
+ TextRegion → <TextRegion>
11
+ TextLine → <TextLine>
12
+ Word → <Word>
13
+
14
+ Coordinate mapping:
15
+ Uses <Coords points="x1,y1 x2,y2 x3,y3 x4,y4"/> — polygon if available,
16
+ otherwise constructs a rectangle from the bbox.
17
+
18
+ Reading order:
19
+ <ReadingOrder> / <OrderedGroup> / <RegionRefIndexed>
20
+
21
+ Text content:
22
+ <TextEquiv><Unicode>text</Unicode></TextEquiv> at each level.
23
+
24
+ Namespace: http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from datetime import datetime, timezone
30
+
31
+ from lxml import etree
32
+
33
+ from src.app.domain.models import (
34
+ CanonicalDocument,
35
+ Page,
36
+ TextLine,
37
+ TextRegion,
38
+ Word,
39
+ )
40
+ from src.app.domain.models.status import BlockRole
41
+ from src.app.geometry.polygon import bbox_to_polygon
42
+ from src.app.geometry.quantization import RoundingStrategy, quantize_value
43
+
44
+ PAGE_NS = "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15"
45
+ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
46
+ SCHEMA_LOCATION = (
47
+ "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15 "
48
+ "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15/pagecontent.xsd"
49
+ )
50
+
51
+ NSMAP = {
52
+ None: PAGE_NS,
53
+ "xsi": XSI_NS,
54
+ }
55
+
56
+ # Mapping from canonical BlockRole to PAGE region type
57
+ _ROLE_TO_TYPE: dict[BlockRole | None, str] = {
58
+ BlockRole.BODY: "paragraph",
59
+ BlockRole.HEADING: "heading",
60
+ BlockRole.FOOTNOTE: "footnote",
61
+ BlockRole.CAPTION: "caption",
62
+ BlockRole.MARGIN: "marginalia",
63
+ BlockRole.PAGE_NUMBER: "page-number",
64
+ BlockRole.HEADER: "header",
65
+ BlockRole.FOOTER: "footer",
66
+ BlockRole.OTHER: "other",
67
+ None: "paragraph",
68
+ }
69
+
70
+
71
+ def serialize_page_xml(
72
+ doc: CanonicalDocument,
73
+ *,
74
+ rounding: RoundingStrategy = RoundingStrategy.ROUND,
75
+ pretty_print: bool = True,
76
+ encoding: str = "UTF-8",
77
+ ) -> bytes:
78
+ """Serialize a CanonicalDocument to PAGE XML bytes.
79
+
80
+ Args:
81
+ doc: The validated canonical document.
82
+ rounding: Strategy for coordinate rounding.
83
+ pretty_print: Whether to indent output.
84
+ encoding: Output encoding.
85
+
86
+ Returns:
87
+ PAGE XML as bytes.
88
+ """
89
+ root = _build_page_tree(doc, rounding)
90
+ return etree.tostring(
91
+ root,
92
+ pretty_print=pretty_print,
93
+ xml_declaration=True,
94
+ encoding=encoding,
95
+ )
96
+
97
+
98
+ def serialize_page_xml_to_string(
99
+ doc: CanonicalDocument,
100
+ *,
101
+ rounding: RoundingStrategy = RoundingStrategy.ROUND,
102
+ ) -> str:
103
+ """Serialize to a UTF-8 string (convenience for tests)."""
104
+ return serialize_page_xml(doc, rounding=rounding).decode("utf-8")
105
+
106
+
107
+ # -- Tree construction --------------------------------------------------------
108
+
109
+
110
+ def _build_page_tree(
111
+ doc: CanonicalDocument, rounding: RoundingStrategy
112
+ ) -> etree._Element:
113
+ root = etree.Element(f"{{{PAGE_NS}}}PcGts", nsmap=NSMAP)
114
+ root.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
115
+ root.set("pcGtsId", doc.document_id)
116
+
117
+ # <Metadata>
118
+ metadata = etree.SubElement(root, f"{{{PAGE_NS}}}Metadata")
119
+ creator = etree.SubElement(metadata, f"{{{PAGE_NS}}}Creator")
120
+ creator.text = "XmLLM"
121
+ created = etree.SubElement(metadata, f"{{{PAGE_NS}}}Created")
122
+ created.text = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
123
+ last_change = etree.SubElement(metadata, f"{{{PAGE_NS}}}LastChange")
124
+ last_change.text = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
125
+
126
+ # One <Page> per canonical page (PAGE XML is per-page, but we handle multi-page)
127
+ for page in doc.pages:
128
+ _add_page(root, page, rounding)
129
+
130
+ return root
131
+
132
+
133
+ def _add_page(
134
+ parent: etree._Element, page: Page, rounding: RoundingStrategy
135
+ ) -> None:
136
+ page_el = etree.SubElement(parent, f"{{{PAGE_NS}}}Page")
137
+ page_el.set("imageFilename", page.metadata.get("image_ref", "") if page.metadata else "")
138
+ page_el.set("imageWidth", str(int(page.width)))
139
+ page_el.set("imageHeight", str(int(page.height)))
140
+
141
+ # <ReadingOrder>
142
+ if page.reading_order:
143
+ _add_reading_order(page_el, page)
144
+
145
+ # <TextRegion> elements
146
+ for region in page.text_regions:
147
+ _add_text_region(page_el, region, rounding)
148
+
149
+ # Non-text regions are not in PAGE TextRegion — could add as ImageRegion etc.
150
+ # For now we skip them (Sprint scope).
151
+
152
+
153
+ def _add_reading_order(page_el: etree._Element, page: Page) -> None:
154
+ ro = etree.SubElement(page_el, f"{{{PAGE_NS}}}ReadingOrder")
155
+ og = etree.SubElement(ro, f"{{{PAGE_NS}}}OrderedGroup")
156
+ og.set("id", f"ro_{page.id}")
157
+ for idx, region_id in enumerate(page.reading_order):
158
+ ref = etree.SubElement(og, f"{{{PAGE_NS}}}RegionRefIndexed")
159
+ ref.set("index", str(idx))
160
+ ref.set("regionRef", region_id)
161
+
162
+
163
+ def _add_text_region(
164
+ parent: etree._Element, region: TextRegion, rounding: RoundingStrategy
165
+ ) -> None:
166
+ tr = etree.SubElement(parent, f"{{{PAGE_NS}}}TextRegion")
167
+ tr.set("id", region.id)
168
+ tr.set("type", _ROLE_TO_TYPE.get(region.role, "paragraph"))
169
+
170
+ # <Coords>
171
+ _add_coords(tr, region.geometry.bbox, region.geometry.polygon, rounding)
172
+
173
+ # <TextLine> elements
174
+ for line in region.lines:
175
+ _add_text_line(tr, line, rounding)
176
+
177
+ # Region-level <TextEquiv>
178
+ _add_text_equiv(tr, region.text)
179
+
180
+
181
+ def _add_text_line(
182
+ parent: etree._Element, line: TextLine, rounding: RoundingStrategy
183
+ ) -> None:
184
+ tl = etree.SubElement(parent, f"{{{PAGE_NS}}}TextLine")
185
+ tl.set("id", line.id)
186
+
187
+ # <Coords>
188
+ _add_coords(tl, line.geometry.bbox, line.geometry.polygon, rounding)
189
+
190
+ # <Word> elements
191
+ for word in line.words:
192
+ _add_word(tl, word, rounding)
193
+
194
+ # Line-level <TextEquiv>
195
+ _add_text_equiv(tl, line.text)
196
+
197
+
198
+ def _add_word(
199
+ parent: etree._Element, word: Word, rounding: RoundingStrategy
200
+ ) -> None:
201
+ w = etree.SubElement(parent, f"{{{PAGE_NS}}}Word")
202
+ w.set("id", word.id)
203
+
204
+ if word.confidence is not None:
205
+ w.set("conf", f"{word.confidence:.2f}")
206
+
207
+ # <Coords>
208
+ _add_coords(w, word.geometry.bbox, word.geometry.polygon, rounding)
209
+
210
+ # <TextEquiv>
211
+ _add_text_equiv(w, word.text)
212
+
213
+
214
+ # -- Helpers ------------------------------------------------------------------
215
+
216
+
217
+ def _add_coords(
218
+ parent: etree._Element,
219
+ bbox: tuple[float, float, float, float],
220
+ polygon: list[tuple[float, float]] | None,
221
+ rounding: RoundingStrategy,
222
+ ) -> None:
223
+ """Add a <Coords> element with points attribute.
224
+
225
+ Uses the polygon if available, otherwise constructs a rectangle from bbox.
226
+ """
227
+ coords = etree.SubElement(parent, f"{{{PAGE_NS}}}Coords")
228
+
229
+ if polygon and len(polygon) >= 3:
230
+ points = polygon
231
+ else:
232
+ points = bbox_to_polygon(bbox)
233
+
234
+ points_str = " ".join(
235
+ f"{quantize_value(x, rounding)},{quantize_value(y, rounding)}"
236
+ for x, y in points
237
+ )
238
+ coords.set("points", points_str)
239
+
240
+
241
+ def _add_text_equiv(parent: etree._Element, text: str) -> None:
242
+ """Add <TextEquiv><Unicode>text</Unicode></TextEquiv>."""
243
+ te = etree.SubElement(parent, f"{{{PAGE_NS}}}TextEquiv")
244
+ unicode_el = etree.SubElement(te, f"{{{PAGE_NS}}}Unicode")
245
+ unicode_el.text = text
tests/integration/test_dual_export.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration test: PaddleOCR → CanonicalDocument → ALTO + PAGE (dual export).
2
+
3
+ Validates that the same canonical document can be serialized to both
4
+ ALTO XML and PAGE XML, and that both outputs are structurally correct.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from lxml import etree
13
+
14
+ from src.app.domain.models import RawProviderPayload
15
+ from src.app.domain.models.geometry import GeometryContext
16
+ from src.app.normalization.pipeline import normalize
17
+ from src.app.policies.document_policy import DocumentPolicy
18
+ from src.app.policies.export_policy import check_alto_export, check_page_export
19
+ from src.app.serializers.alto_xml import ALTO_NS, serialize_alto
20
+ from src.app.serializers.page_xml import PAGE_NS, serialize_page_xml
21
+ from src.app.validators.export_eligibility_validator import compute_export_eligibility
22
+
23
+
24
+ class TestDualExport:
25
+ """Full pipeline: raw → canon → validate → ALTO + PAGE."""
26
+
27
+ def test_both_exports_from_same_document(self, fixtures_dir: Path) -> None:
28
+ # 1. Load and normalize
29
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
30
+ paddle_output = json.load(f)
31
+
32
+ raw = RawProviderPayload(
33
+ provider_id="paddleocr",
34
+ adapter_id="adapter.word_box_json.v1",
35
+ runtime_type="local",
36
+ payload=paddle_output,
37
+ image_width=2480, image_height=3508,
38
+ )
39
+ geo_ctx = GeometryContext(source_width=2480, source_height=3508)
40
+
41
+ doc = normalize(
42
+ raw, family="word_box_json", geometry_context=geo_ctx,
43
+ document_id="dual_export_test", source_filename="page.png",
44
+ )
45
+
46
+ # 2. Check export eligibility
47
+ policy = DocumentPolicy()
48
+ eligibility = compute_export_eligibility(doc, policy)
49
+
50
+ alto_decision = check_alto_export(eligibility, policy)
51
+ page_decision = check_page_export(eligibility, policy)
52
+
53
+ assert alto_decision.allowed
54
+ assert page_decision.allowed
55
+
56
+ # 3. Serialize both
57
+ alto_bytes = serialize_alto(doc)
58
+ page_bytes = serialize_page_xml(doc)
59
+
60
+ assert alto_bytes
61
+ assert page_bytes
62
+
63
+ # 4. Parse both
64
+ alto_root = etree.fromstring(alto_bytes)
65
+ page_root = etree.fromstring(page_bytes)
66
+
67
+ # 5. Validate ALTO structure
68
+ alto_strings = alto_root.findall(f".//{{{ALTO_NS}}}String")
69
+ assert len(alto_strings) == 5
70
+ assert alto_strings[0].get("CONTENT") == "Bonjour"
71
+
72
+ # 6. Validate PAGE structure
73
+ page_words = page_root.findall(f".//{{{PAGE_NS}}}Word")
74
+ assert len(page_words) == 5
75
+ w1_te = page_words[0].find(f".//{{{PAGE_NS}}}Unicode")
76
+ assert w1_te.text == "Bonjour"
77
+
78
+ # 7. Verify same word count in both
79
+ assert len(alto_strings) == len(page_words)
80
+
81
+ # 8. Verify same text content in both
82
+ alto_texts = [s.get("CONTENT") for s in alto_strings]
83
+ page_texts = [
84
+ w.find(f".//{{{PAGE_NS}}}Unicode").text for w in page_words
85
+ ]
86
+ assert alto_texts == page_texts
87
+
88
+ def test_page_has_coords_alto_has_hpos(self, fixtures_dir: Path) -> None:
89
+ """PAGE uses Coords/points, ALTO uses HPOS/VPOS/WIDTH/HEIGHT."""
90
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
91
+ paddle_output = json.load(f)
92
+
93
+ raw = RawProviderPayload(
94
+ provider_id="paddleocr", adapter_id="v1", runtime_type="local",
95
+ payload=paddle_output, image_width=2480, image_height=3508,
96
+ )
97
+ geo_ctx = GeometryContext(source_width=2480, source_height=3508)
98
+
99
+ doc = normalize(
100
+ raw, family="word_box_json", geometry_context=geo_ctx,
101
+ document_id="coord_test",
102
+ )
103
+
104
+ alto_root = etree.fromstring(serialize_alto(doc))
105
+ page_root = etree.fromstring(serialize_page_xml(doc))
106
+
107
+ # ALTO: String has HPOS, VPOS, WIDTH, HEIGHT
108
+ alto_s = alto_root.find(f".//{{{ALTO_NS}}}String")
109
+ assert alto_s.get("HPOS") is not None
110
+ assert alto_s.get("VPOS") is not None
111
+ assert alto_s.get("WIDTH") is not None
112
+ assert alto_s.get("HEIGHT") is not None
113
+
114
+ # PAGE: Word has Coords with points
115
+ page_w = page_root.find(f".//{{{PAGE_NS}}}Word")
116
+ coords = page_w.find(f"{{{PAGE_NS}}}Coords")
117
+ assert coords is not None
118
+ points = coords.get("points")
119
+ assert points is not None
120
+ # Points should be polygon (4 vertices from PaddleOCR)
121
+ parts = points.split()
122
+ assert len(parts) == 4
123
+
124
+ def test_page_has_reading_order(self, fixtures_dir: Path) -> None:
125
+ with open(fixtures_dir / "paddle_ocr_sample.json") as f:
126
+ paddle_output = json.load(f)
127
+
128
+ raw = RawProviderPayload(
129
+ provider_id="paddleocr", adapter_id="v1", runtime_type="local",
130
+ payload=paddle_output, image_width=2480, image_height=3508,
131
+ )
132
+ geo_ctx = GeometryContext(source_width=2480, source_height=3508)
133
+
134
+ doc = normalize(
135
+ raw, family="word_box_json", geometry_context=geo_ctx,
136
+ document_id="ro_test",
137
+ )
138
+
139
+ page_root = etree.fromstring(serialize_page_xml(doc))
140
+ ro = page_root.find(f".//{{{PAGE_NS}}}ReadingOrder")
141
+ assert ro is not None
142
+ refs = ro.findall(f".//{{{PAGE_NS}}}RegionRefIndexed")
143
+ assert len(refs) >= 1
144
+ assert refs[0].get("regionRef") == "tb1"
tests/unit/test_page_xml_serializer.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the PAGE XML serializer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from lxml import etree
6
+
7
+ from src.app.domain.models import (
8
+ AltoReadiness,
9
+ CanonicalDocument,
10
+ EvidenceType,
11
+ Geometry,
12
+ GeometryStatus,
13
+ Hyphenation,
14
+ Page,
15
+ PageXmlReadiness,
16
+ Provenance,
17
+ ReadinessLevel,
18
+ Source,
19
+ TextLine,
20
+ TextRegion,
21
+ Word,
22
+ )
23
+ from src.app.domain.models.status import BlockRole, InputType
24
+ from src.app.serializers.page_xml import PAGE_NS, serialize_page_xml, serialize_page_xml_to_string
25
+
26
+
27
+ def _prov(ref: str = "$.test") -> Provenance:
28
+ return Provenance(
29
+ provider="test", adapter="test.v1", source_ref=ref,
30
+ evidence_type=EvidenceType.PROVIDER_NATIVE,
31
+ )
32
+
33
+
34
+ def _geo(x: float = 100, y: float = 200, w: float = 300, h: float = 50) -> Geometry:
35
+ return Geometry(bbox=(x, y, w, h), status=GeometryStatus.EXACT)
36
+
37
+
38
+ def _geo_with_polygon() -> Geometry:
39
+ return Geometry(
40
+ bbox=(100, 200, 300, 50),
41
+ polygon=[(98, 205), (402, 195), (404, 245), (100, 255)],
42
+ status=GeometryStatus.EXACT,
43
+ )
44
+
45
+
46
+ def _simple_doc() -> CanonicalDocument:
47
+ """One-page doc with one region, one line, two words."""
48
+ return CanonicalDocument(
49
+ document_id="doc_page_test",
50
+ source=Source(input_type=InputType.IMAGE, filename="test.png"),
51
+ pages=[
52
+ Page(
53
+ id="p1", page_index=0, width=2480, height=3508,
54
+ alto_readiness=AltoReadiness(level=ReadinessLevel.FULL),
55
+ page_readiness=PageXmlReadiness(level=ReadinessLevel.FULL),
56
+ reading_order=["tb1"],
57
+ text_regions=[
58
+ TextRegion(
59
+ id="tb1", role=BlockRole.BODY,
60
+ geometry=_geo(100, 200, 1200, 900),
61
+ lang="fra", provenance=_prov(),
62
+ lines=[
63
+ TextLine(
64
+ id="tl1",
65
+ geometry=_geo(110, 220, 1100, 42),
66
+ lang="fra", provenance=_prov(),
67
+ words=[
68
+ Word(id="w1", text="Bonjour",
69
+ geometry=_geo(110, 220, 90, 40),
70
+ lang="fra", confidence=0.96,
71
+ provenance=_prov()),
72
+ Word(id="w2", text="monde",
73
+ geometry=_geo(220, 220, 80, 40),
74
+ lang="fra", confidence=0.94,
75
+ provenance=_prov()),
76
+ ],
77
+ ),
78
+ ],
79
+ ),
80
+ ],
81
+ ),
82
+ ],
83
+ )
84
+
85
+
86
+ class TestPageXmlSerialization:
87
+ def test_produces_valid_xml(self) -> None:
88
+ doc = _simple_doc()
89
+ xml_bytes = serialize_page_xml(doc)
90
+ root = etree.fromstring(xml_bytes)
91
+ assert root.tag == f"{{{PAGE_NS}}}PcGts"
92
+
93
+ def test_has_metadata(self) -> None:
94
+ doc = _simple_doc()
95
+ root = etree.fromstring(serialize_page_xml(doc))
96
+ meta = root.find(f"{{{PAGE_NS}}}Metadata")
97
+ assert meta is not None
98
+ creator = meta.find(f"{{{PAGE_NS}}}Creator")
99
+ assert creator is not None
100
+ assert creator.text == "XmLLM"
101
+
102
+ def test_pcgts_id(self) -> None:
103
+ doc = _simple_doc()
104
+ root = etree.fromstring(serialize_page_xml(doc))
105
+ assert root.get("pcGtsId") == "doc_page_test"
106
+
107
+ def test_page_dimensions(self) -> None:
108
+ doc = _simple_doc()
109
+ root = etree.fromstring(serialize_page_xml(doc))
110
+ page = root.find(f"{{{PAGE_NS}}}Page")
111
+ assert page is not None
112
+ assert page.get("imageWidth") == "2480"
113
+ assert page.get("imageHeight") == "3508"
114
+
115
+ def test_reading_order(self) -> None:
116
+ doc = _simple_doc()
117
+ root = etree.fromstring(serialize_page_xml(doc))
118
+ ro = root.find(f".//{{{PAGE_NS}}}ReadingOrder")
119
+ assert ro is not None
120
+ og = ro.find(f"{{{PAGE_NS}}}OrderedGroup")
121
+ assert og is not None
122
+ refs = og.findall(f"{{{PAGE_NS}}}RegionRefIndexed")
123
+ assert len(refs) == 1
124
+ assert refs[0].get("index") == "0"
125
+ assert refs[0].get("regionRef") == "tb1"
126
+
127
+ def test_text_region_exists(self) -> None:
128
+ doc = _simple_doc()
129
+ root = etree.fromstring(serialize_page_xml(doc))
130
+ tr = root.find(f".//{{{PAGE_NS}}}TextRegion")
131
+ assert tr is not None
132
+ assert tr.get("id") == "tb1"
133
+ assert tr.get("type") == "paragraph"
134
+
135
+ def test_text_region_coords(self) -> None:
136
+ doc = _simple_doc()
137
+ root = etree.fromstring(serialize_page_xml(doc))
138
+ tr = root.find(f".//{{{PAGE_NS}}}TextRegion")
139
+ coords = tr.find(f"{{{PAGE_NS}}}Coords")
140
+ assert coords is not None
141
+ points = coords.get("points")
142
+ assert points is not None
143
+ # bbox (100,200,1200,900) → rectangle 4 points
144
+ parts = points.split()
145
+ assert len(parts) == 4
146
+ assert parts[0] == "100,200" # top-left
147
+
148
+ def test_text_line_exists(self) -> None:
149
+ doc = _simple_doc()
150
+ root = etree.fromstring(serialize_page_xml(doc))
151
+ tl = root.find(f".//{{{PAGE_NS}}}TextLine")
152
+ assert tl is not None
153
+ assert tl.get("id") == "tl1"
154
+
155
+ def test_words_exist(self) -> None:
156
+ doc = _simple_doc()
157
+ root = etree.fromstring(serialize_page_xml(doc))
158
+ words = root.findall(f".//{{{PAGE_NS}}}Word")
159
+ assert len(words) == 2
160
+
161
+ def test_word_text_equiv(self) -> None:
162
+ doc = _simple_doc()
163
+ root = etree.fromstring(serialize_page_xml(doc))
164
+ words = root.findall(f".//{{{PAGE_NS}}}Word")
165
+ te = words[0].find(f"{{{PAGE_NS}}}TextEquiv")
166
+ assert te is not None
167
+ unicode_el = te.find(f"{{{PAGE_NS}}}Unicode")
168
+ assert unicode_el is not None
169
+ assert unicode_el.text == "Bonjour"
170
+
171
+ def test_word_confidence(self) -> None:
172
+ doc = _simple_doc()
173
+ root = etree.fromstring(serialize_page_xml(doc))
174
+ words = root.findall(f".//{{{PAGE_NS}}}Word")
175
+ assert words[0].get("conf") == "0.96"
176
+
177
+ def test_line_text_equiv(self) -> None:
178
+ doc = _simple_doc()
179
+ root = etree.fromstring(serialize_page_xml(doc))
180
+ tl = root.find(f".//{{{PAGE_NS}}}TextLine")
181
+ te = tl.find(f"{{{PAGE_NS}}}TextEquiv")
182
+ unicode_el = te.find(f"{{{PAGE_NS}}}Unicode")
183
+ assert unicode_el.text == "Bonjour monde"
184
+
185
+ def test_region_text_equiv(self) -> None:
186
+ doc = _simple_doc()
187
+ root = etree.fromstring(serialize_page_xml(doc))
188
+ tr = root.find(f".//{{{PAGE_NS}}}TextRegion")
189
+ # Region TextEquiv is after lines
190
+ te_all = tr.findall(f"{{{PAGE_NS}}}TextEquiv")
191
+ assert len(te_all) == 1
192
+ unicode_el = te_all[0].find(f"{{{PAGE_NS}}}Unicode")
193
+ assert unicode_el.text == "Bonjour monde"
194
+
195
+ def test_string_output(self) -> None:
196
+ doc = _simple_doc()
197
+ xml_str = serialize_page_xml_to_string(doc)
198
+ assert "<?xml version=" in xml_str
199
+ assert "Bonjour" in xml_str
200
+ assert "PcGts" in xml_str
201
+
202
+
203
+ class TestPageXmlPolygon:
204
+ def test_uses_polygon_when_available(self) -> None:
205
+ doc = CanonicalDocument(
206
+ document_id="doc_poly",
207
+ source=Source(input_type=InputType.IMAGE),
208
+ pages=[Page(
209
+ id="p1", page_index=0, width=2480, height=3508,
210
+ text_regions=[TextRegion(
211
+ id="tb1", geometry=_geo_with_polygon(), provenance=_prov(),
212
+ lines=[TextLine(
213
+ id="tl1", geometry=_geo_with_polygon(), provenance=_prov(),
214
+ words=[Word(
215
+ id="w1", text="test",
216
+ geometry=_geo_with_polygon(), provenance=_prov(),
217
+ )],
218
+ )],
219
+ )],
220
+ )],
221
+ )
222
+ root = etree.fromstring(serialize_page_xml(doc))
223
+ word = root.find(f".//{{{PAGE_NS}}}Word")
224
+ coords = word.find(f"{{{PAGE_NS}}}Coords")
225
+ points = coords.get("points")
226
+ # Should use the polygon, not a rectangle from bbox
227
+ parts = points.split()
228
+ assert len(parts) == 4
229
+ assert parts[0] == "98,205" # first polygon point
230
+
231
+ def test_falls_back_to_bbox_rectangle(self) -> None:
232
+ doc = CanonicalDocument(
233
+ document_id="doc_rect",
234
+ source=Source(input_type=InputType.IMAGE),
235
+ pages=[Page(
236
+ id="p1", page_index=0, width=1000, height=1000,
237
+ text_regions=[TextRegion(
238
+ id="tb1", geometry=_geo(10, 20, 100, 50), provenance=_prov(),
239
+ lines=[TextLine(
240
+ id="tl1", geometry=_geo(10, 20, 100, 50), provenance=_prov(),
241
+ words=[Word(
242
+ id="w1", text="test",
243
+ geometry=_geo(10, 20, 100, 50), provenance=_prov(),
244
+ )],
245
+ )],
246
+ )],
247
+ )],
248
+ )
249
+ root = etree.fromstring(serialize_page_xml(doc))
250
+ word = root.find(f".//{{{PAGE_NS}}}Word")
251
+ coords = word.find(f"{{{PAGE_NS}}}Coords")
252
+ points = coords.get("points")
253
+ parts = points.split()
254
+ assert len(parts) == 4
255
+ # bbox (10,20,100,50) → rectangle: top-left, top-right, bottom-right, bottom-left
256
+ assert parts[0] == "10,20"
257
+ assert parts[1] == "110,20"
258
+ assert parts[2] == "110,70"
259
+ assert parts[3] == "10,70"
260
+
261
+
262
+ class TestPageXmlRoles:
263
+ def test_heading_role(self) -> None:
264
+ doc = CanonicalDocument(
265
+ document_id="doc_roles",
266
+ source=Source(input_type=InputType.IMAGE),
267
+ pages=[Page(
268
+ id="p1", page_index=0, width=1000, height=1000,
269
+ text_regions=[TextRegion(
270
+ id="tb1", role=BlockRole.HEADING,
271
+ geometry=_geo(), provenance=_prov(),
272
+ lines=[TextLine(
273
+ id="tl1", geometry=_geo(), provenance=_prov(),
274
+ words=[Word(id="w1", text="Title", geometry=_geo(), provenance=_prov())],
275
+ )],
276
+ )],
277
+ )],
278
+ )
279
+ root = etree.fromstring(serialize_page_xml(doc))
280
+ tr = root.find(f".//{{{PAGE_NS}}}TextRegion")
281
+ assert tr.get("type") == "heading"
282
+
283
+ def test_footnote_role(self) -> None:
284
+ doc = CanonicalDocument(
285
+ document_id="doc_fn",
286
+ source=Source(input_type=InputType.IMAGE),
287
+ pages=[Page(
288
+ id="p1", page_index=0, width=1000, height=1000,
289
+ text_regions=[TextRegion(
290
+ id="tb1", role=BlockRole.FOOTNOTE,
291
+ geometry=_geo(), provenance=_prov(),
292
+ lines=[TextLine(
293
+ id="tl1", geometry=_geo(), provenance=_prov(),
294
+ words=[Word(id="w1", text="Note", geometry=_geo(), provenance=_prov())],
295
+ )],
296
+ )],
297
+ )],
298
+ )
299
+ root = etree.fromstring(serialize_page_xml(doc))
300
+ tr = root.find(f".//{{{PAGE_NS}}}TextRegion")
301
+ assert tr.get("type") == "footnote"
302
+
303
+
304
+ class TestPageXmlMultipleRegions:
305
+ def test_reading_order_multiple(self) -> None:
306
+ doc = CanonicalDocument(
307
+ document_id="doc_multi",
308
+ source=Source(input_type=InputType.IMAGE),
309
+ pages=[Page(
310
+ id="p1", page_index=0, width=2000, height=3000,
311
+ reading_order=["tb1", "tb2"],
312
+ text_regions=[
313
+ TextRegion(
314
+ id="tb1", role=BlockRole.HEADING,
315
+ geometry=_geo(0, 0, 2000, 200), provenance=_prov(),
316
+ lines=[TextLine(id="tl1", geometry=_geo(0, 0, 2000, 40), provenance=_prov(),
317
+ words=[Word(id="w1", text="Title", geometry=_geo(0, 0, 200, 40), provenance=_prov())])],
318
+ ),
319
+ TextRegion(
320
+ id="tb2", role=BlockRole.BODY,
321
+ geometry=_geo(0, 250, 2000, 2500), provenance=_prov(),
322
+ lines=[TextLine(id="tl2", geometry=_geo(0, 250, 2000, 40), provenance=_prov(),
323
+ words=[Word(id="w2", text="Body", geometry=_geo(0, 250, 200, 40), provenance=_prov())])],
324
+ ),
325
+ ],
326
+ )],
327
+ )
328
+ root = etree.fromstring(serialize_page_xml(doc))
329
+
330
+ # Two text regions
331
+ regions = root.findall(f".//{{{PAGE_NS}}}TextRegion")
332
+ assert len(regions) == 2
333
+
334
+ # Reading order with 2 refs
335
+ refs = root.findall(f".//{{{PAGE_NS}}}RegionRefIndexed")
336
+ assert len(refs) == 2
337
+ assert refs[0].get("regionRef") == "tb1"
338
+ assert refs[0].get("index") == "0"
339
+ assert refs[1].get("regionRef") == "tb2"
340
+ assert refs[1].get("index") == "1"