From 4a7e613246a9af24070dc81dcd095dd8a8bd6b0b Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Wed, 16 Sep 2026 11:33:28 +0200 Subject: [PATCH 1/2] Add Label parsing for TextRegion and Page --- pygexml/page.py | 31 +++++++++++++++++++++++++++++++ pygexml/strategies.py | 20 +++++++++++++++++--- test/test_page.py | 24 ++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/pygexml/page.py b/pygexml/page.py index e4c1c51..60bc0c2 100644 --- a/pygexml/page.py +++ b/pygexml/page.py @@ -102,6 +102,13 @@ def __str__(self) -> str: ID: TypeAlias = str +@dataclass(frozen=True) +class Label(DataClassJsonMixin): + value: str + type: str | None = None + comments: str | None = None + + @dataclass class LayoutLine(DataClassJsonMixin): id: ID @@ -188,6 +195,7 @@ def words(self) -> Iterable[str]: @dataclass class TextRegion(LayoutRegion, DataClassJsonMixin): textlines: Mapping[ID, TextLine] # pyright: ignore[reportIncompatibleVariableOverride] # fmt: skip + labels: set[Label] = field(default_factory=set) @classmethod def from_xml(cls, element: Element) -> "TextRegion": @@ -208,6 +216,7 @@ def from_xml(cls, element: Element) -> "TextRegion": textlines={ tl.id: tl for tl in (TextLine.from_xml(tl) for tl in text_lines) }, + labels=_parse_labels(element), ) @classmethod @@ -258,10 +267,31 @@ def _parse_reading_order_group(element: Element) -> list[ID]: return result +def _parse_label(element: Element) -> Label | None: + value = element.attrib.get("value") + if value is None: # Can has Maybe monad and do notation in Python pls? + return None + return Label( + value=value, + type=element.attrib.get("type"), + comments=element.attrib.get("comments"), + ) + + +def _parse_labels(element: Element) -> set[Label]: + return { + parsed_label + for labels in find_children(element, "Labels") + for label in find_children(labels, "Label") + if (parsed_label := _parse_label(label)) is not None + } + + @dataclass class Page(PageLayout, DataClassJsonMixin): regions: Mapping[ID, TextRegion] # pyright: ignore[reportIncompatibleVariableOverride] # fmt: skip reading_order: list[ID] | None = field(default=None) + labels: set[Label] = field(default_factory=set) @classmethod def from_xml(cls, element: Element) -> "Page": @@ -299,6 +329,7 @@ def from_xml(cls, element: Element) -> "Page": tr.id: tr for tr in (TextRegion.from_xml(region) for region in regions) }, reading_order=reading_order, + labels=_parse_labels(element), ) @classmethod diff --git a/pygexml/strategies.py b/pygexml/strategies.py index 2fbf5ef..d049528 100644 --- a/pygexml/strategies.py +++ b/pygexml/strategies.py @@ -7,7 +7,7 @@ from pygexml.geometry import Point, Box, Polygon from pygexml.image import Image -from pygexml.page import Coords, Page, TextLine, TextRegion +from pygexml.page import Coords, Label, Page, TextLine, TextRegion st_points = st.builds(Point, x=st.integers(min_value=0), y=st.integers(min_value=0)) @@ -56,6 +56,13 @@ def st_simple_text(**kwargs): confidence=st.one_of(st.none(), st.floats(min_value=0, max_value=1)), ) +st_labels = st.builds( + Label, + value=st_simple_text(min_size=1), + type=st.one_of(st.none(), st_simple_text(min_size=1)), + comments=st.one_of(st.none(), st_simple_text(min_size=1)), +) + st_text_regions = st.builds( TextRegion, id=st_simple_text(), @@ -63,6 +70,7 @@ def st_simple_text(**kwargs): textlines=st.builds( lambda lines: {l.id: l for l in lines}, st.lists(st_text_lines) ), + labels=st.sets(st_labels, max_size=3), ) st_images = st.builds( @@ -85,7 +93,10 @@ def st_pages(draw): image = draw(st_images) regions = {tr.id: tr for tr in draw(st.lists(st_text_regions))} reading_order = draw(st.one_of(st.none(), st.permutations(list(regions.keys())))) - return Page(image=image, regions=regions, reading_order=reading_order) + labels = draw(st.sets(st_labels, max_size=3)) + return Page( + image=image, regions=regions, reading_order=reading_order, labels=labels + ) @st.composite @@ -93,4 +104,7 @@ 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))} reading_order = draw(st.one_of(st.none(), st.permutations(list(regions.keys())))) - return Page(image=image, regions=regions, reading_order=reading_order) + labels = draw(st.sets(st_labels, max_size=3)) + return Page( + image=image, regions=regions, reading_order=reading_order, labels=labels + ) diff --git a/test/test_page.py b/test/test_page.py index ccc5edf..4c06058 100644 --- a/test/test_page.py +++ b/test/test_page.py @@ -9,7 +9,7 @@ from pygexml.strategies import * from pygexml.geometry import Point, Box, Polygon from pygexml.image import Image -from pygexml.page import Coords, ID, TextLine, TextRegion, Page +from pygexml.page import Coords, ID, Label, TextLine, TextRegion, Page ############## Tests for Coords #################### @@ -258,6 +258,11 @@ def test_textregion_simple_parsing_example() -> None: tr = TextRegion.from_xml(etree.fromstring(""" + + + @@ -275,6 +280,11 @@ def test_textregion_simple_parsing_example() -> None: """)) assert tr.id == "tr-id" assert tr.coords == Coords.parse("1,2 8,9") + assert tr.labels == { + Label(value="person", type="entity", comments="a person"), + Label(value="named"), + Label(value="person", type="entity"), + } assert tr.textlines == { "tl-1": TextLine( id="tl-1", @@ -408,6 +418,7 @@ def test_textregion_serialization_roundtrip() -> None: textlines={ "tl-1": TextLine(id="tl-1", coords=Coords.parse("1,2 3,4"), text="foo") }, + labels={Label(value="person", type="entity")}, ) assert TextRegion.from_dict(tr.to_dict()) == tr @@ -418,6 +429,10 @@ def test_textregion_serialization_roundtrip() -> None: def test_page_from_element_example() -> None: pa = Page.from_xml(etree.fromstring(""" + + @@ -453,6 +468,10 @@ def test_page_from_element_example() -> None: """)) assert pa.image == Image(filename="7895328.jpg", width=4279, height=5315) + assert pa.labels == { + Label(value="document", type="genre"), + Label(value="annotated", comments="manual"), + } assert pa.regions == { "tr-1": TextRegion( id="tr-1", @@ -1108,8 +1127,9 @@ def test_page_serialization_roundtrip() -> None: id="tl-1", coords=Coords.parse("1,2 3,4"), text="foo" ) }, - ) + ), }, + labels={Label(value="document", type="genre")}, ) assert Page.from_dict(pa.to_dict()) == pa From 7bb62d47b0d167be1ce784be61af364294a57cd8 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Wed, 16 Sep 2026 11:34:46 +0200 Subject: [PATCH 2/2] Mention Label parsing in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2dab07a..593defc 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ All dataclasses are serializable with `to_dict`/`from_dict` and `to_json`/`from_ | Class | Import from | |---|---| | `Page` | `pygexml` | -| `Page`, `TextRegion`, `TextLine`, `Coords` | `pygexml.page` | +| `Page`, `TextRegion`, `TextLine`, `Coords`, `Label` | `pygexml.page` | | `Point`, `Box`, `Polygon` | `pygexml.geometry` | `Page`, `TextRegion` and `TextLine` each expose `all_text()` and `all_words()` iterators. On `Page`, these respect the PAGE-XML reading order if present. Lookups by ID are available via `lookup_region()` and `lookup_textline()`. The reading order is also accessible directly via `regions_ordered()`. `TextLine.confidence` is read from PAGE-XML `TextEquiv/@conf`. ALTO confidence is not mapped because its `String/@WC` values are defined per word, below the granularity of this model. +`Page.labels` and `TextRegion.labels` contain semantic PAGE-XML labels as `set[Label]`. Each `Label` has a required `value` and optional `type` and `comments`. Refer to the [online API docs][api-docs] for details.