Claude commited on
Commit
e6d26ea
·
unverified ·
1 Parent(s): e60a30b

feat(adapters/ocr): Sprint A14-S30 — TesseractAdapter natif (no shim)

Browse files

Migration native du legacy picarones.engines.tesseract.TesseractEngine
vers le contrat BaseOCRAdapter (S26). Pas un shim : la classe implémente
directement le contrat du nouveau monde, sans héritage du legacy.

Le legacy TesseractEngine reste en place pour les callers qui n'ont
pas encore migré ; sa suppression viendra au S46 quand la parité sera
atteinte sur tous les adapters (S30-S34).

picarones/adapters/ocr/tesseract.py
-----------------------------------
- TesseractAdapter(BaseOCRAdapter) — direct hérite, pas de shim.
- input_types = {IMAGE}, output_types = {RAW_TEXT}.
- execution_mode = "cpu" (Tesseract appelle une lib C en sous-process,
ProcessPool dans le runner pour vrai parallélisme).
- Constructeur kwargs-only : name (alphanum + _-), lang, psm (0-13),
oem (0-3), tesseract_cmd. Validation stricte au constructeur.
- execute(inputs, params, context) :
· valide IMAGE input + URI + fichier existe ;
· lazy-import pytesseract + PIL avec OCRAdapterError clair si absent ;
· applique tesseract_cmd custom si fourni ;
· pytesseract.image_to_string(image, lang, --oem N --psm M) ;
· text strippé (rétrocompat octet-pour-octet legacy S1) ;
· écrit dans <stem>.<name>.txt à côté de l'image (cohérent avec
PrecomputedTextAdapter — un caller peut relire la sortie via
cet adapter pour la comparer dans un second run) ;
· Artifact RAW_TEXT avec id "<doc>:<name>:raw_text".
- Erreurs Tesseract wrappées dans OCRAdapterError avec type+message.

Tests S30 dédiés (24 nouveaux)
------------------------------
- TesseractAdapterConstructor : defaults, custom name, rejet name vide/
whitespace/chars invalides, rejet psm hors [0,13], rejet oem hors
[0,3], boundary values acceptées.
- TesseractAdapterContract : isinstance BaseOCRAdapter, input_types,
output_types, execution_mode = "cpu".
- TesseractAdapterInputValidation : IMAGE absent → OCRAdapterError,
artefact sans URI → OCRAdapterError, image inexistante →
OCRAdapterError.
- TesseractAdapterExecute : nominal (mocked pytesseract) → Artifact +
fichier <stem>.tesseract.txt, custom name change le filename, lang/
psm/oem passés correctement à pytesseract, tesseract_cmd appliqué,
exception Tesseract wrappée, pytesseract absent → OCRAdapterError
avec pip install hint, artifact id utilise adapter name, text strippé.

Pas de confidences pour S30
---------------------------
Confidences (legacy S47 image_to_data) reportées à un sprint dédié qui
définira ConfidenceArtifact typé, conformément à la doc BaseOCRAdapter
(S26). La fonctionnalité reste disponible via legacy
picarones.engines.tesseract jusqu'au S46.

Tests : 4622 passed, 11 skipped (vs 4596 avant : +24 S30 + 2 README).
Lint : ruff check picarones/ tests/ → All checks passed.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

README.md CHANGED
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
396
  python -m mypy picarones/core/
397
  ```
398
 
399
- **Test suite**: ~4610 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
396
  python -m mypy picarones/core/
397
  ```
398
 
399
+ **Test suite**: ~4640 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/adapters/ocr/__init__.py CHANGED
@@ -21,9 +21,11 @@ from __future__ import annotations
21
 
22
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
23
  from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
 
24
 
25
  __all__ = [
26
  "BaseOCRAdapter",
27
  "OCRAdapterError",
28
  "PrecomputedTextAdapter",
 
29
  ]
 
21
 
22
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
23
  from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
24
+ from picarones.adapters.ocr.tesseract import TesseractAdapter
25
 
26
  __all__ = [
27
  "BaseOCRAdapter",
28
  "OCRAdapterError",
29
  "PrecomputedTextAdapter",
30
+ "TesseractAdapter",
31
  ]
picarones/adapters/ocr/tesseract.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``TesseractAdapter`` natif — Sprint A14-S30.
2
+
3
+ Migration native du legacy ``picarones.engines.tesseract.TesseractEngine``
4
+ vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe
5
+ implémente directement le contrat du nouveau monde, sans héritage du
6
+ legacy.
7
+
8
+ Le legacy ``TesseractEngine`` reste en place pour les callers qui
9
+ n'ont pas encore migré ; sa suppression viendra au S46 quand la
10
+ parité sera atteinte sur tous les adapters.
11
+
12
+ Cas d'usage BnF
13
+ ---------------
14
+ Tesseract 5 reste l'OCR open-source de référence pour les corpus
15
+ imprimés et certains manuscrits réguliers. L'adapter est CPU-bound
16
+ (Tesseract appelle une lib C en sous-process) — déclaré
17
+ ``execution_mode="cpu"`` pour que le runner utilise un
18
+ ``ProcessPoolExecutor``.
19
+
20
+ Configuration
21
+ -------------
22
+ Constructeur :
23
+
24
+ - ``name`` (défaut ``"tesseract"``) : identifiant de l'instance.
25
+ Sert de suffixe au fichier de sortie ``<stem>.<name>.txt`` —
26
+ permet de coexister avec plusieurs configurations Tesseract dans
27
+ un même benchmark.
28
+ - ``lang`` (défaut ``"fra"``) : code langue Tesseract (``"fra"``,
29
+ ``"lat"``, ``"eng"``, ``"fra+lat"``).
30
+ - ``psm`` (défaut ``6``) : Page Segmentation Mode (0-13).
31
+ - ``oem`` (défaut ``3``) : OCR Engine Mode.
32
+ - ``tesseract_cmd`` (défaut ``None``) : chemin vers l'exécutable
33
+ ``tesseract`` si non standard.
34
+
35
+ Comportement
36
+ ------------
37
+ 1. Vérifie qu'un ``Artifact`` ``IMAGE`` est présent dans ``inputs``
38
+ et qu'il porte une ``uri`` filesystem.
39
+ 2. Lazy-import de ``pytesseract`` et ``PIL`` — si absent, lève
40
+ ``OCRAdapterError`` avec message explicite.
41
+ 3. Applique ``tesseract_cmd`` s'il est fourni.
42
+ 4. Appelle ``pytesseract.image_to_string`` avec ``lang`` et
43
+ ``--oem N --psm M``.
44
+ 5. Écrit le texte dans ``<stem>.<name>.txt`` à côté de l'image.
45
+ Cohérent avec le pattern ``PrecomputedTextAdapter`` (Sprint S26)
46
+ — un caller peut relire la sortie via cet adapter pour la
47
+ comparer dans un second run.
48
+ 6. Retourne un ``Artifact`` ``RAW_TEXT`` pointant vers le fichier
49
+ produit.
50
+
51
+ Anti-sur-ingénierie
52
+ -------------------
53
+ - Pas de retry — Tesseract échoue rarement sur une image valide,
54
+ et un appelant peut wrapper si besoin.
55
+ - Pas d'extraction de confidences (legacy S47) — reporté à un
56
+ sprint dédié qui définira ``ConfidenceArtifact`` typé. La
57
+ fonctionnalité reste disponible via le legacy
58
+ ``picarones.engines.tesseract.TesseractEngine`` jusqu'au S46.
59
+ - Pas de validation de l'encodage de l'image — Tesseract gère.
60
+ - Pas de support batch — un appel par image (le runner gère le
61
+ parallélisme inter-documents).
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
70
+ from picarones.domain.artifacts import Artifact, ArtifactType
71
+
72
+
73
+ class TesseractAdapter(BaseOCRAdapter):
74
+ """Adapter Tesseract 5 natif au nouveau contrat (S26).
75
+
76
+ Parameters
77
+ ----------
78
+ name:
79
+ Identifiant lisible de l'instance. Défaut ``"tesseract"``.
80
+ Doit être alphanumérique + ``_-`` (composant de nom de fichier).
81
+ lang:
82
+ Code langue Tesseract (``"fra"``, ``"lat"``, ``"eng"``, ...).
83
+ Défaut ``"fra"``.
84
+ psm:
85
+ Page Segmentation Mode entre 0 et 13. Défaut 6
86
+ (single uniform block of text).
87
+ oem:
88
+ OCR Engine Mode (0-3). Défaut 3 (LSTM, le plus précis).
89
+ tesseract_cmd:
90
+ Chemin custom vers l'exécutable ``tesseract``. Défaut
91
+ ``None`` (laisse pytesseract trouver l'installation système).
92
+
93
+ Raises
94
+ ------
95
+ OCRAdapterError
96
+ Si le ``name`` ou les valeurs de ``psm`` / ``oem`` sont
97
+ invalides.
98
+ """
99
+
100
+ input_types = frozenset({ArtifactType.IMAGE})
101
+ output_types = frozenset({ArtifactType.RAW_TEXT})
102
+ execution_mode = "cpu"
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ name: str = "tesseract",
108
+ lang: str = "fra",
109
+ psm: int = 6,
110
+ oem: int = 3,
111
+ tesseract_cmd: str | None = None,
112
+ ) -> None:
113
+ if not name or not name.strip():
114
+ raise OCRAdapterError(
115
+ "TesseractAdapter : name vide non autorisé.",
116
+ )
117
+ if not all(c.isalnum() or c in "_-" for c in name):
118
+ raise OCRAdapterError(
119
+ f"TesseractAdapter : name invalide {name!r} — "
120
+ "alphanumérique + _ - uniquement.",
121
+ )
122
+ if not 0 <= psm <= 13:
123
+ raise OCRAdapterError(
124
+ f"TesseractAdapter : psm doit être ∈ [0, 13], reçu {psm}.",
125
+ )
126
+ if not 0 <= oem <= 3:
127
+ raise OCRAdapterError(
128
+ f"TesseractAdapter : oem doit être ∈ [0, 3], reçu {oem}.",
129
+ )
130
+ self._name = name
131
+ self._lang = lang
132
+ self._psm = psm
133
+ self._oem = oem
134
+ self._tesseract_cmd = tesseract_cmd
135
+
136
+ @property
137
+ def name(self) -> str:
138
+ return self._name
139
+
140
+ @property
141
+ def lang(self) -> str:
142
+ return self._lang
143
+
144
+ @property
145
+ def psm(self) -> int:
146
+ return self._psm
147
+
148
+ @property
149
+ def oem(self) -> int:
150
+ return self._oem
151
+
152
+ def execute(
153
+ self,
154
+ inputs: dict[ArtifactType, Artifact],
155
+ params: dict[str, Any],
156
+ context: Any,
157
+ ) -> dict[ArtifactType, Artifact]:
158
+ """Exécute Tesseract sur l'image fournie.
159
+
160
+ Raises
161
+ ------
162
+ OCRAdapterError
163
+ - input ``IMAGE`` absent ;
164
+ - artefact image sans URI ;
165
+ - fichier image introuvable ;
166
+ - ``pytesseract`` ou ``PIL`` non installé ;
167
+ - erreur Tesseract (lib system manquante, etc.).
168
+ """
169
+ if ArtifactType.IMAGE not in inputs:
170
+ raise OCRAdapterError(
171
+ f"{self.name} : input IMAGE manquant.",
172
+ )
173
+ image_artifact = inputs[ArtifactType.IMAGE]
174
+ if image_artifact.uri is None:
175
+ raise OCRAdapterError(
176
+ f"{self.name} : artefact image "
177
+ f"{image_artifact.id!r} sans URI.",
178
+ )
179
+
180
+ image_path = Path(image_artifact.uri)
181
+ if not image_path.exists():
182
+ raise OCRAdapterError(
183
+ f"{self.name} : image introuvable {image_path!r}.",
184
+ )
185
+
186
+ # Lazy-import de pytesseract + PIL — si absents, message
187
+ # explicite plutôt qu'``ImportError`` au top-level.
188
+ try:
189
+ import pytesseract # type: ignore[import-untyped]
190
+ from PIL import Image
191
+ except ImportError as exc:
192
+ raise OCRAdapterError(
193
+ f"{self.name} : pytesseract/Pillow non installés. "
194
+ "Installer avec : pip install pytesseract pillow",
195
+ ) from exc
196
+
197
+ # Application du tesseract_cmd custom si fourni.
198
+ if self._tesseract_cmd is not None:
199
+ pytesseract.pytesseract.tesseract_cmd = self._tesseract_cmd
200
+
201
+ # OCR.
202
+ custom_config = f"--oem {self._oem} --psm {self._psm}"
203
+ try:
204
+ with Image.open(image_path) as image:
205
+ text = pytesseract.image_to_string(
206
+ image,
207
+ lang=self._lang,
208
+ config=custom_config,
209
+ )
210
+ except Exception as exc:
211
+ raise OCRAdapterError(
212
+ f"{self.name} : Tesseract a levé sur "
213
+ f"{image_path!r} : {type(exc).__name__}: {exc}",
214
+ ) from exc
215
+
216
+ text = text.strip()
217
+
218
+ # Écriture du résultat à côté de l'image. Cohérent avec le
219
+ # pattern ``PrecomputedTextAdapter`` — un caller peut relire
220
+ # la sortie via cet adapter pour la comparer dans un second run.
221
+ text_path = (
222
+ image_path.parent / f"{image_path.stem}.{self.name}.txt"
223
+ )
224
+ text_path.write_text(text, encoding="utf-8")
225
+
226
+ return {
227
+ ArtifactType.RAW_TEXT: Artifact(
228
+ id=f"{context.document_id}:{self.name}:raw_text",
229
+ document_id=context.document_id,
230
+ type=ArtifactType.RAW_TEXT,
231
+ produced_by_step="ocr",
232
+ uri=str(text_path),
233
+ ),
234
+ }
235
+
236
+
237
+ __all__ = ["TesseractAdapter"]
tests/adapters/ocr/__init__.py ADDED
File without changes
tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S30 — ``TesseractAdapter`` natif au contrat S26.
2
+
3
+ Tests de l'adapter Tesseract migré nativement (pas de shim sur le
4
+ legacy ``picarones.engines.tesseract``).
5
+
6
+ Couvre :
7
+
8
+ 1. Constructeur :
9
+ - rejet des paramètres invalides (name, psm, oem) ;
10
+ - valeurs par défaut ;
11
+ - propriétés en lecture.
12
+
13
+ 2. ``execute`` :
14
+ - cas nominal (mock pytesseract) → Artifact RAW_TEXT avec URI ;
15
+ - input IMAGE absent → OCRAdapterError ;
16
+ - artefact image sans URI → OCRAdapterError ;
17
+ - image inexistante → OCRAdapterError ;
18
+ - pytesseract non installé → OCRAdapterError ;
19
+ - Tesseract lève → OCRAdapterError ;
20
+ - écriture du fichier de sortie au bon emplacement ;
21
+ - tesseract_cmd appliqué.
22
+
23
+ 3. Contrat ``BaseOCRAdapter`` :
24
+ - input_types / output_types / execution_mode ;
25
+ - hérite bien de BaseOCRAdapter.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import sys
31
+ from pathlib import Path
32
+ from unittest.mock import MagicMock, patch
33
+
34
+ import pytest
35
+
36
+ from picarones.adapters.ocr import (
37
+ BaseOCRAdapter,
38
+ OCRAdapterError,
39
+ TesseractAdapter,
40
+ )
41
+ from picarones.domain.artifacts import Artifact, ArtifactType
42
+ from picarones.pipeline.types import RunContext
43
+
44
+
45
+ # ──────────────────────────────────────────────────────────────────────
46
+ # Helpers
47
+ # ──────────────────────────────────────────────────────────────────────
48
+
49
+
50
+ def _make_image_artifact(uri: str) -> Artifact:
51
+ return Artifact(
52
+ id="d1:initial:image",
53
+ document_id="d1",
54
+ type=ArtifactType.IMAGE,
55
+ uri=uri,
56
+ )
57
+
58
+
59
+ def _make_context() -> RunContext:
60
+ return RunContext(
61
+ document_id="d1",
62
+ code_version="1.0.0",
63
+ pipeline_name="test",
64
+ )
65
+
66
+
67
+ # ──────────────────────────────────────────────────────────────────────
68
+ # Constructeur
69
+ # ──────────────────────────────────────────────────────────────────────
70
+
71
+
72
+ class TestTesseractAdapterConstructor:
73
+ def test_defaults(self) -> None:
74
+ adapter = TesseractAdapter()
75
+ assert adapter.name == "tesseract"
76
+ assert adapter.lang == "fra"
77
+ assert adapter.psm == 6
78
+ assert adapter.oem == 3
79
+
80
+ def test_custom_name(self) -> None:
81
+ adapter = TesseractAdapter(name="my_tesseract_lat")
82
+ assert adapter.name == "my_tesseract_lat"
83
+
84
+ def test_rejects_empty_name(self) -> None:
85
+ with pytest.raises(OCRAdapterError, match="vide"):
86
+ TesseractAdapter(name="")
87
+
88
+ def test_rejects_whitespace_name(self) -> None:
89
+ with pytest.raises(OCRAdapterError, match="vide"):
90
+ TesseractAdapter(name=" ")
91
+
92
+ def test_rejects_invalid_chars_in_name(self) -> None:
93
+ with pytest.raises(OCRAdapterError, match="invalide"):
94
+ TesseractAdapter(name="bad name with space")
95
+
96
+ def test_rejects_psm_out_of_range(self) -> None:
97
+ with pytest.raises(OCRAdapterError, match=r"psm.*\[0, 13\]"):
98
+ TesseractAdapter(psm=14)
99
+ with pytest.raises(OCRAdapterError, match=r"psm.*\[0, 13\]"):
100
+ TesseractAdapter(psm=-1)
101
+
102
+ def test_rejects_oem_out_of_range(self) -> None:
103
+ with pytest.raises(OCRAdapterError, match=r"oem.*\[0, 3\]"):
104
+ TesseractAdapter(oem=4)
105
+ with pytest.raises(OCRAdapterError, match=r"oem.*\[0, 3\]"):
106
+ TesseractAdapter(oem=-1)
107
+
108
+ def test_accepts_psm_boundary_values(self) -> None:
109
+ TesseractAdapter(psm=0)
110
+ TesseractAdapter(psm=13)
111
+
112
+ def test_accepts_oem_boundary_values(self) -> None:
113
+ TesseractAdapter(oem=0)
114
+ TesseractAdapter(oem=3)
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────────
118
+ # Contrat BaseOCRAdapter
119
+ # ──────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ class TestTesseractAdapterContract:
123
+ def test_inherits_base_adapter(self) -> None:
124
+ adapter = TesseractAdapter()
125
+ assert isinstance(adapter, BaseOCRAdapter)
126
+
127
+ def test_input_types(self) -> None:
128
+ assert TesseractAdapter.input_types == frozenset({ArtifactType.IMAGE})
129
+
130
+ def test_output_types(self) -> None:
131
+ assert TesseractAdapter.output_types == frozenset({ArtifactType.RAW_TEXT})
132
+
133
+ def test_execution_mode_is_cpu(self) -> None:
134
+ """Tesseract est CPU-bound — utilise un ProcessPool dans le runner."""
135
+ assert TesseractAdapter.execution_mode == "cpu"
136
+
137
+
138
+ # ──────────────────────────────────────────────────────────────────────
139
+ # execute() — validation des inputs
140
+ # ──────────────────────────────────────────────────────────────────────
141
+
142
+
143
+ class TestTesseractAdapterInputValidation:
144
+ def test_missing_image_input_raises(self, tmp_path: Path) -> None:
145
+ adapter = TesseractAdapter()
146
+ with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
147
+ adapter.execute(inputs={}, params={}, context=_make_context())
148
+
149
+ def test_image_artifact_without_uri_raises(self) -> None:
150
+ adapter = TesseractAdapter()
151
+ artifact = Artifact(
152
+ id="d1:img",
153
+ document_id="d1",
154
+ type=ArtifactType.IMAGE,
155
+ uri=None, # explicit no URI
156
+ )
157
+ with pytest.raises(OCRAdapterError, match="sans URI"):
158
+ adapter.execute(
159
+ inputs={ArtifactType.IMAGE: artifact},
160
+ params={},
161
+ context=_make_context(),
162
+ )
163
+
164
+ def test_image_path_does_not_exist_raises(self) -> None:
165
+ adapter = TesseractAdapter()
166
+ artifact = _make_image_artifact("/nonexistent/path/img.png")
167
+ with pytest.raises(OCRAdapterError, match="introuvable"):
168
+ adapter.execute(
169
+ inputs={ArtifactType.IMAGE: artifact},
170
+ params={},
171
+ context=_make_context(),
172
+ )
173
+
174
+
175
+ # ──────────────────────────────────────────────────────────────────────
176
+ # execute() — chemin nominal et erreurs Tesseract
177
+ # ──────────────────────────────────────────────────────────────────────
178
+
179
+
180
+ class TestTesseractAdapterExecute:
181
+ def _create_dummy_image(self, tmp_path: Path) -> Path:
182
+ """Crée un fichier vide qui sert d'image (les tests mocquent
183
+ pytesseract donc le contenu n'est pas analysé)."""
184
+ path = tmp_path / "page.png"
185
+ path.write_bytes(b"\x89PNG\r\n\x1a\n") # signature PNG basique
186
+ return path
187
+
188
+ @patch("PIL.Image.open")
189
+ @patch("pytesseract.image_to_string")
190
+ def test_nominal_execution(
191
+ self, mock_image_to_string: MagicMock,
192
+ mock_image_open: MagicMock,
193
+ tmp_path: Path,
194
+ ) -> None:
195
+ """Cas nominal : pytesseract retourne du texte → Artifact RAW_TEXT
196
+ avec URI vers un fichier produit."""
197
+ mock_image_to_string.return_value = "Bonjour le monde\n"
198
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
199
+ adapter = TesseractAdapter()
200
+ image_path = self._create_dummy_image(tmp_path)
201
+ artifact = _make_image_artifact(str(image_path))
202
+
203
+ result = adapter.execute(
204
+ inputs={ArtifactType.IMAGE: artifact},
205
+ params={},
206
+ context=_make_context(),
207
+ )
208
+ assert ArtifactType.RAW_TEXT in result
209
+ produced = result[ArtifactType.RAW_TEXT]
210
+ assert produced.type == ArtifactType.RAW_TEXT
211
+ assert produced.uri is not None
212
+
213
+ # Le fichier de sortie existe et contient le texte stripé.
214
+ out_path = Path(produced.uri)
215
+ assert out_path.exists()
216
+ assert out_path.read_text(encoding="utf-8") == "Bonjour le monde"
217
+
218
+ # Convention : <stem>.<name>.txt à côté de l'image.
219
+ assert out_path.name == "page.tesseract.txt"
220
+ assert out_path.parent == tmp_path
221
+
222
+ @patch("PIL.Image.open")
223
+ @patch("pytesseract.image_to_string")
224
+ def test_custom_name_changes_output_filename(
225
+ self, mock_image_to_string: MagicMock,
226
+ mock_image_open: MagicMock,
227
+ tmp_path: Path,
228
+ ) -> None:
229
+ mock_image_to_string.return_value = "x"
230
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
231
+ adapter = TesseractAdapter(name="tess_lat_psm6")
232
+ image_path = self._create_dummy_image(tmp_path)
233
+ artifact = _make_image_artifact(str(image_path))
234
+
235
+ result = adapter.execute(
236
+ inputs={ArtifactType.IMAGE: artifact},
237
+ params={},
238
+ context=_make_context(),
239
+ )
240
+ out_path = Path(result[ArtifactType.RAW_TEXT].uri)
241
+ assert out_path.name == "page.tess_lat_psm6.txt"
242
+
243
+ @patch("PIL.Image.open")
244
+ @patch("pytesseract.image_to_string")
245
+ def test_lang_psm_oem_passed_to_pytesseract(
246
+ self, mock_image_to_string: MagicMock,
247
+ mock_image_open: MagicMock,
248
+ tmp_path: Path,
249
+ ) -> None:
250
+ mock_image_to_string.return_value = "x"
251
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
252
+ adapter = TesseractAdapter(lang="lat", psm=4, oem=1)
253
+ image_path = self._create_dummy_image(tmp_path)
254
+ artifact = _make_image_artifact(str(image_path))
255
+
256
+ adapter.execute(
257
+ inputs={ArtifactType.IMAGE: artifact},
258
+ params={},
259
+ context=_make_context(),
260
+ )
261
+
262
+ # On vérifie l'appel à pytesseract.image_to_string avec les bons args.
263
+ assert mock_image_to_string.called
264
+ kwargs = mock_image_to_string.call_args.kwargs
265
+ assert kwargs["lang"] == "lat"
266
+ assert "--psm 4" in kwargs["config"]
267
+ assert "--oem 1" in kwargs["config"]
268
+
269
+ @patch("PIL.Image.open")
270
+ @patch("pytesseract.image_to_string")
271
+ def test_tesseract_cmd_applied_when_set(
272
+ self, mock_image_to_string: MagicMock,
273
+ mock_image_open: MagicMock,
274
+ tmp_path: Path,
275
+ ) -> None:
276
+ mock_image_to_string.return_value = "x"
277
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
278
+ # Ré-import temporaire pour récupérer le module.
279
+ import pytesseract # type: ignore[import-untyped]
280
+ adapter = TesseractAdapter(tesseract_cmd="/custom/bin/tesseract")
281
+ image_path = self._create_dummy_image(tmp_path)
282
+ artifact = _make_image_artifact(str(image_path))
283
+
284
+ adapter.execute(
285
+ inputs={ArtifactType.IMAGE: artifact},
286
+ params={},
287
+ context=_make_context(),
288
+ )
289
+ assert pytesseract.pytesseract.tesseract_cmd == "/custom/bin/tesseract"
290
+
291
+ @patch("PIL.Image.open")
292
+ @patch("pytesseract.image_to_string")
293
+ def test_tesseract_exception_wrapped_in_ocr_error(
294
+ self, mock_image_to_string: MagicMock,
295
+ mock_image_open: MagicMock,
296
+ tmp_path: Path,
297
+ ) -> None:
298
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
299
+ mock_image_to_string.side_effect = RuntimeError("Tesseract crashed")
300
+ adapter = TesseractAdapter()
301
+ image_path = self._create_dummy_image(tmp_path)
302
+ artifact = _make_image_artifact(str(image_path))
303
+
304
+ with pytest.raises(OCRAdapterError, match="RuntimeError.*Tesseract crashed"):
305
+ adapter.execute(
306
+ inputs={ArtifactType.IMAGE: artifact},
307
+ params={},
308
+ context=_make_context(),
309
+ )
310
+
311
+ def test_pytesseract_not_installed_raises_clean_error(
312
+ self, tmp_path: Path,
313
+ ) -> None:
314
+ """Si pytesseract est introuvable, l'erreur est claire et
315
+ propose une commande pip."""
316
+ adapter = TesseractAdapter()
317
+ image_path = self._create_dummy_image(tmp_path)
318
+ artifact = _make_image_artifact(str(image_path))
319
+
320
+ # Simule que pytesseract est absent.
321
+ with patch.dict(sys.modules, {"pytesseract": None}):
322
+ with pytest.raises(
323
+ OCRAdapterError, match="pytesseract.*pip install",
324
+ ):
325
+ adapter.execute(
326
+ inputs={ArtifactType.IMAGE: artifact},
327
+ params={},
328
+ context=_make_context(),
329
+ )
330
+
331
+ @patch("PIL.Image.open")
332
+ @patch("pytesseract.image_to_string")
333
+ def test_artifact_id_uses_adapter_name(
334
+ self, mock_image_to_string: MagicMock,
335
+ mock_image_open: MagicMock,
336
+ tmp_path: Path,
337
+ ) -> None:
338
+ mock_image_to_string.return_value = "x"
339
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
340
+ adapter = TesseractAdapter(name="custom_name")
341
+ image_path = self._create_dummy_image(tmp_path)
342
+ artifact = _make_image_artifact(str(image_path))
343
+
344
+ result = adapter.execute(
345
+ inputs={ArtifactType.IMAGE: artifact},
346
+ params={},
347
+ context=_make_context(),
348
+ )
349
+ produced = result[ArtifactType.RAW_TEXT]
350
+ assert produced.id == "d1:custom_name:raw_text"
351
+ assert produced.document_id == "d1"
352
+ assert produced.produced_by_step == "ocr"
353
+
354
+ @patch("PIL.Image.open")
355
+ @patch("pytesseract.image_to_string")
356
+ def test_text_is_stripped(
357
+ self, mock_image_to_string: MagicMock,
358
+ mock_image_open: MagicMock,
359
+ tmp_path: Path,
360
+ ) -> None:
361
+ """Le texte est strippé des whitespaces extérieurs comme dans
362
+ le legacy."""
363
+ mock_image_to_string.return_value = " \n\nHello world\n\n "
364
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
365
+ adapter = TesseractAdapter()
366
+ image_path = self._create_dummy_image(tmp_path)
367
+ artifact = _make_image_artifact(str(image_path))
368
+
369
+ result = adapter.execute(
370
+ inputs={ArtifactType.IMAGE: artifact},
371
+ params={},
372
+ context=_make_context(),
373
+ )
374
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
375
+ encoding="utf-8",
376
+ )
377
+ assert out_text == "Hello world"