Claude commited on
Commit
1c1ad9a
·
unverified ·
1 Parent(s): b57eb56

feat(adapters): Phase B5 — TesseractAdapter expose ALTO XML natif

Browse files

Phase B5 du chantier Option B (mai 2026). Tesseract sait nativement
produire un ALTO 4 via ``pytesseract.image_to_alto_xml`` ; cette
capacité est désormais exposable via le flag ``expose_alto`` du
TesseractAdapter.

**Premier adapter du repo à produire un Artifact ALTO_XML** — débloque
la valeur métier AltoView. Combiné avec l'infra B0-B4
(RunOrchestrator + ViewExecutor), un benchmark Tesseract avec
``expose_alto=True`` produit désormais des métriques ALTO documentaires
(``alto_validity``, ``alto_text_cer``, ``alto_text_wer``, etc.) en
plus du CER/WER plat.

picarones/adapters/ocr/tesseract.py
- ``ArtifactType.ALTO_XML`` ajouté au ``output_types`` (set maximal).
Le YAML PipelineSpec décide quels types sont effectivement
consommés.
- Nouveau kwarg constructeur ``expose_alto: bool = False``
(compat ascendante : pipelines existants intacts).
- Nouvelle méthode ``_extract_and_persist_alto`` :
* Appelle ``pytesseract.image_to_alto_xml(image, lang, config)``
avec les mêmes paramètres OCR que ``image_to_string``.
* Normalise bytes/str (selon version pytesseract).
* Validation structurelle via ``safe_parse_xml`` —
ALTO mal formé → warning + None retourné, OCR RAW_TEXT reste
valide.
* Persiste ``<stem>.<name>.alto.xml`` à côté du fichier texte
(cohérent avec le pattern ``write_confidences_sidecar``).
* Best-effort total : toute défaillance (Tesseract crash,
sortie vide, XML invalide, OS error) est dégradée en warning.

tests/adapters/ocr/test_tesseract_alto.py (nouveau, 11 tests + 1 live)
- TestExposeAltoFlag (4) : flag par défaut off, peut être activé,
ALTO_XML dans output_types maximal, RAW_TEXT/CONFIDENCES toujours
présents.
- TestExecuteNoAlto (1) : sans flag, ``image_to_alto_xml`` jamais
invoqué (pas de coût Tesseract additionnel).
- TestExecuteAltoEnabled (6) : ALTO produit, lang/config propagés,
crash tolérance, sortie vide skippée, XML mal formé rejeté,
str/bytes normalisés.
- TestExecuteAltoLive (@pytest .mark.live) : invocation Tesseract
réelle, validation que ``parse_alto`` accepte la sortie.
Skipped sans le binaire (-m live opt-in).

tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py
- ``test_output_types`` mis à jour pour le nouveau set maximal
(RAW_TEXT, CONFIDENCES, ALTO_XML).

Tesseract devient le premier adapter de la chaîne à produire un
ALTO ; les adapters cloud (Mistral OCR, Google Vision, Azure DI) et
Pero/Kraken/Calamari pourront suivre le même pattern dans des phases
ultérieures.

picarones/adapters/ocr/tesseract.py CHANGED
@@ -105,12 +105,20 @@ class TesseractAdapter(BaseOCRAdapter):
105
  #: ``PipelineSpec`` choisit ceux qui sont effectivement consommés
106
  #: par les étapes en aval ; l'executor filtre la sortie de
107
  #: ``execute()`` sur ``step.output_types``. Si l'utilisateur
108
- #: désactive ``expose_confidences``, le YAML doit déclarer
109
- #: ``output_types: [raw_text]`` (sinon la jonction sera vue par
110
- #: l'aval comme manquant son input ``confidences``).
111
- output_types = frozenset(
112
- {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES},
113
- )
 
 
 
 
 
 
 
 
114
  execution_mode = "cpu"
115
 
116
  def __init__(
@@ -122,6 +130,7 @@ class TesseractAdapter(BaseOCRAdapter):
122
  oem: int = 3,
123
  tesseract_cmd: str | None = None,
124
  expose_confidences: bool = True,
 
125
  ) -> None:
126
  if not name or not name.strip():
127
  raise OCRAdapterError(
@@ -155,6 +164,7 @@ class TesseractAdapter(BaseOCRAdapter):
155
  self._oem = oem
156
  self._tesseract_cmd = tesseract_cmd
157
  self._expose_confidences = expose_confidences
 
158
 
159
  @property
160
  def name(self) -> str:
@@ -164,6 +174,10 @@ class TesseractAdapter(BaseOCRAdapter):
164
  def expose_confidences(self) -> bool:
165
  return self._expose_confidences
166
 
 
 
 
 
167
  @property
168
  def lang(self) -> str:
169
  return self._lang
@@ -278,6 +292,30 @@ class TesseractAdapter(BaseOCRAdapter):
278
  if confidences_artifact is not None:
279
  outputs[ArtifactType.CONFIDENCES] = confidences_artifact
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  return outputs
282
 
283
  def _extract_and_persist_confidences(
@@ -334,5 +372,108 @@ class TesseractAdapter(BaseOCRAdapter):
334
  extractor="tesseract",
335
  )
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
  __all__ = ["TesseractAdapter"]
 
105
  #: ``PipelineSpec`` choisit ceux qui sont effectivement consommés
106
  #: par les étapes en aval ; l'executor filtre la sortie de
107
  #: ``execute()`` sur ``step.output_types``. Si l'utilisateur
108
+ #: désactive ``expose_confidences`` ou ``expose_alto``, le YAML
109
+ #: doit déclarer ``output_types: [raw_text]`` (sinon la jonction
110
+ #: sera vue par l'aval comme manquant son input ``confidences`` /
111
+ #: ``alto_xml``).
112
+ #:
113
+ #: Phase B5 (mai 2026) — ``ALTO_XML`` ajouté au set maximal pour
114
+ #: permettre la production d'un ALTO natif via
115
+ #: ``pytesseract.image_to_alto_xml``. Activé via le flag
116
+ #: ``expose_alto`` (off par défaut, compat ascendante).
117
+ output_types = frozenset({
118
+ ArtifactType.RAW_TEXT,
119
+ ArtifactType.CONFIDENCES,
120
+ ArtifactType.ALTO_XML,
121
+ })
122
  execution_mode = "cpu"
123
 
124
  def __init__(
 
130
  oem: int = 3,
131
  tesseract_cmd: str | None = None,
132
  expose_confidences: bool = True,
133
+ expose_alto: bool = False,
134
  ) -> None:
135
  if not name or not name.strip():
136
  raise OCRAdapterError(
 
164
  self._oem = oem
165
  self._tesseract_cmd = tesseract_cmd
166
  self._expose_confidences = expose_confidences
167
+ self._expose_alto = expose_alto
168
 
169
  @property
170
  def name(self) -> str:
 
174
  def expose_confidences(self) -> bool:
175
  return self._expose_confidences
176
 
177
+ @property
178
+ def expose_alto(self) -> bool:
179
+ return self._expose_alto
180
+
181
  @property
182
  def lang(self) -> str:
183
  return self._lang
 
292
  if confidences_artifact is not None:
293
  outputs[ArtifactType.CONFIDENCES] = confidences_artifact
294
 
295
+ # Phase B5 — production ALTO XML natif (best-effort).
296
+ # Tesseract sait nativement produire un ALTO 4 via
297
+ # ``pytesseract.image_to_alto_xml``. Désactivé par défaut
298
+ # (compat ascendante : les pipelines existants ne s'attendent
299
+ # pas à un artefact ALTO_XML). Activer via le constructeur :
300
+ #
301
+ # TesseractAdapter(expose_alto=True)
302
+ #
303
+ # ou en YAML (PipelineSpec) :
304
+ #
305
+ # adapter_kwargs: {expose_alto: true}
306
+ # output_types: [raw_text, alto_xml]
307
+ if self._expose_alto:
308
+ alto_artifact = self._extract_and_persist_alto(
309
+ image_path=image_path,
310
+ text_path=text_path,
311
+ pytesseract_module=pytesseract,
312
+ pil_image_class=Image,
313
+ custom_config=custom_config,
314
+ document_id=context.document_id,
315
+ )
316
+ if alto_artifact is not None:
317
+ outputs[ArtifactType.ALTO_XML] = alto_artifact
318
+
319
  return outputs
320
 
321
  def _extract_and_persist_confidences(
 
372
  extractor="tesseract",
373
  )
374
 
375
+ def _extract_and_persist_alto(
376
+ self,
377
+ *,
378
+ image_path: Path,
379
+ text_path: Path,
380
+ pytesseract_module,
381
+ pil_image_class,
382
+ custom_config: str,
383
+ document_id: str,
384
+ ) -> Artifact | None:
385
+ """Phase B5 — appelle ``image_to_alto_xml`` puis écrit
386
+ ``<stem>.<name>.alto.xml`` à côté du fichier texte.
387
+
388
+ Retourne l'``Artifact ALTO_XML`` ou ``None`` si l'extraction
389
+ échoue ou si la sortie n'est pas un ALTO valide (warning
390
+ loggé, OCR reste valide via ``RAW_TEXT``).
391
+
392
+ Validation
393
+ ----------
394
+ L'ALTO produit est passé par ``safe_parse_xml`` (résistance
395
+ XXE/billion-laughs) puis par ``parse_alto`` (vérifie qu'on a
396
+ bien au moins une page + un bloc de texte). Si la
397
+ validation échoue, on log et on retourne ``None`` plutôt
398
+ que de produire un artefact corrompu en aval.
399
+ """
400
+ import logging
401
+ logger = logging.getLogger(__name__)
402
+
403
+ # Sortie attendue : str (ALTO XML 4).
404
+ try:
405
+ with pil_image_class.open(image_path) as image:
406
+ alto_xml = pytesseract_module.image_to_alto_xml(
407
+ image,
408
+ lang=self._lang,
409
+ config=custom_config,
410
+ )
411
+ except Exception as exc: # noqa: BLE001 — best-effort
412
+ logger.warning(
413
+ "[%s] image_to_alto_xml indisponible (%s) — ALTO "
414
+ "sauté pour ce document.", self._name, exc,
415
+ )
416
+ return None
417
+
418
+ # ``image_to_alto_xml`` retourne ``bytes`` selon la version de
419
+ # pytesseract. On normalise vers une string UTF-8.
420
+ if isinstance(alto_xml, bytes):
421
+ try:
422
+ alto_xml = alto_xml.decode("utf-8")
423
+ except UnicodeDecodeError as exc:
424
+ logger.warning(
425
+ "[%s] ALTO Tesseract non-UTF-8 (%s) — ALTO sauté.",
426
+ self._name, exc,
427
+ )
428
+ return None
429
+
430
+ if not alto_xml or not alto_xml.strip():
431
+ logger.warning(
432
+ "[%s] ALTO Tesseract vide — ALTO sauté.", self._name,
433
+ )
434
+ return None
435
+
436
+ # Validation structurelle minimale (résistance XXE +
437
+ # confirmation que c'est bien un ALTO parsable).
438
+ # ``safe_parse_xml`` est volontairement tolérante : elle
439
+ # retourne ``None`` au lieu de lever sur les XML invalides.
440
+ # Pour rejeter proprement un ALTO mal formé, on traite ``None``
441
+ # comme un échec de validation.
442
+ try:
443
+ from picarones.formats._xml_utils import safe_parse_xml
444
+ parsed = safe_parse_xml(alto_xml.encode("utf-8"))
445
+ except Exception as exc: # noqa: BLE001 — XML mal formé
446
+ logger.warning(
447
+ "[%s] ALTO Tesseract mal formé (%s) — ALTO sauté.",
448
+ self._name, exc,
449
+ )
450
+ return None
451
+ if parsed is None:
452
+ logger.warning(
453
+ "[%s] ALTO Tesseract non-parsable (safe_parse_xml a "
454
+ "retourné None) — ALTO sauté.", self._name,
455
+ )
456
+ return None
457
+
458
+ # Persistance à côté du fichier texte (cohérent avec
459
+ # ``write_confidences_sidecar`` : ``<stem>.<name>.alto.xml``).
460
+ alto_path = text_path.with_suffix(".alto.xml")
461
+ try:
462
+ alto_path.write_text(alto_xml, encoding="utf-8")
463
+ except OSError as exc:
464
+ logger.warning(
465
+ "[%s] ALTO non persisté (%s) — ALTO sauté.",
466
+ self._name, exc,
467
+ )
468
+ return None
469
+
470
+ return Artifact(
471
+ id=f"{document_id}:{self._name}:alto_xml",
472
+ document_id=document_id,
473
+ type=ArtifactType.ALTO_XML,
474
+ produced_by_step="ocr",
475
+ uri=str(alto_path),
476
+ )
477
+
478
 
479
  __all__ = ["TesseractAdapter"]
tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py CHANGED
@@ -129,12 +129,18 @@ class TestTesseractAdapterContract:
129
 
130
  def test_output_types(self) -> None:
131
  """``output_types`` est l'ensemble maximal produit (constante de
132
- classe). Si ``expose_confidences=False``, l'execute() omet
133
- CONFIDENCES du dict — le YAML ``PipelineSpec`` doit alors
134
- déclarer seulement ``[raw_text]`` pour cohérence.
 
 
 
 
 
135
  """
136
  assert TesseractAdapter.output_types == frozenset(
137
- {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES},
 
138
  )
139
 
140
  def test_execution_mode_is_cpu(self) -> None:
 
129
 
130
  def test_output_types(self) -> None:
131
  """``output_types`` est l'ensemble maximal produit (constante de
132
+ classe). Si ``expose_confidences=False`` ou ``expose_alto=False``,
133
+ l'execute() omet le type correspondant — le YAML
134
+ ``PipelineSpec`` doit alors déclarer seulement les types
135
+ effectivement consommés pour cohérence.
136
+
137
+ Phase B5 (mai 2026) — ``ALTO_XML`` ajouté au set maximal pour
138
+ permettre la production d'un ALTO natif via
139
+ ``image_to_alto_xml`` (opt-in via ``expose_alto=True``).
140
  """
141
  assert TesseractAdapter.output_types == frozenset(
142
+ {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES,
143
+ ArtifactType.ALTO_XML},
144
  )
145
 
146
  def test_execution_mode_is_cpu(self) -> None:
tests/adapters/ocr/test_tesseract_alto.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase B5 — production native ALTO XML par ``TesseractAdapter``.
2
+
3
+ Tesseract sait nativement produire un ALTO 4 via
4
+ ``pytesseract.image_to_alto_xml``. Ce test vérifie que :
5
+
6
+ 1. Le flag ``expose_alto`` (off par défaut, compat ascendante) ajoute
7
+ un ``Artifact ALTO_XML`` à la sortie d'``execute()``.
8
+ 2. La sortie est validée structurellement (XML bien formé) avant
9
+ d'être promue en artefact.
10
+ 3. Les défaillances (Tesseract qui plante, sortie vide, XML mal
11
+ formé) sont absorbées en warning sans casser l'OCR ``RAW_TEXT``.
12
+ 4. Un test ``@pytest.mark.live`` invoque le vrai binaire
13
+ ``tesseract`` et vérifie que l'ALTO produit est valide.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from unittest.mock import MagicMock, patch
20
+
21
+ import pytest
22
+
23
+ from picarones.adapters.ocr import TesseractAdapter
24
+ from picarones.domain.artifacts import Artifact, ArtifactType
25
+ from picarones.pipeline.types import RunContext
26
+
27
+
28
+ # ──────────────────────────────────────────────────────────────────────
29
+ # Helpers
30
+ # ──────────────────────────────────────────────────────────────────────
31
+
32
+
33
+ _PNG_HEADER = b"\x89PNG\r\n\x1a\n"
34
+
35
+
36
+ _ALTO_VALID = """<?xml version="1.0" encoding="UTF-8"?>
37
+ <alto xmlns="http://www.loc.gov/standards/alto/ns-v4#">
38
+ <Layout>
39
+ <Page ID="page_1" PHYSICAL_IMG_NR="1" WIDTH="1000" HEIGHT="1500">
40
+ <PrintSpace ID="ps_1">
41
+ <TextBlock ID="block_1">
42
+ <TextLine ID="line_1">
43
+ <String ID="word_1" CONTENT="Bonjour"
44
+ HPOS="100" VPOS="100" WIDTH="80" HEIGHT="20"/>
45
+ <String ID="word_2" CONTENT="monde"
46
+ HPOS="200" VPOS="100" WIDTH="60" HEIGHT="20"/>
47
+ </TextLine>
48
+ </TextBlock>
49
+ </PrintSpace>
50
+ </Page>
51
+ </Layout>
52
+ </alto>
53
+ """
54
+
55
+
56
+ def _make_image_artifact(uri: str) -> Artifact:
57
+ return Artifact(
58
+ id="d1:initial:image",
59
+ document_id="d1",
60
+ type=ArtifactType.IMAGE,
61
+ uri=uri,
62
+ )
63
+
64
+
65
+ def _make_context() -> RunContext:
66
+ return RunContext(
67
+ document_id="d1",
68
+ code_version="1.0.0",
69
+ pipeline_name="test",
70
+ )
71
+
72
+
73
+ def _create_dummy_image(tmp_path: Path) -> Path:
74
+ path = tmp_path / "page.png"
75
+ path.write_bytes(_PNG_HEADER)
76
+ return path
77
+
78
+
79
+ # ──────────────────────────────────────────────────────────────────────
80
+ # Constructeur
81
+ # ──────────────────────────────────────────────────────────────────────
82
+
83
+
84
+ class TestExposeAltoFlag:
85
+ def test_default_off(self) -> None:
86
+ """Compat ascendante : ``expose_alto`` est désactivé par défaut.
87
+
88
+ Les pipelines existants qui consomment ``RAW_TEXT`` /
89
+ ``CONFIDENCES`` ne reçoivent aucun nouvel artefact non
90
+ sollicité.
91
+ """
92
+ adapter = TesseractAdapter()
93
+ assert adapter.expose_alto is False
94
+
95
+ def test_can_be_enabled(self) -> None:
96
+ adapter = TesseractAdapter(expose_alto=True)
97
+ assert adapter.expose_alto is True
98
+
99
+ def test_alto_xml_in_class_output_types(self) -> None:
100
+ """Phase B5 — ``ALTO_XML`` est dans le set maximal de
101
+ l'adapter (le YAML ``output_types`` du step décide quels
102
+ types l'aval consomme).
103
+ """
104
+ assert ArtifactType.ALTO_XML in TesseractAdapter.output_types
105
+
106
+ def test_default_output_still_includes_raw_text(self) -> None:
107
+ """Pas de régression : ``RAW_TEXT`` et ``CONFIDENCES`` restent
108
+ dans le set maximal."""
109
+ assert ArtifactType.RAW_TEXT in TesseractAdapter.output_types
110
+ assert ArtifactType.CONFIDENCES in TesseractAdapter.output_types
111
+
112
+
113
+ # ──────────────────────────────────────────────────────────────────────
114
+ # execute() — pas de production ALTO si expose_alto=False
115
+ # ──────────────────────────────────────────────────────────────────────
116
+
117
+
118
+ class TestExecuteNoAlto:
119
+ @patch("PIL.Image.open")
120
+ @patch("pytesseract.image_to_string")
121
+ @patch("pytesseract.image_to_alto_xml")
122
+ def test_alto_function_not_called_by_default(
123
+ self,
124
+ mock_image_to_alto: MagicMock,
125
+ mock_image_to_string: MagicMock,
126
+ mock_image_open: MagicMock,
127
+ tmp_path: Path,
128
+ ) -> None:
129
+ """Sans ``expose_alto``, ``pytesseract.image_to_alto_xml``
130
+ n'est jamais invoqué — pas de coût Tesseract additionnel."""
131
+ mock_image_to_string.return_value = "Bonjour le monde"
132
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
133
+ adapter = TesseractAdapter(
134
+ expose_alto=False, expose_confidences=False,
135
+ )
136
+ image_path = _create_dummy_image(tmp_path)
137
+
138
+ result = adapter.execute(
139
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
140
+ params={}, context=_make_context(),
141
+ )
142
+
143
+ # ALTO absent du résultat.
144
+ assert ArtifactType.ALTO_XML not in result
145
+ # ``image_to_alto_xml`` jamais invoqué.
146
+ mock_image_to_alto.assert_not_called()
147
+
148
+
149
+ # ──────────────────────────────────────────────────────────────────────
150
+ # execute() — production ALTO quand expose_alto=True
151
+ # ──────────────────────────────────────────────────────────────────────
152
+
153
+
154
+ class TestExecuteAltoEnabled:
155
+ @patch("PIL.Image.open")
156
+ @patch("pytesseract.image_to_string")
157
+ @patch("pytesseract.image_to_alto_xml")
158
+ def test_alto_artifact_produced(
159
+ self,
160
+ mock_image_to_alto: MagicMock,
161
+ mock_image_to_string: MagicMock,
162
+ mock_image_open: MagicMock,
163
+ tmp_path: Path,
164
+ ) -> None:
165
+ """Avec ``expose_alto=True``, un ``Artifact ALTO_XML`` est
166
+ produit en plus du ``RAW_TEXT``."""
167
+ mock_image_to_string.return_value = "Bonjour monde"
168
+ mock_image_to_alto.return_value = _ALTO_VALID.encode("utf-8")
169
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
170
+
171
+ adapter = TesseractAdapter(
172
+ expose_alto=True, expose_confidences=False,
173
+ )
174
+ image_path = _create_dummy_image(tmp_path)
175
+
176
+ result = adapter.execute(
177
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
178
+ params={}, context=_make_context(),
179
+ )
180
+
181
+ assert ArtifactType.ALTO_XML in result
182
+ alto_artifact = result[ArtifactType.ALTO_XML]
183
+ assert alto_artifact.type == ArtifactType.ALTO_XML
184
+ assert alto_artifact.uri is not None
185
+ # Le fichier ALTO existe et contient l'XML retourné par Tesseract.
186
+ alto_path = Path(alto_artifact.uri)
187
+ assert alto_path.exists()
188
+ assert alto_path.suffix == ".xml"
189
+ assert "alto" in alto_path.name.lower()
190
+ assert "Bonjour" in alto_path.read_text(encoding="utf-8")
191
+
192
+ @patch("PIL.Image.open")
193
+ @patch("pytesseract.image_to_string")
194
+ @patch("pytesseract.image_to_alto_xml")
195
+ def test_alto_called_with_correct_lang_and_config(
196
+ self,
197
+ mock_image_to_alto: MagicMock,
198
+ mock_image_to_string: MagicMock,
199
+ mock_image_open: MagicMock,
200
+ tmp_path: Path,
201
+ ) -> None:
202
+ """``image_to_alto_xml`` reçoit les mêmes ``lang``/``config``
203
+ que ``image_to_string`` — cohérence des paramètres OCR."""
204
+ mock_image_to_string.return_value = "x"
205
+ mock_image_to_alto.return_value = _ALTO_VALID.encode("utf-8")
206
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
207
+
208
+ adapter = TesseractAdapter(
209
+ lang="lat", psm=4, oem=1,
210
+ expose_alto=True, expose_confidences=False,
211
+ )
212
+ image_path = _create_dummy_image(tmp_path)
213
+ adapter.execute(
214
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
215
+ params={}, context=_make_context(),
216
+ )
217
+
218
+ # Vérification que image_to_alto_xml a été invoqué avec
219
+ # la bonne langue et la bonne config.
220
+ assert mock_image_to_alto.call_count == 1
221
+ kwargs = mock_image_to_alto.call_args.kwargs
222
+ assert kwargs["lang"] == "lat"
223
+ assert kwargs["config"] == "--oem 1 --psm 4"
224
+
225
+ @patch("PIL.Image.open")
226
+ @patch("pytesseract.image_to_string")
227
+ @patch("pytesseract.image_to_alto_xml")
228
+ def test_alto_failure_does_not_break_raw_text(
229
+ self,
230
+ mock_image_to_alto: MagicMock,
231
+ mock_image_to_string: MagicMock,
232
+ mock_image_open: MagicMock,
233
+ tmp_path: Path,
234
+ ) -> None:
235
+ """Si ``image_to_alto_xml`` lève une exception, l'OCR
236
+ ``RAW_TEXT`` reste valide — l'ALTO est juste sauté avec
237
+ un warning loggé.
238
+ """
239
+ mock_image_to_string.return_value = "Bonjour"
240
+ mock_image_to_alto.side_effect = RuntimeError("Tesseract ALTO crash")
241
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
242
+
243
+ adapter = TesseractAdapter(
244
+ expose_alto=True, expose_confidences=False,
245
+ )
246
+ image_path = _create_dummy_image(tmp_path)
247
+ result = adapter.execute(
248
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
249
+ params={}, context=_make_context(),
250
+ )
251
+
252
+ # RAW_TEXT toujours présent.
253
+ assert ArtifactType.RAW_TEXT in result
254
+ # ALTO absent (best-effort skip).
255
+ assert ArtifactType.ALTO_XML not in result
256
+
257
+ @patch("PIL.Image.open")
258
+ @patch("pytesseract.image_to_string")
259
+ @patch("pytesseract.image_to_alto_xml")
260
+ def test_alto_empty_output_skipped(
261
+ self,
262
+ mock_image_to_alto: MagicMock,
263
+ mock_image_to_string: MagicMock,
264
+ mock_image_open: MagicMock,
265
+ tmp_path: Path,
266
+ ) -> None:
267
+ """Un ALTO vide ou que des espaces n'est pas promu en artefact."""
268
+ mock_image_to_string.return_value = "x"
269
+ mock_image_to_alto.return_value = b""
270
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
271
+
272
+ adapter = TesseractAdapter(
273
+ expose_alto=True, expose_confidences=False,
274
+ )
275
+ image_path = _create_dummy_image(tmp_path)
276
+ result = adapter.execute(
277
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
278
+ params={}, context=_make_context(),
279
+ )
280
+
281
+ assert ArtifactType.ALTO_XML not in result
282
+
283
+ @patch("PIL.Image.open")
284
+ @patch("pytesseract.image_to_string")
285
+ @patch("pytesseract.image_to_alto_xml")
286
+ def test_alto_malformed_xml_skipped(
287
+ self,
288
+ mock_image_to_alto: MagicMock,
289
+ mock_image_to_string: MagicMock,
290
+ mock_image_open: MagicMock,
291
+ tmp_path: Path,
292
+ ) -> None:
293
+ """Un ALTO mal formé (balise non fermée, etc.) n'est pas promu
294
+ en artefact — la validation ``safe_parse_xml`` rejette."""
295
+ mock_image_to_string.return_value = "x"
296
+ # XML invalide : pas de balise root fermante.
297
+ mock_image_to_alto.return_value = b"<alto><Page></alto>"
298
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
299
+
300
+ adapter = TesseractAdapter(
301
+ expose_alto=True, expose_confidences=False,
302
+ )
303
+ image_path = _create_dummy_image(tmp_path)
304
+ result = adapter.execute(
305
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
306
+ params={}, context=_make_context(),
307
+ )
308
+
309
+ assert ArtifactType.ALTO_XML not in result
310
+
311
+ @patch("PIL.Image.open")
312
+ @patch("pytesseract.image_to_string")
313
+ @patch("pytesseract.image_to_alto_xml")
314
+ def test_alto_string_output_normalized(
315
+ self,
316
+ mock_image_to_alto: MagicMock,
317
+ mock_image_to_string: MagicMock,
318
+ mock_image_open: MagicMock,
319
+ tmp_path: Path,
320
+ ) -> None:
321
+ """``pytesseract.image_to_alto_xml`` peut retourner un ``str``
322
+ au lieu de ``bytes`` selon la version — l'adapter doit gérer
323
+ les deux types."""
324
+ mock_image_to_string.return_value = "x"
325
+ mock_image_to_alto.return_value = _ALTO_VALID # str, pas bytes
326
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
327
+
328
+ adapter = TesseractAdapter(
329
+ expose_alto=True, expose_confidences=False,
330
+ )
331
+ image_path = _create_dummy_image(tmp_path)
332
+ result = adapter.execute(
333
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
334
+ params={}, context=_make_context(),
335
+ )
336
+
337
+ assert ArtifactType.ALTO_XML in result
338
+
339
+
340
+ # ──────────────────────────────────────────────────────────────────────
341
+ # Test live — vraie exécution Tesseract
342
+ # ──────────────────────────────────────────────────────────────────────
343
+
344
+
345
+ @pytest.mark.live
346
+ class TestExecuteAltoLive:
347
+ """Tests qui invoquent le vrai binaire ``tesseract``.
348
+
349
+ Activés uniquement avec ``pytest -m live``. Skipped sans le
350
+ binaire (vérifié au fixture).
351
+ """
352
+
353
+ @pytest.fixture
354
+ def real_image(self, tmp_path: Path) -> Path:
355
+ """Crée une image PNG avec du texte rendu via Pillow.
356
+
357
+ Tesseract devrait être capable de transcrire ce texte.
358
+ """
359
+ from PIL import Image, ImageDraw
360
+
361
+ img = Image.new("RGB", (300, 80), color=(255, 255, 255))
362
+ d = ImageDraw.Draw(img)
363
+ d.text((10, 30), "Bonjour", fill=(0, 0, 0))
364
+ path = tmp_path / "live_page.png"
365
+ img.save(path)
366
+ return path
367
+
368
+ def test_real_tesseract_produces_valid_alto(
369
+ self, real_image: Path, tmp_path: Path,
370
+ ) -> None:
371
+ """Vrai Tesseract → ALTO XML structurellement valide."""
372
+ from picarones.formats.alto.parser import parse_alto
373
+
374
+ adapter = TesseractAdapter(
375
+ lang="eng", psm=7,
376
+ expose_alto=True, expose_confidences=False,
377
+ )
378
+
379
+ result = adapter.execute(
380
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(real_image))},
381
+ params={}, context=_make_context(),
382
+ )
383
+
384
+ assert ArtifactType.ALTO_XML in result, (
385
+ "Tesseract n'a pas produit d'ALTO — vérifier l'installation "
386
+ "tesseract + pytesseract."
387
+ )
388
+ alto_path = Path(result[ArtifactType.ALTO_XML].uri)
389
+ assert alto_path.exists()
390
+ # Le parser ALTO de Picarones doit accepter la sortie Tesseract.
391
+ parsed = parse_alto(alto_path.read_text(encoding="utf-8"))
392
+ assert parsed is not None