Spaces:
Build error
Build error
| """ | |
| Gรฉnรฉrateur ALTO v4 depuis un PageMaster validรฉ (R02). | |
| Source canonique : PageMaster uniquement โ jamais la rรฉponse brute ai_raw.json. | |
| bbox [x, y, width, height] โ HPOS / VPOS / WIDTH / HEIGHT (correspondance directe). | |
| Mapping RegionType โ รฉlรฉment ALTO : | |
| text_block / margin / rubric โ TextBlock | |
| miniature / decorated_initial โ Illustration | |
| other โ ComposedBlock | |
| """ | |
| # 1. stdlib | |
| import logging | |
| from pathlib import Path | |
| # 2. third-party | |
| from lxml import etree | |
| from pydantic import ValidationError | |
| # 3. local | |
| from app.schemas.page_master import OCRResult, PageMaster, Region, RegionType | |
| logger = logging.getLogger(__name__) | |
| # โโ Namespaces ALTO v4 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _ALTO_NS = "http://www.loc.gov/standards/alto/ns-v4#" | |
| _XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" | |
| _SCHEMA_LOC = ( | |
| "http://www.loc.gov/standards/alto/ns-v4# " | |
| "https://www.loc.gov/standards/alto/v4/alto-4-2.xsd" | |
| ) | |
| # โโ Classification des types de rรฉgions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _TEXT_REGION_TYPES = {RegionType.TEXT_BLOCK, RegionType.MARGIN, RegionType.RUBRIC} | |
| _ILLUSTRATION_TYPES = {RegionType.MINIATURE, RegionType.DECORATED_INITIAL} | |
| _COMPOSED_TYPES = {RegionType.OTHER} | |
| def _a(tag: str) -> str: | |
| """Retourne le tag qualifiรฉ dans le namespace ALTO.""" | |
| return f"{{{_ALTO_NS}}}{tag}" | |
| def _bbox_attrib(region: Region) -> dict[str, str]: | |
| """Retourne les attributs ALTO bbox depuis une Region (R03 โ direct mapping).""" | |
| x, y, w, h = region.bbox | |
| return {"HPOS": str(x), "VPOS": str(y), "WIDTH": str(w), "HEIGHT": str(h)} | |
| def _build_text_block( | |
| parent: etree._Element, | |
| region: Region, | |
| ocr_block: dict | None, | |
| fallback_text: str, | |
| language: str, | |
| confidence: float, | |
| ) -> None: | |
| """Ajoute un รฉlรฉment TextBlock au parent. | |
| Si un bloc OCR est disponible pour cette rรฉgion ou si fallback_text est fourni, | |
| ajoute un TextLine / String enfant. Sinon, le TextBlock reste vide (valide ALTO). | |
| """ | |
| attrib = {"ID": region.id, "LANG": language, **_bbox_attrib(region)} | |
| block_el = etree.SubElement(parent, _a("TextBlock"), attrib) | |
| # Rรฉsolution du texte pour ce bloc | |
| text = "" | |
| block_confidence = confidence | |
| if ocr_block: | |
| text = ( | |
| ocr_block.get("text") | |
| or ocr_block.get("diplomatic_text") | |
| or "" | |
| ) | |
| if not text and ocr_block.get("lines"): | |
| text = " ".join( | |
| ln.get("text", "") for ln in ocr_block["lines"] if ln.get("text") | |
| ) | |
| block_confidence = float(ocr_block.get("confidence", confidence)) | |
| elif fallback_text: | |
| text = fallback_text | |
| if not text: | |
| return # TextBlock sans TextLine โ valide ALTO, rรฉgion visible dans le layout | |
| x, y, w, h = region.bbox | |
| line_el = etree.SubElement( | |
| block_el, | |
| _a("TextLine"), | |
| {"ID": f"{region.id}_l1", "HPOS": str(x), "VPOS": str(y), "WIDTH": str(w), "HEIGHT": str(h)}, | |
| ) | |
| etree.SubElement( | |
| line_el, | |
| _a("String"), | |
| { | |
| "ID": f"{region.id}_l1_s1", | |
| "CONTENT": text, | |
| "HPOS": str(x), | |
| "VPOS": str(y), | |
| "WIDTH": str(w), | |
| "HEIGHT": str(h), | |
| "WC": f"{block_confidence:.4f}", | |
| }, | |
| ) | |
| def generate_alto(master: PageMaster) -> str: | |
| """Gรฉnรจre le XML ALTO v4 complet depuis un PageMaster. | |
| Toutes les rรฉgions du layout sont validรฉes avant la gรฉnรฉration. | |
| Un master.json avec une rรฉgion invalide lรจve une ValueError explicite โ | |
| jamais d'ALTO partiel silencieux. | |
| Args: | |
| master: PageMaster Pydantic validรฉ (source canonique, R02). | |
| Returns: | |
| Chaรฎne UTF-8 contenant le XML ALTO v4 (avec dรฉclaration XML). | |
| Raises: | |
| ValueError: si une rรฉgion du layout est invalide (bbox incorrecte, champ manquant). | |
| """ | |
| # โโ 1. Validation stricte de toutes les rรฉgions โโโโโโโโโโโโโโโโโโโโโโโโโ | |
| raw_regions: list[dict] = (master.layout.get("regions") or []) | |
| regions: list[Region] = [] | |
| for i, raw in enumerate(raw_regions): | |
| try: | |
| regions.append(Region.model_validate(raw)) | |
| except (ValidationError, Exception) as exc: | |
| raise ValueError( | |
| f"Rรฉgion [{i}] invalide dans le layout de la page ยซ{master.page_id}ยป : {exc}" | |
| ) from exc | |
| # โโ 2. Index OCR par region_id โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| ocr: OCRResult | None = master.ocr | |
| language = (ocr.language if ocr else "la") or "la" | |
| global_confidence = ocr.confidence if ocr else 0.0 | |
| global_text = (ocr.diplomatic_text if ocr else "") or "" | |
| ocr_by_region: dict[str, dict] = {} | |
| if ocr: | |
| for block in ocr.blocks: | |
| rid = block.get("region_id") | |
| if rid: | |
| ocr_by_region[rid] = block | |
| # Fallback : si aucun bloc OCR n'est rรฉfรฉrencรฉ par region_id, | |
| # le texte diplomatique global ira dans le premier TextBlock. | |
| has_per_region_ocr = bool(ocr_by_region) | |
| first_text_block_done = False | |
| # โโ 3. Construction de l'arbre XML โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| nsmap = {None: _ALTO_NS, "xsi": _XSI_NS} | |
| root = etree.Element(_a("alto"), nsmap=nsmap) | |
| root.set(f"{{{_XSI_NS}}}schemaLocation", _SCHEMA_LOC) | |
| # โโ Description โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| desc = etree.SubElement(root, _a("Description")) | |
| etree.SubElement(desc, _a("MeasurementUnit")).text = "pixel" | |
| src_info = etree.SubElement(desc, _a("sourceImageInformation")) | |
| file_name = master.image.iiif_service_url or master.image.master or master.image.derivative_web or master.page_id | |
| etree.SubElement(src_info, _a("fileName")).text = str(file_name) | |
| if master.processing: | |
| ocr_proc = etree.SubElement(desc, _a("OCRProcessing"), {"ID": "OCR_1"}) | |
| step = etree.SubElement(ocr_proc, _a("ocrProcessingStep")) | |
| etree.SubElement(step, _a("processingDateTime")).text = ( | |
| master.processing.processed_at.isoformat() | |
| ) | |
| software = etree.SubElement(step, _a("processingSoftware")) | |
| etree.SubElement(software, _a("softwareCreator")).text = "IIIF Studio" | |
| etree.SubElement(software, _a("softwareName")).text = ( | |
| master.processing.model_display_name | |
| ) | |
| etree.SubElement(software, _a("softwareVersion")).text = ( | |
| master.processing.prompt_version | |
| ) | |
| # โโ Layout โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| layout_el = etree.SubElement(root, _a("Layout")) | |
| width = master.image.width | |
| height = master.image.height | |
| page_id_safe = master.page_id.replace(" ", "_") | |
| page_el = etree.SubElement( | |
| layout_el, | |
| _a("Page"), | |
| { | |
| "ID": f"P_{page_id_safe}", | |
| "PHYSICAL_IMG_NR": str(master.sequence), | |
| "WIDTH": str(width), | |
| "HEIGHT": str(height), | |
| }, | |
| ) | |
| print_space = etree.SubElement( | |
| page_el, | |
| _a("PrintSpace"), | |
| {"HPOS": "0", "VPOS": "0", "WIDTH": str(width), "HEIGHT": str(height)}, | |
| ) | |
| # โโ Rรฉgions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| for region in regions: | |
| if region.type in _TEXT_REGION_TYPES: | |
| ocr_block = ocr_by_region.get(region.id) | |
| fallback = "" | |
| if not has_per_region_ocr and not first_text_block_done and global_text: | |
| fallback = global_text | |
| first_text_block_done = True | |
| _build_text_block( | |
| print_space, | |
| region, | |
| ocr_block=ocr_block, | |
| fallback_text=fallback, | |
| language=language, | |
| confidence=global_confidence, | |
| ) | |
| elif region.type in _ILLUSTRATION_TYPES: | |
| etree.SubElement( | |
| print_space, | |
| _a("Illustration"), | |
| { | |
| "ID": region.id, | |
| "TYPE": region.type.value, | |
| **_bbox_attrib(region), | |
| }, | |
| ) | |
| else: # _COMPOSED_TYPES (other) | |
| etree.SubElement( | |
| print_space, | |
| _a("ComposedBlock"), | |
| {"ID": region.id, **_bbox_attrib(region)}, | |
| ) | |
| logger.info( | |
| "ALTO gรฉnรฉrรฉ", | |
| extra={ | |
| "page_id": master.page_id, | |
| "regions": len(regions), | |
| }, | |
| ) | |
| # โโ 4. Sรฉrialisation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| return etree.tostring( | |
| root, | |
| xml_declaration=True, | |
| encoding="UTF-8", | |
| pretty_print=True, | |
| ).decode("utf-8") | |
| def write_alto(alto_xml: str, output_path: Path) -> None: | |
| """รcrit le XML ALTO dans le fichier de sortie. | |
| Crรฉe les dossiers parents si nรฉcessaire. | |
| Args: | |
| alto_xml: chaรฎne XML retournรฉe par generate_alto(). | |
| output_path: chemin de sortie (typiquement .../pages/{folio}/alto.xml). | |
| """ | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| output_path.write_text(alto_xml, encoding="utf-8") | |
| logger.info("alto.xml รฉcrit", extra={"path": str(output_path)}) | |