Spaces:
Sleeping
Sprint 5: PAGE XML serializer and dual export
Browse filesComplete 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
|
@@ -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
|
|
@@ -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"
|
|
@@ -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"
|