File size: 7,905 Bytes
253292a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162c559
253292a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162c559
253292a
 
 
 
 
162c559
253292a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3b0e4a
253292a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f3b0e4a
253292a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""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"]