File size: 11,215 Bytes
84ced9b
 
 
 
 
 
 
 
 
bbbfba8
84ced9b
 
 
 
bbbfba8
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
 
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
 
84ced9b
 
 
 
 
 
bbbfba8
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbbfba8
84ced9b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
"""End-to-end tests with all fixture types (§28.5).

Each fixture represents a different document scenario and is run through
the full pipeline: raw → normalize → enrich → validate → ALTO + PAGE + viewer.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

from lxml import etree
from src.app.domain.models import CanonicalDocument, RawProviderPayload
from src.app.domain.models.geometry import GeometryContext
from src.app.domain.models.status import GeometryStatus
from src.app.enrichers import EnricherPipeline
from src.app.enrichers.bbox_repair_light import BboxRepairLightEnricher
from src.app.enrichers.hyphenation_basic import HyphenationBasicEnricher
from src.app.enrichers.lang_propagation import LangPropagationEnricher
from src.app.enrichers.reading_order_simple import ReadingOrderSimpleEnricher
from src.app.enrichers.text_consistency import TextConsistencyEnricher
from src.app.normalization.pipeline import normalize
from src.app.policies.document_policy import DocumentPolicy
from src.app.policies.export_policy import check_alto_export, check_page_export
from src.app.serializers.alto_xml import ALTO_NS, serialize_alto
from src.app.serializers.page_xml import PAGE_NS, serialize_page_xml
from src.app.validators.export_eligibility_validator import compute_export_eligibility
from src.app.validators.structural_validator import validate_structure
from src.app.viewer.projection_builder import build_projection

if TYPE_CHECKING:
    from pathlib import Path

GEO_CTX = GeometryContext(source_width=1000, source_height=800)

ENRICHER_PIPELINE = EnricherPipeline([
    BboxRepairLightEnricher(),
    LangPropagationEnricher(),
    ReadingOrderSimpleEnricher(),
    HyphenationBasicEnricher(),
    TextConsistencyEnricher(),
])


def _run_paddle_pipeline(fixture_name: str, fixtures_dir: Path) -> tuple[
    CanonicalDocument, bytes, bytes, dict
]:
    """Full pipeline for a PaddleOCR-format fixture."""
    with open(fixtures_dir / fixture_name) as f:
        payload = json.load(f)

    raw = RawProviderPayload(
        provider_id="paddleocr", adapter_id="v1", runtime_type="local",
        payload=payload, image_width=1000, image_height=800,
    )
    doc = normalize(raw, "word_box_json", GEO_CTX, document_id=f"test_{fixture_name}")
    doc = ENRICHER_PIPELINE.run(doc, DocumentPolicy())

    validate_structure(doc, bbox_tolerance=5.0)
    eligibility = compute_export_eligibility(doc)

    alto_bytes = serialize_alto(doc)
    page_bytes = serialize_page_xml(doc)
    vp = build_projection(doc, export_status=eligibility)

    return doc, alto_bytes, page_bytes, vp.model_dump(mode="json")


# -- Simple page (paddle_ocr_sample.json) ------------------------------------


class TestSimplePage:
    def test_full_pipeline(self, fixtures_dir: Path) -> None:
        doc, alto, page, viewer = _run_paddle_pipeline("paddle_ocr_sample.json", fixtures_dir)
        assert len(doc.pages[0].text_regions) >= 1
        assert b"Bonjour" in alto
        assert b"Bonjour" in page
        assert len(viewer["word_overlays"]) == 5

    def test_alto_valid_structure(self, fixtures_dir: Path) -> None:
        _, alto, _, _ = _run_paddle_pipeline("paddle_ocr_sample.json", fixtures_dir)
        root = etree.fromstring(alto)
        strings = root.findall(f".//{{{ALTO_NS}}}String")
        assert all(s.get("HPOS").isdigit() for s in strings)
        assert all(s.get("CONTENT") for s in strings)

    def test_page_has_reading_order(self, fixtures_dir: Path) -> None:
        _, _, page, _ = _run_paddle_pipeline("paddle_ocr_sample.json", fixtures_dir)
        root = etree.fromstring(page)
        refs = root.findall(f".//{{{PAGE_NS}}}RegionRefIndexed")
        assert len(refs) >= 1


# -- Double column -----------------------------------------------------------


class TestDoubleColumn:
    def test_four_items(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("double_column.json", fixtures_dir)
        words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
        assert len(words) == 4

    def test_reading_order_inferred(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("double_column.json", fixtures_dir)
        assert doc.pages[0].reading_order  # enricher should have set it

    def test_dual_export(self, fixtures_dir: Path) -> None:
        _, alto, page, _ = _run_paddle_pipeline("double_column.json", fixtures_dir)
        alto_root = etree.fromstring(alto)
        page_root = etree.fromstring(page)
        assert len(alto_root.findall(f".//{{{ALTO_NS}}}String")) == 4
        assert len(page_root.findall(f".//{{{PAGE_NS}}}Word")) == 4


# -- Noisy page --------------------------------------------------------------


class TestNoisyPage:
    def test_handles_negative_coords(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
        # bbox_repair_light should clip negative coords
        for r in doc.pages[0].text_regions:
            for ln in r.lines:
                for w in ln.words:
                    x, y, _, _ = w.geometry.bbox
                    assert x >= 0
                    assert y >= 0

    def test_low_confidence_preserved(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
        words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
        confs = [w.confidence for w in words if w.confidence is not None]
        assert any(c < 0.5 for c in confs)

    def test_structural_validation(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
        report = validate_structure(doc, bbox_tolerance=5.0)
        # After repair, structural issues should be minimized
        assert report.error_count == 0

    def test_viewer_has_all_words(self, fixtures_dir: Path) -> None:
        _, _, _, viewer = _run_paddle_pipeline("noisy_page.json", fixtures_dir)
        assert len(viewer["word_overlays"]) == 4


# -- Title + body ------------------------------------------------------------


class TestTitleBody:
    def test_four_items(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("title_body.json", fixtures_dir)
        words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
        assert len(words) == 4

    def test_alto_all_strings(self, fixtures_dir: Path) -> None:
        _, alto, _, _ = _run_paddle_pipeline("title_body.json", fixtures_dir)
        root = etree.fromstring(alto)
        strings = root.findall(f".//{{{ALTO_NS}}}String")
        assert any("Titre" in s.get("CONTENT", "") for s in strings)

    def test_page_all_words(self, fixtures_dir: Path) -> None:
        _, _, page, _ = _run_paddle_pipeline("title_body.json", fixtures_dir)
        root = etree.fromstring(page)
        words = root.findall(f".//{{{PAGE_NS}}}Word")
        assert len(words) == 4


# -- Hyphenation -------------------------------------------------------------


class TestHyphenationFixture:
    def test_hyphenation_detected(self, fixtures_dir: Path) -> None:
        doc, _, _, _ = _run_paddle_pipeline("hyphenation_sample.json", fixtures_dir)
        words = [w for r in doc.pages[0].text_regions for ln in r.lines for w in ln.words]
        hyph_words = [w for w in words if w.hyphenation is not None and w.hyphenation.is_hyphenated]
        assert len(hyph_words) == 2
        assert hyph_words[0].hyphenation.full_form == "patrimoine"
        assert hyph_words[0].hyphenation.part == 1
        assert hyph_words[1].hyphenation.part == 2

    def test_alto_hyphenation(self, fixtures_dir: Path) -> None:
        _, alto, _, _ = _run_paddle_pipeline("hyphenation_sample.json", fixtures_dir)
        root = etree.fromstring(alto)
        strings = root.findall(f".//{{{ALTO_NS}}}String")
        hyp_strings = [s for s in strings if s.get("SUBS_TYPE")]
        assert len(hyp_strings) == 2
        assert hyp_strings[0].get("SUBS_TYPE") == "HypPart1"
        assert hyp_strings[0].get("SUBS_CONTENT") == "patrimoine"


# -- Text only (no geometry) -------------------------------------------------


class TestTextOnlyFixture:
    def test_produces_document(self, fixtures_dir: Path) -> None:
        with open(fixtures_dir / "text_only_blocks.json") as f:
            payload = json.load(f)

        raw = RawProviderPayload(
            provider_id="qwen", adapter_id="v1", runtime_type="api",
            payload=payload, image_width=1000, image_height=800,
        )
        doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")

        assert len(doc.pages[0].text_regions) == 3

    def test_geometry_is_unknown(self, fixtures_dir: Path) -> None:
        with open(fixtures_dir / "text_only_blocks.json") as f:
            payload = json.load(f)

        raw = RawProviderPayload(
            provider_id="qwen", adapter_id="v1", runtime_type="api",
            payload=payload, image_width=1000, image_height=800,
        )
        doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")

        word = doc.pages[0].text_regions[0].lines[0].words[0]
        assert word.geometry.status == GeometryStatus.UNKNOWN

    def test_alto_refused(self, fixtures_dir: Path) -> None:
        with open(fixtures_dir / "text_only_blocks.json") as f:
            payload = json.load(f)

        raw = RawProviderPayload(
            provider_id="qwen", adapter_id="v1", runtime_type="api",
            payload=payload, image_width=1000, image_height=800,
        )
        doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")

        eligibility = compute_export_eligibility(doc)
        decision = check_alto_export(eligibility)
        assert decision.allowed is False

    def test_page_export_possible(self, fixtures_dir: Path) -> None:
        with open(fixtures_dir / "text_only_blocks.json") as f:
            payload = json.load(f)

        raw = RawProviderPayload(
            provider_id="qwen", adapter_id="v1", runtime_type="api",
            payload=payload, image_width=1000, image_height=800,
        )
        doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")

        eligibility = compute_export_eligibility(doc)
        # PAGE is more lenient — may still be exportable
        page_decision = check_page_export(eligibility)
        # Even if refused, it should give a clear reason
        assert page_decision.reason

    def test_viewer_renders_degraded(self, fixtures_dir: Path) -> None:
        with open(fixtures_dir / "text_only_blocks.json") as f:
            payload = json.load(f)

        raw = RawProviderPayload(
            provider_id="qwen", adapter_id="v1", runtime_type="api",
            payload=payload, image_width=1000, image_height=800,
        )
        doc = normalize(raw, "text_only", GEO_CTX, document_id="text_test")
        eligibility = compute_export_eligibility(doc)

        vp = build_projection(doc, export_status=eligibility)
        # Should still have overlays even with unknown geometry
        assert len(vp.word_overlays) > 0