XmLLM / src /app /serializers /alto_xml.py
Claude
Code quality: fix all ruff warnings, add CI/CD, improve test coverage
bbbfba8 unverified
Raw
History Blame Contribute Delete
6.7 kB
"""ALTO XML serializer — deterministic conversion from CanonicalDocument to ALTO v4.
This serializer is a pure output transformation. It MUST NOT:
- Call any model or provider
- Reconstruct segmentation
- Correct text
- Invent coordinates
- Make export eligibility decisions
It receives a validated CanonicalDocument and produces ALTO XML bytes.
ALTO mapping:
Page → <Page>
TextRegion → <TextBlock>
TextLine → <TextLine>
Word → <String>
Coordinate mapping:
bbox[0] → HPOS
bbox[1] → VPOS
bbox[2] → WIDTH
bbox[3] → HEIGHT
text → CONTENT
confidence → WC
hyphenation.part=1 → SUBS_TYPE="HypPart1"
hyphenation.part=2 → SUBS_TYPE="HypPart2"
hyphenation.full_form → SUBS_CONTENT
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from lxml import etree
from src.app.geometry.quantization import RoundingStrategy, quantize_bbox
if TYPE_CHECKING:
from src.app.domain.models import CanonicalDocument, Page, TextLine, TextRegion, Word
ALTO_NS = "http://www.loc.gov/standards/alto/ns-v4#"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
SCHEMA_LOCATION = (
"http://www.loc.gov/standards/alto/ns-v4# "
"http://www.loc.gov/standards/alto/v4/alto-4-2.xsd"
)
NSMAP = {
None: ALTO_NS,
"xsi": XSI_NS,
}
def serialize_alto(
doc: CanonicalDocument,
*,
rounding: RoundingStrategy = RoundingStrategy.ROUND,
pretty_print: bool = True,
encoding: str = "UTF-8",
) -> bytes:
"""Serialize a CanonicalDocument to ALTO v4 XML bytes.
Args:
doc: The validated canonical document.
rounding: Strategy for converting float coordinates to integers.
pretty_print: Whether to indent the XML output.
encoding: Output encoding.
Returns:
The ALTO XML as bytes.
"""
root = _build_alto_tree(doc, rounding)
return etree.tostring(
root,
pretty_print=pretty_print,
xml_declaration=True,
encoding=encoding,
)
def serialize_alto_to_string(
doc: CanonicalDocument,
*,
rounding: RoundingStrategy = RoundingStrategy.ROUND,
) -> str:
"""Serialize to a UTF-8 string (convenience for tests)."""
return serialize_alto(doc, rounding=rounding).decode("utf-8")
# -- Tree construction --------------------------------------------------------
def _build_alto_tree(
doc: CanonicalDocument, rounding: RoundingStrategy
) -> etree._Element:
root = etree.Element(f"{{{ALTO_NS}}}alto", nsmap=NSMAP)
root.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
# <Description>
desc = etree.SubElement(root, f"{{{ALTO_NS}}}Description")
_add_description(desc, doc)
# <Layout>
layout = etree.SubElement(root, f"{{{ALTO_NS}}}Layout")
for page in doc.pages:
_add_page(layout, page, rounding)
return root
def _add_description(desc: etree._Element, doc: CanonicalDocument) -> None:
"""Add <Description> metadata."""
measurement = etree.SubElement(desc, f"{{{ALTO_NS}}}MeasurementUnit")
measurement.text = "pixel"
src_info = etree.SubElement(desc, f"{{{ALTO_NS}}}sourceImageInformation")
file_name = etree.SubElement(src_info, f"{{{ALTO_NS}}}fileName")
file_name.text = doc.source.filename or doc.document_id
# Processing info
processing = etree.SubElement(desc, f"{{{ALTO_NS}}}Processing")
processing.set("ID", "proc_1")
sw = etree.SubElement(processing, f"{{{ALTO_NS}}}processingSoftware")
sw_name = etree.SubElement(sw, f"{{{ALTO_NS}}}softwareName")
sw_name.text = "XmLLM"
sw_version = etree.SubElement(sw, f"{{{ALTO_NS}}}softwareVersion")
sw_version.text = doc.schema_version
def _add_page(
layout: etree._Element, page: Page, rounding: RoundingStrategy
) -> None:
"""Add a <Page> with its <PrintSpace> and blocks."""
page_el = etree.SubElement(layout, f"{{{ALTO_NS}}}Page")
page_el.set("ID", page.id)
page_el.set("PHYSICAL_IMG_NR", str(page.page_index + 1))
page_el.set("WIDTH", str(int(page.width)))
page_el.set("HEIGHT", str(int(page.height)))
# <PrintSpace> covers the entire page
ps = etree.SubElement(page_el, f"{{{ALTO_NS}}}PrintSpace")
ps.set("HPOS", "0")
ps.set("VPOS", "0")
ps.set("WIDTH", str(int(page.width)))
ps.set("HEIGHT", str(int(page.height)))
for region in page.text_regions:
_add_text_block(ps, region, rounding)
def _add_text_block(
parent: etree._Element, region: TextRegion, rounding: RoundingStrategy
) -> None:
"""Add a <TextBlock> with its lines."""
tb = etree.SubElement(parent, f"{{{ALTO_NS}}}TextBlock")
tb.set("ID", region.id)
_set_bbox_attrs(tb, region.geometry.bbox, rounding)
if region.lang:
tb.set("LANG", region.lang)
for line in region.lines:
_add_text_line(tb, line, rounding)
def _add_text_line(
parent: etree._Element, line: TextLine, rounding: RoundingStrategy
) -> None:
"""Add a <TextLine> with its strings."""
tl = etree.SubElement(parent, f"{{{ALTO_NS}}}TextLine")
tl.set("ID", line.id)
_set_bbox_attrs(tl, line.geometry.bbox, rounding)
for i, word in enumerate(line.words):
if i > 0:
_add_sp(tl)
_add_string(tl, word, rounding)
def _add_string(
parent: etree._Element, word: Word, rounding: RoundingStrategy
) -> None:
"""Add a <String> element for a word."""
s = etree.SubElement(parent, f"{{{ALTO_NS}}}String")
s.set("ID", word.id)
_set_bbox_attrs(s, word.geometry.bbox, rounding)
s.set("CONTENT", word.text)
if word.confidence is not None:
s.set("WC", f"{word.confidence:.2f}")
if word.lang:
s.set("LANG", word.lang)
# Hyphenation
if word.hyphenation and word.hyphenation.is_hyphenated:
if word.hyphenation.part == 1:
s.set("SUBS_TYPE", "HypPart1")
elif word.hyphenation.part == 2:
s.set("SUBS_TYPE", "HypPart2")
if word.hyphenation.full_form:
s.set("SUBS_CONTENT", word.hyphenation.full_form)
def _add_sp(parent: etree._Element) -> None:
"""Add a <SP> (space) element between words."""
etree.SubElement(parent, f"{{{ALTO_NS}}}SP")
# -- Helpers ------------------------------------------------------------------
def _set_bbox_attrs(
el: etree._Element,
bbox: tuple[float, float, float, float],
rounding: RoundingStrategy,
) -> None:
"""Set HPOS, VPOS, WIDTH, HEIGHT attributes from a canonical bbox."""
hpos, vpos, width, height = quantize_bbox(bbox, rounding)
el.set("HPOS", str(hpos))
el.set("VPOS", str(vpos))
el.set("WIDTH", str(width))
el.set("HEIGHT", str(height))