"""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 → TextRegion → TextLine → Word → 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) # desc = etree.SubElement(root, f"{{{ALTO_NS}}}Description") _add_description(desc, doc) # 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 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 with its 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))) # 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 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 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 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 (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))