"""Tests for the PAGE XML serializer.""" from __future__ import annotations from lxml import etree from src.app.domain.models import ( AltoReadiness, CanonicalDocument, EvidenceType, Geometry, GeometryStatus, Page, PageXmlReadiness, Provenance, ReadinessLevel, Source, TextLine, TextRegion, Word, ) from src.app.domain.models.status import BlockRole, InputType from src.app.serializers.page_xml import PAGE_NS, serialize_page_xml, serialize_page_xml_to_string def _prov(ref: str = "$.test") -> Provenance: return Provenance( provider="test", adapter="test.v1", source_ref=ref, evidence_type=EvidenceType.PROVIDER_NATIVE, ) def _geo(x: float = 100, y: float = 200, w: float = 300, h: float = 50) -> Geometry: return Geometry(bbox=(x, y, w, h), status=GeometryStatus.EXACT) def _geo_with_polygon() -> Geometry: return Geometry( bbox=(100, 200, 300, 50), polygon=[(98, 205), (402, 195), (404, 245), (100, 255)], status=GeometryStatus.EXACT, ) def _simple_doc() -> CanonicalDocument: """One-page doc with one region, one line, two words.""" return CanonicalDocument( document_id="doc_page_test", source=Source(input_type=InputType.IMAGE, filename="test.png"), pages=[ Page( id="p1", page_index=0, width=2480, height=3508, alto_readiness=AltoReadiness(level=ReadinessLevel.FULL), page_readiness=PageXmlReadiness(level=ReadinessLevel.FULL), reading_order=["tb1"], text_regions=[ TextRegion( id="tb1", role=BlockRole.BODY, geometry=_geo(100, 200, 1200, 900), lang="fra", provenance=_prov(), lines=[ TextLine( id="tl1", geometry=_geo(110, 220, 1100, 42), lang="fra", provenance=_prov(), words=[ Word(id="w1", text="Bonjour", geometry=_geo(110, 220, 90, 40), lang="fra", confidence=0.96, provenance=_prov()), Word(id="w2", text="monde", geometry=_geo(220, 220, 80, 40), lang="fra", confidence=0.94, provenance=_prov()), ], ), ], ), ], ), ], ) class TestPageXmlSerialization: def test_produces_valid_xml(self) -> None: doc = _simple_doc() xml_bytes = serialize_page_xml(doc) root = etree.fromstring(xml_bytes) assert root.tag == f"{{{PAGE_NS}}}PcGts" def test_has_metadata(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) meta = root.find(f"{{{PAGE_NS}}}Metadata") assert meta is not None creator = meta.find(f"{{{PAGE_NS}}}Creator") assert creator is not None assert creator.text == "XmLLM" def test_pcgts_id(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) assert root.get("pcGtsId") == "doc_page_test" def test_page_dimensions(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) page = root.find(f"{{{PAGE_NS}}}Page") assert page is not None assert page.get("imageWidth") == "2480" assert page.get("imageHeight") == "3508" def test_reading_order(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) ro = root.find(f".//{{{PAGE_NS}}}ReadingOrder") assert ro is not None og = ro.find(f"{{{PAGE_NS}}}OrderedGroup") assert og is not None refs = og.findall(f"{{{PAGE_NS}}}RegionRefIndexed") assert len(refs) == 1 assert refs[0].get("index") == "0" assert refs[0].get("regionRef") == "tb1" def test_text_region_exists(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) tr = root.find(f".//{{{PAGE_NS}}}TextRegion") assert tr is not None assert tr.get("id") == "tb1" assert tr.get("type") == "paragraph" def test_text_region_coords(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) tr = root.find(f".//{{{PAGE_NS}}}TextRegion") coords = tr.find(f"{{{PAGE_NS}}}Coords") assert coords is not None points = coords.get("points") assert points is not None # bbox (100,200,1200,900) → rectangle 4 points parts = points.split() assert len(parts) == 4 assert parts[0] == "100,200" # top-left def test_text_line_exists(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) tl = root.find(f".//{{{PAGE_NS}}}TextLine") assert tl is not None assert tl.get("id") == "tl1" def test_words_exist(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) words = root.findall(f".//{{{PAGE_NS}}}Word") assert len(words) == 2 def test_word_text_equiv(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) words = root.findall(f".//{{{PAGE_NS}}}Word") te = words[0].find(f"{{{PAGE_NS}}}TextEquiv") assert te is not None unicode_el = te.find(f"{{{PAGE_NS}}}Unicode") assert unicode_el is not None assert unicode_el.text == "Bonjour" def test_word_confidence(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) words = root.findall(f".//{{{PAGE_NS}}}Word") assert words[0].get("conf") == "0.96" def test_line_text_equiv(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) tl = root.find(f".//{{{PAGE_NS}}}TextLine") te = tl.find(f"{{{PAGE_NS}}}TextEquiv") unicode_el = te.find(f"{{{PAGE_NS}}}Unicode") assert unicode_el.text == "Bonjour monde" def test_region_text_equiv(self) -> None: doc = _simple_doc() root = etree.fromstring(serialize_page_xml(doc)) tr = root.find(f".//{{{PAGE_NS}}}TextRegion") # Region TextEquiv is after lines te_all = tr.findall(f"{{{PAGE_NS}}}TextEquiv") assert len(te_all) == 1 unicode_el = te_all[0].find(f"{{{PAGE_NS}}}Unicode") assert unicode_el.text == "Bonjour monde" def test_string_output(self) -> None: doc = _simple_doc() xml_str = serialize_page_xml_to_string(doc) assert " None: doc = CanonicalDocument( document_id="doc_poly", source=Source(input_type=InputType.IMAGE), pages=[Page( id="p1", page_index=0, width=2480, height=3508, text_regions=[TextRegion( id="tb1", geometry=_geo_with_polygon(), provenance=_prov(), lines=[TextLine( id="tl1", geometry=_geo_with_polygon(), provenance=_prov(), words=[Word( id="w1", text="test", geometry=_geo_with_polygon(), provenance=_prov(), )], )], )], )], ) root = etree.fromstring(serialize_page_xml(doc)) word = root.find(f".//{{{PAGE_NS}}}Word") coords = word.find(f"{{{PAGE_NS}}}Coords") points = coords.get("points") # Should use the polygon, not a rectangle from bbox parts = points.split() assert len(parts) == 4 assert parts[0] == "98,205" # first polygon point def test_falls_back_to_bbox_rectangle(self) -> None: doc = CanonicalDocument( document_id="doc_rect", source=Source(input_type=InputType.IMAGE), pages=[Page( id="p1", page_index=0, width=1000, height=1000, text_regions=[TextRegion( id="tb1", geometry=_geo(10, 20, 100, 50), provenance=_prov(), lines=[TextLine( id="tl1", geometry=_geo(10, 20, 100, 50), provenance=_prov(), words=[Word( id="w1", text="test", geometry=_geo(10, 20, 100, 50), provenance=_prov(), )], )], )], )], ) root = etree.fromstring(serialize_page_xml(doc)) word = root.find(f".//{{{PAGE_NS}}}Word") coords = word.find(f"{{{PAGE_NS}}}Coords") points = coords.get("points") parts = points.split() assert len(parts) == 4 # bbox (10,20,100,50) → rectangle: top-left, top-right, bottom-right, bottom-left assert parts[0] == "10,20" assert parts[1] == "110,20" assert parts[2] == "110,70" assert parts[3] == "10,70" class TestPageXmlRoles: def test_heading_role(self) -> None: doc = CanonicalDocument( document_id="doc_roles", source=Source(input_type=InputType.IMAGE), pages=[Page( id="p1", page_index=0, width=1000, height=1000, text_regions=[TextRegion( id="tb1", role=BlockRole.HEADING, geometry=_geo(), provenance=_prov(), lines=[TextLine( id="tl1", geometry=_geo(), provenance=_prov(), words=[Word(id="w1", text="Title", geometry=_geo(), provenance=_prov())], )], )], )], ) root = etree.fromstring(serialize_page_xml(doc)) tr = root.find(f".//{{{PAGE_NS}}}TextRegion") assert tr.get("type") == "heading" def test_footnote_role(self) -> None: doc = CanonicalDocument( document_id="doc_fn", source=Source(input_type=InputType.IMAGE), pages=[Page( id="p1", page_index=0, width=1000, height=1000, text_regions=[TextRegion( id="tb1", role=BlockRole.FOOTNOTE, geometry=_geo(), provenance=_prov(), lines=[TextLine( id="tl1", geometry=_geo(), provenance=_prov(), words=[Word(id="w1", text="Note", geometry=_geo(), provenance=_prov())], )], )], )], ) root = etree.fromstring(serialize_page_xml(doc)) tr = root.find(f".//{{{PAGE_NS}}}TextRegion") assert tr.get("type") == "footnote" class TestPageXmlMultipleRegions: def test_reading_order_multiple(self) -> None: doc = CanonicalDocument( document_id="doc_multi", source=Source(input_type=InputType.IMAGE), pages=[Page( id="p1", page_index=0, width=2000, height=3000, reading_order=["tb1", "tb2"], text_regions=[ TextRegion( id="tb1", role=BlockRole.HEADING, geometry=_geo(0, 0, 2000, 200), provenance=_prov(), lines=[TextLine( id="tl1", geometry=_geo(0, 0, 2000, 40), provenance=_prov(), words=[Word( id="w1", text="Title", geometry=_geo(0, 0, 200, 40), provenance=_prov(), )], )], ), TextRegion( id="tb2", role=BlockRole.BODY, geometry=_geo(0, 250, 2000, 2500), provenance=_prov(), lines=[TextLine( id="tl2", geometry=_geo(0, 250, 2000, 40), provenance=_prov(), words=[Word( id="w2", text="Body", geometry=_geo(0, 250, 200, 40), provenance=_prov(), )], )], ), ], )], ) root = etree.fromstring(serialize_page_xml(doc)) # Two text regions regions = root.findall(f".//{{{PAGE_NS}}}TextRegion") assert len(regions) == 2 # Reading order with 2 refs refs = root.findall(f".//{{{PAGE_NS}}}RegionRefIndexed") assert len(refs) == 2 assert refs[0].get("regionRef") == "tb1" assert refs[0].get("index") == "0" assert refs[1].get("regionRef") == "tb2" assert refs[1].get("index") == "1"