XmLLM / tests /unit /test_page_xml_serializer.py
Claude
Code quality: fix all ruff warnings, add CI/CD, improve test coverage
bbbfba8 unverified
Raw
History Blame Contribute Delete
13.6 kB
"""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 "<?xml version=" in xml_str
assert "Bonjour" in xml_str
assert "PcGts" in xml_str
class TestPageXmlPolygon:
def test_uses_polygon_when_available(self) -> 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"