File size: 7,531 Bytes
c7d5985
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
 
c7d5985
 
 
 
 
 
 
bbbfba8
 
 
 
 
 
 
 
 
c7d5985
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
c7d5985
bbbfba8
c7d5985
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
c7d5985
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
"""PAGE XML serializer β€” deterministic conversion from CanonicalDocument to PAGE XML.

This serializer follows the same rules as the ALTO serializer (see AGENTS.md Β§6):
  - No model calls, no segmentation reconstruction, no text correction
  - No coordinate invention, no export eligibility decisions
  - Pure deterministic mapping from validated CanonicalDocument

PAGE XML mapping:
  Page         β†’ <Page>
  TextRegion   β†’ <TextRegion>
  TextLine     β†’ <TextLine>
  Word         β†’ <Word>

Coordinate mapping:
  Uses <Coords points="x1,y1 x2,y2 x3,y3 x4,y4"/> β€” polygon if available,
  otherwise constructs a rectangle from the bbox.

Reading order:
  <ReadingOrder> / <OrderedGroup> / <RegionRefIndexed>

Text content:
  <TextEquiv><Unicode>text</Unicode></TextEquiv> at each level.

Namespace: http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import TYPE_CHECKING

from lxml import etree

from src.app.domain.models.status import BlockRole
from src.app.geometry.polygon import bbox_to_polygon
from src.app.geometry.quantization import RoundingStrategy, quantize_value

if TYPE_CHECKING:
    from src.app.domain.models import (
        CanonicalDocument,
        Page,
        TextLine,
        TextRegion,
        Word,
    )

PAGE_NS = "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
SCHEMA_LOCATION = (
    "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15 "
    "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15/pagecontent.xsd"
)

NSMAP = {
    None: PAGE_NS,
    "xsi": XSI_NS,
}

# Mapping from canonical BlockRole to PAGE region type
_ROLE_TO_TYPE: dict[BlockRole | None, str] = {
    BlockRole.BODY: "paragraph",
    BlockRole.HEADING: "heading",
    BlockRole.FOOTNOTE: "footnote",
    BlockRole.CAPTION: "caption",
    BlockRole.MARGIN: "marginalia",
    BlockRole.PAGE_NUMBER: "page-number",
    BlockRole.HEADER: "header",
    BlockRole.FOOTER: "footer",
    BlockRole.OTHER: "other",
    None: "paragraph",
}


def serialize_page_xml(
    doc: CanonicalDocument,
    *,
    rounding: RoundingStrategy = RoundingStrategy.ROUND,
    pretty_print: bool = True,
    encoding: str = "UTF-8",
) -> bytes:
    """Serialize a CanonicalDocument to PAGE XML bytes.

    Args:
        doc: The validated canonical document.
        rounding: Strategy for coordinate rounding.
        pretty_print: Whether to indent output.
        encoding: Output encoding.

    Returns:
        PAGE XML as bytes.
    """
    root = _build_page_tree(doc, rounding)
    return etree.tostring(
        root,
        pretty_print=pretty_print,
        xml_declaration=True,
        encoding=encoding,
    )


def serialize_page_xml_to_string(
    doc: CanonicalDocument,
    *,
    rounding: RoundingStrategy = RoundingStrategy.ROUND,
) -> str:
    """Serialize to a UTF-8 string (convenience for tests)."""
    return serialize_page_xml(doc, rounding=rounding).decode("utf-8")


# -- Tree construction --------------------------------------------------------


def _build_page_tree(
    doc: CanonicalDocument, rounding: RoundingStrategy
) -> etree._Element:
    root = etree.Element(f"{{{PAGE_NS}}}PcGts", nsmap=NSMAP)
    root.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
    root.set("pcGtsId", doc.document_id)

    # <Metadata>
    metadata = etree.SubElement(root, f"{{{PAGE_NS}}}Metadata")
    creator = etree.SubElement(metadata, f"{{{PAGE_NS}}}Creator")
    creator.text = "XmLLM"
    created = etree.SubElement(metadata, f"{{{PAGE_NS}}}Created")
    created.text = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
    last_change = etree.SubElement(metadata, f"{{{PAGE_NS}}}LastChange")
    last_change.text = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")

    # One <Page> per canonical page (PAGE XML is per-page, but we handle multi-page)
    for page in doc.pages:
        _add_page(root, page, rounding)

    return root


def _add_page(
    parent: etree._Element, page: Page, rounding: RoundingStrategy
) -> None:
    page_el = etree.SubElement(parent, f"{{{PAGE_NS}}}Page")
    page_el.set("imageFilename", page.metadata.get("image_ref", "") if page.metadata else "")
    page_el.set("imageWidth", str(int(page.width)))
    page_el.set("imageHeight", str(int(page.height)))

    # <ReadingOrder>
    if page.reading_order:
        _add_reading_order(page_el, page)

    # <TextRegion> elements
    for region in page.text_regions:
        _add_text_region(page_el, region, rounding)

    # Non-text regions are not in PAGE TextRegion β€” could add as ImageRegion etc.
    # For now we skip them (Sprint scope).


def _add_reading_order(page_el: etree._Element, page: Page) -> None:
    ro = etree.SubElement(page_el, f"{{{PAGE_NS}}}ReadingOrder")
    og = etree.SubElement(ro, f"{{{PAGE_NS}}}OrderedGroup")
    og.set("id", f"ro_{page.id}")
    for idx, region_id in enumerate(page.reading_order):
        ref = etree.SubElement(og, f"{{{PAGE_NS}}}RegionRefIndexed")
        ref.set("index", str(idx))
        ref.set("regionRef", region_id)


def _add_text_region(
    parent: etree._Element, region: TextRegion, rounding: RoundingStrategy
) -> None:
    tr = etree.SubElement(parent, f"{{{PAGE_NS}}}TextRegion")
    tr.set("id", region.id)
    tr.set("type", _ROLE_TO_TYPE.get(region.role, "paragraph"))

    # <Coords>
    _add_coords(tr, region.geometry.bbox, region.geometry.polygon, rounding)

    # <TextLine> elements
    for line in region.lines:
        _add_text_line(tr, line, rounding)

    # Region-level <TextEquiv>
    _add_text_equiv(tr, region.text)


def _add_text_line(
    parent: etree._Element, line: TextLine, rounding: RoundingStrategy
) -> None:
    tl = etree.SubElement(parent, f"{{{PAGE_NS}}}TextLine")
    tl.set("id", line.id)

    # <Coords>
    _add_coords(tl, line.geometry.bbox, line.geometry.polygon, rounding)

    # <Word> elements
    for word in line.words:
        _add_word(tl, word, rounding)

    # Line-level <TextEquiv>
    _add_text_equiv(tl, line.text)


def _add_word(
    parent: etree._Element, word: Word, rounding: RoundingStrategy
) -> None:
    w = etree.SubElement(parent, f"{{{PAGE_NS}}}Word")
    w.set("id", word.id)

    if word.confidence is not None:
        w.set("conf", f"{word.confidence:.2f}")

    # <Coords>
    _add_coords(w, word.geometry.bbox, word.geometry.polygon, rounding)

    # <TextEquiv>
    _add_text_equiv(w, word.text)


# -- Helpers ------------------------------------------------------------------


def _add_coords(
    parent: etree._Element,
    bbox: tuple[float, float, float, float],
    polygon: list[tuple[float, float]] | None,
    rounding: RoundingStrategy,
) -> None:
    """Add a <Coords> element with points attribute.

    Uses the polygon if available, otherwise constructs a rectangle from bbox.
    """
    coords = etree.SubElement(parent, f"{{{PAGE_NS}}}Coords")

    points = polygon if polygon and len(polygon) >= 3 else bbox_to_polygon(bbox)

    points_str = " ".join(
        f"{quantize_value(x, rounding)},{quantize_value(y, rounding)}"
        for x, y in points
    )
    coords.set("points", points_str)


def _add_text_equiv(parent: etree._Element, text: str) -> None:
    """Add <TextEquiv><Unicode>text</Unicode></TextEquiv>."""
    te = etree.SubElement(parent, f"{{{PAGE_NS}}}TextEquiv")
    unicode_el = etree.SubElement(te, f"{{{PAGE_NS}}}Unicode")
    unicode_el.text = text