Claude
fix(architecture): audit complet β€” corrige les violations de couches dΓ©tectΓ©es par CI
162c559 unverified
Raw
History Blame
7.91 kB
"""Projecteurs ALTO β€” Sprint A14-S9.
Convertit un ``AltoDocument`` (ou un artefact ``ALTO_XML``) vers
d'autres types d'artefacts, en documentant explicitement les
pertes via ``ProjectionReport``.
ImplΓ©mentations
---------------
- ``AltoToText`` β€” extraction du texte par ordre de lecture
``Page β†’ Block β†’ Line β†’ String``. Gestion cΓ©sure
``HypPart1``/``HypPart2``.
Γ€ venir post-livraison :
- ``AltoToLines`` (extraction lignes).
- ``AltoToWordsWithBoxes`` (mots + coordonnΓ©es).
"""
from __future__ import annotations
from picarones.domain.artifacts import Artifact, ArtifactType
from picarones.evaluation.projectors.base import ProjectionReport
from picarones.formats.alto.parser import AltoParseError, parse_alto
from picarones.formats.alto.types import AltoDocument, AltoLine, AltoTextBlock
def alto_document_to_text(document: AltoDocument) -> str:
"""Extrait le texte plat d'un ``AltoDocument``.
Conventions :
- Ordre de lecture ``Page β†’ Block β†’ Line β†’ String``, dans l'ordre
d'apparition dans le XML.
- Espace entre les ``String`` d'une mΓͺme ligne.
- Saut de ligne entre les ``TextLine``.
- Saut de ligne supplΓ©mentaire entre les ``TextBlock``.
- **CΓ©sure** :
- Si un ``HypPart1`` porte ``SUBS_CONTENT`` (mot complet), on
utilise ce mot complet et on saute le ``HypPart2``
correspondant (mΓͺme ligne ou ligne suivante du mΓͺme bloc).
- Sinon, on concatène ``HypPart1.content + HypPart2.content``
et on saute le ``HypPart2``.
- Le saut de ligne visuel entre les deux est **conservΓ©** (le
mot reconstruit termine la ligne du ``HypPart1``, la ligne
du ``HypPart2`` continue avec ses autres mots).
"""
blocks_text: list[str] = []
for page in document.pages:
for block in page.blocks:
block_text = _extract_block_text(block)
if block_text:
blocks_text.append(block_text)
return "\n\n".join(blocks_text).strip()
def _extract_block_text(block: AltoTextBlock) -> str:
"""Extrait le texte d'un bloc en gΓ©rant la cΓ©sure cross-ligne.
L'usage standard ALTO place ``HypPart1`` en fin d'une ligne et
``HypPart2`` en dΓ©but de la ligne suivante du **mΓͺme** bloc.
"""
assert isinstance(block, AltoTextBlock)
lines_text: list[str] = []
skip_first_if_hyppart2 = False
for line in block.lines:
text, ended_with_hyp1 = _extract_line_text(
line, skip_first_if_hyppart2=skip_first_if_hyppart2,
)
lines_text.append(text)
skip_first_if_hyppart2 = ended_with_hyp1
return "\n".join(lines_text)
def _extract_line_text(
line: AltoLine,
*,
skip_first_if_hyppart2: bool = False,
) -> tuple[str, bool]:
"""Reconstruit le texte d'une ligne.
Returns
-------
tuple[str, bool]
``(texte_ligne, ended_with_hyppart1_resolved)``. Le second
indique si la ligne se termine par un ``HypPart1`` dont la
rΓ©solution implique de skipper le premier ``HypPart2`` de la
ligne suivante.
"""
parts: list[str] = []
skip_next = False
ended_with_hyp1 = False
strings = list(line.strings)
for i, s in enumerate(strings):
is_first = (i == 0)
if skip_next:
skip_next = False
continue
if is_first and skip_first_if_hyppart2 and s.subs_type == "HypPart2":
# Cross-ligne : la ligne prΓ©cΓ©dente a rΓ©solu le HypPart1.
continue
if s.subs_type == "HypPart1":
is_last = (i == len(strings) - 1)
if s.subs_content:
parts.append(s.subs_content)
if i + 1 < len(strings) and strings[i + 1].subs_type == "HypPart2":
skip_next = True
elif is_last:
ended_with_hyp1 = True
continue
if i + 1 < len(strings) and strings[i + 1].subs_type == "HypPart2":
parts.append(s.content + strings[i + 1].content)
skip_next = True
continue
parts.append(s.content)
if is_last:
ended_with_hyp1 = True
continue
parts.append(s.content)
return " ".join(p for p in parts if p), ended_with_hyp1
# ──────────────────────────────────────────────────────────────────────
# Projecteur conforme au protocole ``Projector`` (Sprint S5)
# ──────────────────────────────────────────────────────────────────────
class AltoToText:
"""Projecteur ``ALTO_XML β†’ RAW_TEXT``.
Lit le XML depuis l'``Artifact.uri`` (chemin filesystem) si
prΓ©sent, sinon attend que le caller ait prΓ©-stockΓ© le payload
dans un mΓ©canisme externe (ce projecteur ne tΓ©lΓ©charge rien
par lui-mΓͺme β€” pas de side-effect rΓ©seau).
Pour S9, on s'attend Γ  ce que ``artifact.uri`` pointe vers un
fichier local lisible. Le service applicatif (S19) rΓ©soudra
les autres cas (URI distante, payload inline).
"""
name = "alto_to_text"
source_type = ArtifactType.ALTO_XML
target_type = ArtifactType.RAW_TEXT
def project(
self,
artifact: Artifact,
params: dict[str, str | int | float | bool],
) -> tuple[Artifact, str, ProjectionReport]:
if artifact.type != self.source_type:
from picarones.domain.errors import ProjectionError
raise ProjectionError(
f"AltoToText n'accepte que ALTO_XML, reΓ§u "
f"{artifact.type.value!r}"
)
# Lecture du XML. Pour S9, on lit depuis le filesystem.
xml_bytes = self._read_xml(artifact)
try:
doc = parse_alto(xml_bytes)
except AltoParseError as exc:
from picarones.domain.errors import ProjectionError
raise ProjectionError(f"AltoToText : {exc}") from exc
text = alto_document_to_text(doc)
# Construction de l'artefact rΓ©sultat.
target = Artifact(
id=f"{artifact.id}:projected_text",
document_id=artifact.document_id,
type=self.target_type,
produced_by_step=artifact.produced_by_step,
)
report = ProjectionReport(
source_artifact_id=artifact.id,
source_type=self.source_type,
target_type=self.target_type,
projector_name=self.name,
lossy=True,
ignored_dimensions=(
"geometry",
"block_structure",
"reading_order",
"ids",
"confidence",
),
warnings=(
"L'extraction texte ALTO ignore les coordonnΓ©es, "
"la structure en blocs, et les IDs. La cΓ©sure "
"HypPart1/HypPart2 est rΓ©solue (mot recombinΓ©).",
),
)
return target, text, report
@staticmethod
def _read_xml(artifact: Artifact) -> bytes:
from picarones.domain.errors import ProjectionError
if artifact.uri is None:
raise ProjectionError(
f"AltoToText : artifact {artifact.id!r} n'a pas d'URI "
"et le projecteur ne sait pas rΓ©soudre les payloads "
"inline pour S9."
)
from pathlib import Path
path = Path(artifact.uri)
try:
return path.read_bytes()
except OSError as exc:
raise ProjectionError(
f"AltoToText : impossible de lire {path!r} : {exc}"
) from exc
__all__ = ["alto_document_to_text", "AltoToText"]