"""Reconstructeur ALTO de référence — texte plat → ALTO XML mono-région. Chantier 1 du plan d'évolution post-Sprint 97. Pourquoi ce module ------------------ Tout l'échafaudage de l'axe B (Sprints 32-34, 53-54, 63-68, 94-97) suppose qu'un utilisateur peut brancher un ``BaseModule(input=TEXT, output=ALTO)`` dans une pipeline. Aucun module réel de ce type n'existait jusqu'à ce chantier — tous les tests utilisaient des ``MockModule``. Ce reconstructeur est volontairement **primitif** : - Une seule ``TextBlock`` couvre toute l'image source. - Une ``TextLine`` est émise par ligne du texte d'entrée (split sur ``\\n``). - Une ``String`` est émise par mot (split whitespace). - Les ``HPOS``/``VPOS``/``WIDTH``/``HEIGHT`` sont distribués uniformément sur les dimensions de l'image source (lecture via Pillow si disponible, sinon valeurs par défaut documentées). Cette baseline n'a pas vocation à être un bon reconstructeur — elle a vocation à être un **point de comparaison stable**. Un VLM produisant un ALTO doit faire mieux qu'elle ; c'est mesurable via Layout F1 (:mod:`picarones.core.layout`) et via les métriques ``alto_text_cer``/``alto_text_wer`` (:mod:`picarones.core.alto_metrics`). Conformité ALTO 4.2 ------------------- Le XML produit valide contre le schéma ALTO 4.2 (LOC) sur ses éléments obligatoires. Le namespace ``http://www.loc.gov/standards/alto/ns-v4#`` est déclaré explicitement. La sortie est déterministe : deux appels avec les mêmes entrées produisent le même XML octet par octet. Exemple ------- >>> from picarones.core.modules import ArtifactType >>> from picarones.modules import TextToAltoMonoRegion >>> module = TextToAltoMonoRegion() >>> outputs = module.process({ ... ArtifactType.IMAGE: "/path/to/page.png", ... ArtifactType.TEXT: "Hello world\\nSecond line", ... }) >>> alto_xml = outputs[ArtifactType.ALTO] >>> " tuple[int, int]: """Retourne ``(width, height)`` en pixels, ou les valeurs par défaut. Pillow est une dépendance dure de Picarones (utilisée par les engines OCR) ; on la suppose disponible. Si la lecture échoue (fichier manquant, format inconnu), on dégrade en valeurs par défaut + warning plutôt que de faire échouer le module — le rapport ALTO peut être inspecté visuellement même avec des bbox approximatives. """ try: from PIL import Image except ImportError: logger.warning( "[alto_text_to_mono_region] Pillow indisponible — " "dimensions ALTO par défaut %dx%d", _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT, ) return _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT try: with Image.open(image_path) as img: return int(img.width), int(img.height) except Exception as exc: # noqa: BLE001 logger.warning( "[alto_text_to_mono_region] lecture %s impossible (%s) — " "dimensions par défaut %dx%d", image_path, exc, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT, ) return _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT def _build_alto_xml( text: str, width: int, height: int, *, image_filename: str = "", measurement_unit: str = "pixel", processing_software: str = "picarones.modules.TextToAltoMonoRegion", ) -> str: """Construit un ALTO XML 4.2 mono-région à partir d'un texte plat. Cette fonction est volontairement **pure** (pas d'I/O, pas de side effect) : la complexité d'I/O (lecture image, écriture fichier) est laissée à l'appelant. Cela rend la fonction trivialement testable. Distribution spatiale (mono-bloc) --------------------------------- - Le ``PrintSpace`` couvre l'image entière : (0, 0, width, height). - Le ``TextBlock`` aussi. - Les ``TextLine`` sont distribuées verticalement à pas constant : hauteur de ligne = ``height / max(1, n_lines)``. - Les ``String`` d'une ligne sont distribuées horizontalement par part proportionnelle à la longueur du mot (en caractères). Garde-fous ---------- - ``text`` peut contenir tout caractère Unicode ; il est échappé pour le XML (``<``, ``>``, ``&``, ``"``). - Les lignes vides sont préservées comme ``TextLine`` sans ``String`` (un blanc reste un blanc). - ``width`` et ``height`` doivent être > 0 ; sinon on remplace par les valeurs par défaut + warning (ne lève pas). """ if width <= 0 or height <= 0: logger.warning( "[alto_text_to_mono_region] width/height invalides " "(%s, %s) — repli sur valeurs par défaut", width, height, ) width, height = _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT # Découpage du texte en lignes (préserve les lignes vides) puis en # mots ; on n'utilise que la séparation whitespace, pas un parser # linguistique — la baseline assume un texte déjà tokenisé. lines = text.split("\n") if text else [] n_lines = max(1, len(lines)) line_h = max(1, height // n_lines) parts: list[str] = [] parts.append('') parts.append( '' ) parts.append("") parts.append(f"{_xml_escape(measurement_unit)}") parts.append("") parts.append(f"{_xml_escape(image_filename)}") parts.append("") parts.append("") parts.append("") parts.append("") parts.append( f"{_xml_escape(processing_software)}" ) parts.append("") parts.append("") parts.append("") parts.append("") parts.append("") parts.append( f'' ) parts.append( f'' ) parts.append( f'' ) for li, line in enumerate(lines or [""]): line_y = li * line_h parts.append( f'' ) words = line.split() if words: # Largeur proportionnelle au nombre de caractères par mot, # avec un minimum d'un pixel pour éviter les bbox dégénérées. total_chars = sum(len(w) for w in words) or 1 cursor = 0 for wi, word in enumerate(words): w_width = max(1, (len(word) * width) // total_chars) # Le dernier mot occupe le reste de la ligne pour # garantir somme(widths) = width (pas de drift visuel). if wi == len(words) - 1: w_width = max(1, width - cursor) parts.append( f'' ) if wi < len(words) - 1: sp_width = max(1, width // (total_chars + 1)) parts.append( f'' ) cursor = cursor + w_width + sp_width else: cursor = cursor + w_width parts.append("") parts.append("") parts.append("") parts.append("") parts.append("") parts.append("") return "\n".join(parts) class TextToAltoMonoRegion(BaseModule): """Reconstructeur ALTO de référence — TEXT (+ IMAGE) → ALTO mono-région. Module **baseline** : produit un ALTO XML 4.2 contenant une seule ``TextBlock`` qui couvre l'image source, avec une ``TextLine`` par ligne du texte d'entrée et une ``String`` par mot. Cette implémentation n'effectue **aucune segmentation visuelle** — elle distribue spatialement le texte selon sa structure linéaire (lignes par hauteur uniforme, mots par largeur proportionnelle au nombre de caractères). C'est délibéré : un reconstructeur réel, pour battre cette baseline en Layout F1, doit apporter une vraie intelligence de segmentation. Conformité ``BaseModule`` ------------------------- - ``input_types = (ArtifactType.IMAGE, ArtifactType.TEXT)`` - ``output_types = (ArtifactType.ALTO,)`` - ``execution_mode = "cpu"`` (aucune I/O réseau, calcul local) Configuration ------------- Dictionnaire optionnel passé au constructeur : - ``name`` (str) : nom affiché du module ; défaut ``"alto_text_to_mono_region"``. - ``measurement_unit`` (str) : unité ALTO ; défaut ``"pixel"``. - ``default_width`` / ``default_height`` (int) : dimensions ALTO utilisées quand l'image source est introuvable ; défauts 2000 et 3000. """ input_types = (ArtifactType.IMAGE, ArtifactType.TEXT) output_types = (ArtifactType.ALTO,) execution_mode: ExecutionMode = "cpu" def __init__(self, config: Optional[dict] = None) -> None: self.config: dict = dict(config or {}) @property def name(self) -> str: return self.config.get("name", "alto_text_to_mono_region") def metadata(self) -> dict: return { "module_kind": "alto_reconstructor", "variant": "mono_region_baseline", "deterministic": True, "schema": "ALTO 4.2", } def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]: self.validate_inputs(inputs) image_payload = inputs[ArtifactType.IMAGE] text_payload = inputs[ArtifactType.TEXT] # ``image_payload`` peut être un chemin (str/Path) — convention # historique des engines — ou directement une paire de # dimensions ``(width, height)`` pour les usages headless. if isinstance(image_payload, tuple) and len(image_payload) == 2: width, height = int(image_payload[0]), int(image_payload[1]) image_filename = "" else: image_path = Path(image_payload) if image_payload is not None else None if image_path is not None and image_path.exists(): width, height = _read_image_size(image_path) image_filename = image_path.name else: width = int(self.config.get("default_width", _DEFAULT_PAGE_WIDTH)) height = int(self.config.get("default_height", _DEFAULT_PAGE_HEIGHT)) image_filename = image_path.name if image_path is not None else "" # Le texte peut être passé tel quel (str) ou enveloppé dans un # ``TextGT`` selon le contexte d'appel. On accepte les deux # pour rendre l'intégration souple côté pipeline_runner. if hasattr(text_payload, "text"): text = str(text_payload.text) else: text = str(text_payload) if text_payload is not None else "" alto_xml = _build_alto_xml( text=text, width=width, height=height, image_filename=image_filename, measurement_unit=self.config.get("measurement_unit", "pixel"), ) return {ArtifactType.ALTO: alto_xml} __all__ = ["TextToAltoMonoRegion", "_build_alto_xml"]