From f0fccf2a110147beff3797c81b2877de65cacc45 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Tue, 23 Jun 2026 13:34:45 +0200 Subject: [PATCH 1/3] Respect PAGE-XML ReadingOrder --- pygexml/page.py | 46 ++++++++- pygexml/strategies.py | 8 +- test/test_page.py | 212 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 256 insertions(+), 10 deletions(-) diff --git a/pygexml/page.py b/pygexml/page.py index 3056c82..6b47601 100644 --- a/pygexml/page.py +++ b/pygexml/page.py @@ -1,7 +1,7 @@ from pathlib import Path from re import Pattern, compile from warnings import warn -from dataclasses import dataclass +from dataclasses import dataclass, field from dataclasses_json import DataClassJsonMixin from typing import ClassVar, TypeAlias from collections.abc import Iterable, Mapping @@ -232,9 +232,26 @@ def all_words(self) -> Iterable[str]: return (w for tl in self.textlines.values() for w in tl.words()) +def _parse_reading_order_group(element: Element) -> list[ID]: + children = list(element) + if QName(element).localname in ("OrderedGroup", "OrderedGroupIndexed"): + children.sort(key=lambda c: int(c.attrib.get("index", 0))) + result: list[ID] = [] + for child in children: + name = QName(child).localname + if name in ("RegionRef", "RegionRefIndexed") and "regionRef" in child.attrib: + result.append( + str(child.attrib["regionRef"]) + ) # silently skip malformed entries without regionRef + elif "Group" in name: + result.extend(_parse_reading_order_group(child)) + return result + + @dataclass class Page(PageLayout, DataClassJsonMixin): regions: Mapping[ID, TextRegion] # pyright: ignore[reportIncompatibleVariableOverride] # fmt: skip + reading_order: list[ID] | None = field(default=None) @classmethod def from_xml(cls, element: Element) -> "Page": @@ -246,6 +263,14 @@ def from_xml(cls, element: Element) -> "Page": regions = find_children(element, "TextRegion") + reading_order_element = find_child(element, "ReadingOrder") + reading_order: list[ID] | None = None + if reading_order_element is not None: + for child in reading_order_element: + if "Group" in QName(child).localname: + reading_order = _parse_reading_order_group(child) + break + return Page( image=Image( filename=str(element.attrib["imageFilename"]), @@ -263,6 +288,7 @@ def from_xml(cls, element: Element) -> "Page": regions={ tr.id: tr for tr in (TextRegion.from_xml(region) for region in regions) }, + reading_order=reading_order, ) @classmethod @@ -344,8 +370,22 @@ def from_alto_file(cls, file: Path | str, encoding: str = "utf-8") -> "Page": def lookup_region(self, id: ID) -> TextRegion | None: return self.regions.get(id) + def regions_ordered(self) -> list[TextRegion]: + if self.reading_order is None: + return list(self.regions.values()) + seen: set[ID] = set() + result: list[TextRegion] = [] + for rid in self.reading_order: + if (region := self.regions.get(rid)) is not None: + result.append(region) + seen.add(rid) + result.extend(r for rid, r in self.regions.items() if rid not in seen) + return result + def all_text(self) -> Iterable[str]: - return (line for region in self.regions.values() for line in region.all_text()) + return (line for region in self.regions_ordered() for line in region.all_text()) def all_words(self) -> Iterable[str]: - return (word for region in self.regions.values() for word in region.all_words()) + return ( + word for region in self.regions_ordered() for word in region.all_words() + ) diff --git a/pygexml/strategies.py b/pygexml/strategies.py index 851b8ee..4a1e166 100644 --- a/pygexml/strategies.py +++ b/pygexml/strategies.py @@ -80,13 +80,13 @@ def st_simple_text(**kwargs): def st_pages(draw): image = draw(st_images) regions = {tr.id: tr for tr in draw(st.lists(st_text_regions))} - page = Page(image=image, regions=regions) - return page + reading_order = draw(st.one_of(st.none(), st.permutations(list(regions.keys())))) + return Page(image=image, regions=regions, reading_order=reading_order) @st.composite def st_pages_with_dimensions(draw): image = draw(st_images_with_dimensions) regions = {tr.id: tr for tr in draw(st.lists(st_text_regions))} - page = Page(image=image, regions=regions) - return page + reading_order = draw(st.one_of(st.none(), st.permutations(list(regions.keys())))) + return Page(image=image, regions=regions, reading_order=reading_order) diff --git a/test/test_page.py b/test/test_page.py index 10d2905..142ee00 100644 --- a/test/test_page.py +++ b/test/test_page.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest -from hypothesis import given, assume +from hypothesis import given, assume, settings import hypothesis.strategies as st from lxml import etree @@ -550,6 +550,96 @@ def test_from_missing_xml_file(tmp_path: Path) -> None: Page.from_xml_file(missing_file) +READING_ORDER_XML_TEMPLATE = """ + + {ro} + + + + + foo + + + + + + + bar + + + + + + + baz + + + +""" + + +def parse_with_ro(ro_xml: str) -> Page: + return Page.from_xml(etree.fromstring(READING_ORDER_XML_TEMPLATE.format(ro=ro_xml))) + + +def test_page_without_reading_order() -> None: + pa = Page.from_xml(etree.fromstring(""" + + + + + + foo + + + + """)) + assert pa.reading_order is None + + +def test_page_ordered_group() -> None: + pa = parse_with_ro(""" + + + + + """) + assert pa.reading_order == ["tr-1", "tr-2"] + + +def test_page_ordered_group_sorts_by_index() -> None: + pa = parse_with_ro(""" + + + + + """) + assert pa.reading_order == ["tr-1", "tr-2"] + + +def test_page_unordered_group() -> None: + pa = parse_with_ro(""" + + + + + """) + assert pa.reading_order == ["tr-1", "tr-2"] + + +def test_page_nested_ordered_group() -> None: + pa = parse_with_ro(""" + + + + + + + + """) + assert pa.reading_order == ["tr-1", "tr-2", "tr-3"] + + def test_page_from_alto_example() -> None: pa = Page.from_alto(etree.fromstring(""" @@ -795,6 +885,28 @@ def test_page_alto_from_missing_file(tmp_path: Path) -> None: Page.from_alto_file(missing_file) +def test_page_alto_has_no_reading_order() -> None: + pa = Page.from_alto(etree.fromstring(""" + + + a.jpg + + + + + + + + + + + + + + """)) + assert pa.reading_order is None + + @given(st_text_regions, st_pages()) def test_page_region_lookup(region: TextRegion, page: Page) -> None: assume(region.id not in page.regions) @@ -811,6 +923,65 @@ def test_page_region_lookup_not_found(id: str, page: Page) -> None: assert page.lookup_region(id) is None +def test_regions_ordered_without_reading_order() -> None: + tr1 = TextRegion(id="tr-1", coords=Coords.parse("1,2 3,4"), textlines={}) + tr2 = TextRegion(id="tr-2", coords=Coords.parse("1,2 3,4"), textlines={}) + pa = Page( + image=Image(filename="a.jpg", width=None, height=None), + regions={"tr-1": tr1, "tr-2": tr2}, + ) + assert pa.regions_ordered() == [tr1, tr2] + + +def test_regions_ordered_with_reading_order() -> None: + tr1 = TextRegion(id="tr-1", coords=Coords.parse("1,2 3,4"), textlines={}) + tr2 = TextRegion(id="tr-2", coords=Coords.parse("1,2 3,4"), textlines={}) + pa = Page( + image=Image(filename="a.jpg", width=None, height=None), + regions={"tr-1": tr1, "tr-2": tr2}, + reading_order=["tr-2", "tr-1"], + ) + assert pa.regions_ordered() == [tr2, tr1] + + +def test_regions_ordered_skips_missing_ids() -> None: + tr1 = TextRegion(id="tr-1", coords=Coords.parse("1,2 3,4"), textlines={}) + pa = Page( + image=Image(filename="a.jpg", width=None, height=None), + regions={"tr-1": tr1}, + reading_order=["tr-1", "nonexistent"], + ) + assert pa.regions_ordered() == [tr1] + + +def test_regions_ordered_appends_unlisted_regions() -> None: + tr1 = TextRegion(id="tr-1", coords=Coords.parse("1,2 3,4"), textlines={}) + tr2 = TextRegion(id="tr-2", coords=Coords.parse("1,2 3,4"), textlines={}) + pa = Page( + image=Image(filename="a.jpg", width=None, height=None), + regions={"tr-1": tr1, "tr-2": tr2}, + reading_order=["tr-1"], + ) + assert pa.regions_ordered() == [tr1, tr2] + + +@given(st_pages()) +def test_regions_ordered_covers_all_regions(page: Page) -> None: + ordered = page.regions_ordered() + assert {r.id for r in ordered} == set(page.regions.keys()) + assert len(ordered) == len(page.regions) + + +@given(st_pages()) +@settings(max_examples=25) +def test_regions_ordered_preserves_reading_order(page: Page) -> None: + assume(page.reading_order is not None) + assert page.reading_order is not None # type narrowing for static analysis + ordered_ids = [r.id for r in page.regions_ordered()] + existing_in_order = [rid for rid in page.reading_order if rid in page.regions] + assert ordered_ids[: len(existing_in_order)] == existing_in_order + + def test_page_all_text_and_words() -> None: pa = Page( image=Image(filename="a", width=None, height=None), @@ -839,13 +1010,38 @@ def test_page_all_text_and_words() -> None: assert list(pa.all_words()) == ["foo", "bar", "bla", "blub", "42"] +def test_page_all_text_and_words_respect_reading_order() -> None: + pa = Page( + image=Image(filename="a", width=None, height=None), + regions={ + "a": TextRegion( + id="a", + coords=Coords.parse("1,2 3,4"), + textlines={ + "b": TextLine(id="b", coords=Coords.parse("2,3 4,5"), text="foo") + }, + ), + "b": TextRegion( + id="b", + coords=Coords.parse("5,6 7,8"), + textlines={ + "c": TextLine(id="c", coords=Coords.parse("6,7 8,9"), text="bar") + }, + ), + }, + reading_order=["b", "a"], + ) + assert list(pa.all_text()) == ["bar", "foo"] + assert list(pa.all_words()) == ["bar", "foo"] + + @given(st_pages()) def test_page_all_arbitrary_text_and_words(page: Page) -> None: assert list(page.all_text()) == [ - t for r in page.regions.values() for t in r.all_text() + t for r in page.regions_ordered() for t in r.all_text() ] assert list(page.all_words()) == [ - w for r in page.regions.values() for w in r.all_words() + w for r in page.regions_ordered() for w in r.all_words() ] @@ -865,3 +1061,13 @@ def test_page_serialization_roundtrip() -> None: }, ) assert Page.from_dict(pa.to_dict()) == pa + + +def test_page_reading_order_serialization_roundtrip() -> None: + pa = parse_with_ro(""" + + + + + """) + assert Page.from_dict(pa.to_dict()) == pa From 789884cf67a08a498a88ff4a39aa67ae0dbbbff9 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Tue, 23 Jun 2026 13:50:07 +0200 Subject: [PATCH 2/3] Add a missing UnorderedGroupIndexed test --- test/test_page.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/test_page.py b/test/test_page.py index 142ee00..278211d 100644 --- a/test/test_page.py +++ b/test/test_page.py @@ -640,6 +640,16 @@ def test_page_nested_ordered_group() -> None: assert pa.reading_order == ["tr-1", "tr-2", "tr-3"] +def test_page_unordered_group_indexed() -> None: + pa = parse_with_ro(""" + + + + + """) + assert pa.reading_order == ["tr-1", "tr-2"] + + def test_page_from_alto_example() -> None: pa = Page.from_alto(etree.fromstring(""" From b44c95fd4c8294667edad8b3c964db756a0a7d45 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Tue, 23 Jun 2026 13:50:30 +0200 Subject: [PATCH 3/3] Simplify alto reading order test --- test/test_page.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/test_page.py b/test/test_page.py index 278211d..4a2e586 100644 --- a/test/test_page.py +++ b/test/test_page.py @@ -901,17 +901,7 @@ def test_page_alto_has_no_reading_order() -> None: a.jpg - - - - - - - - - - - + """)) assert pa.reading_order is None