diff --git a/anonymizer_core_refactored_onnx.py b/anonymizer_core_refactored_onnx.py index c796c15..fd22cf5 100644 --- a/anonymizer_core_refactored_onnx.py +++ b/anonymizer_core_refactored_onnx.py @@ -4812,9 +4812,14 @@ def _apply_pseudo_xmp_metadata(doc) -> None: pass -def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, ocr_word_map: OcrWordMap = None) -> None: +def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, ocr_word_map: OcrWordMap = None, disabled_kinds: Optional[Set[str]] = None) -> None: if fitz is None: raise RuntimeError("PyMuPDF non disponible – installez pymupdf.") + # Plan 1b (P1-2/F-3) — `disabled_kinds` = set des CATÉGORIES décochées + # (les 7 toggles). Sert UNIQUEMENT à gater les chemins de burn qui ne + # dérivent PAS de l'audit (le filtre audit de la Task 1 couvre déjà tout le + # reste). Vide/None ⇒ comportement par défaut byte-for-byte (non-régression). + disabled = disabled_kinds or set() doc = fitz.open(str(original_pdf)) # index hits par page; page==-1 → rechercher sur toutes pages by_page: Dict[int, List[PiiHit]] = {} @@ -4822,6 +4827,14 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc by_page.setdefault(h.page, []).append(h) # Kinds à ne pas chercher dans le PDF (dates masquées uniquement dans le texte, # pas dans le PDF où elles rendent les tableaux illisibles) + # Plan 1b (P1-2/F-3) — _VECTOR_SKIP_KINDS : exclusion TOUJOURS appliquée + # (indépendante des toggles). Les dates EDS et la propagation globale DDN ne + # sont jamais brûlées dans le PDF (elles rendraient les tableaux illisibles), + # seulement masquées dans le texte. C'est un RETRAIT du burn, jamais un ajout : + # il ne peut donc pas réintroduire une catégorie décochée (déjà retirée de + # l'audit par _filter_audit_by_disabled / Task 1) ni faire brûler une catégorie + # cochée absente de ce set. Aucun conflit avec le toggle DATE_NAISSANCE : + # activer DATE_NAISSANCE ne fait PAS brûler ces kinds dans le PDF, c'est voulu. _VECTOR_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"} # Kinds sensibles au substring matching : utiliser _search_whole_word _VECTOR_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM", @@ -4830,7 +4843,10 @@ def redact_pdf_vector(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, oc for pno in range(len(doc)): page = doc[pno] hits = by_page.get(pno, []) + by_page.get(-1, []) - all_rects = _search_pdf_address_lines(page) + # Plan 1b (P1-2/F-3) — Chemin de burn INDÉPENDANT de l'audit : recherche + # géométrique des lignes d'adresse. Le filtre audit (Task 1) ne le couvre + # pas, donc on le gate explicitement sous la catégorie ADRESSE. + all_rects = [] if "ADRESSE" in disabled else _search_pdf_address_lines(page) if not hits and not all_rects: continue # Dédupliquer les tokens : (token, kind) → rechercher une seule fois par page @@ -4929,6 +4945,13 @@ def _rasterize_page(args): if rx1 > rx0: draw.rectangle([rx0, ry0, rx1, ry1], fill=(0, 0, 0)) # Noircir les images embarquées (logos, signatures, captures d'écran) + # Plan 1b (P1-2/F-3) — Choix DÉLIBÉRÉ : les blackouts d'images et de + # codes-barres/QR (ci-dessous) restent TOUJOURS actifs, NON toggleables. + # Une image ou un code-barres peut encoder n'importe quelle PII (nom dans un + # logo signé, NIR/IPP dans un code-barres). Ces masques sont conservateurs : + # ils peuvent sur-masquer, mais ne doivent JAMAIS laisser fuir. Les gater + # sous une catégorie (NOM/ADRESSE/…) serait impossible à décider de façon + # sûre depuis la seule géométrie → on les garde inconditionnels. for (x0, y0, x1, y1) in image_rects_tuples: rx0 = x0 * zoom ry0 = y0 * zoom @@ -4976,11 +4999,18 @@ def _rasterize_page(args): return pno, buf.getvalue(), rect_w, rect_h -def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dpi: int = 120, ogc_label: Optional[str] = None, ocr_word_map: OcrWordMap = None, jpeg_quality: int = 80) -> None: +def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dpi: int = 120, ogc_label: Optional[str] = None, ocr_word_map: OcrWordMap = None, jpeg_quality: int = 80, disabled_kinds: Optional[Set[str]] = None) -> None: if fitz is None: raise RuntimeError("PyMuPDF non disponible – installez pymupdf.") + # Plan 1b (P1-2/F-3) — voir redact_pdf_vector : `disabled_kinds` gate les + # chemins de burn hors-audit. Vide/None ⇒ comportement par défaut. + disabled = disabled_kinds or set() doc = fitz.open(str(original_pdf)) all_rects: Dict[int, List["fitz.Rect"]] = {} + # Plan 1b (P1-2/F-3) — _RASTER_SKIP_KINDS : exclusion TOUJOURS appliquée + # (cf. _VECTOR_SKIP_KINDS). Retrait du burn uniquement, jamais d'ajout : + # aucun conflit avec le toggle DATE_NAISSANCE (ces kinds restent hors PDF + # quel que soit l'état du toggle, c'est voulu — dates illisibles en tableau). _RASTER_SKIP_KINDS = {"EDS_DATE", "EDS_DATE_NAISSANCE", "EDS_SECU", "EDS_TEL", "DATE_NAISSANCE_GLOBAL"} # Kinds sensibles au substring matching : utiliser _search_whole_word _RASTER_WHOLEWORD_KINDS = {"NOM", "NOM_GLOBAL", "NOM_EXTRACTED", "EDS_NOM", "EDS_PRENOM", @@ -5002,7 +5032,9 @@ def redact_pdf_raster(original_pdf: Path, audit: List[PiiHit], out_pdf: Path, dp rects.append(fitz.Rect(margin, margin, page.rect.width - margin, page.rect.height - margin)) all_rects[pno] = rects continue - rects = _search_pdf_address_lines(page) + # Plan 1b (P1-2/F-3) — Chemin de burn INDÉPENDANT de l'audit (recherche + # géométrique d'adresse), gaté sous la catégorie ADRESSE comme en vector. + rects = [] if "ADRESSE" in disabled else _search_pdf_address_lines(page) for h in hits: token = h.original.strip() if not token or h.kind in _RASTER_SKIP_KINDS: @@ -5867,7 +5899,7 @@ def process_pdf( if make_vector_redaction and fitz is not None: vec_path = out_dir / f"{base}.redacted_vector.pdf" try: - redact_pdf_vector(pdf_path, anon.audit, vec_path, ocr_word_map=ocr_word_map) + redact_pdf_vector(pdf_path, anon.audit, vec_path, ocr_word_map=ocr_word_map, disabled_kinds=_disabled) outputs["pdf_vector"] = str(vec_path) _perf_mark("pdf_vector") except Exception as e: @@ -5883,6 +5915,7 @@ def process_pdf( redact_pdf_raster( pdf_path, anon.audit, ras_fb_path, ogc_label=ogc_label, ocr_word_map=ocr_word_map, + disabled_kinds=_disabled, ) outputs["pdf_raster"] = str(ras_fb_path) raster_fallback_ok = True @@ -5924,7 +5957,7 @@ def process_pdf( # S5 : ne pas refaire le raster si le fallback vector→raster l'a déjà produit if "pdf_raster" not in outputs: ras_path = out_dir / f"{base}.redacted_raster.pdf" - redact_pdf_raster(pdf_path, anon.audit, ras_path, ogc_label=ogc_label, ocr_word_map=ocr_word_map) + redact_pdf_raster(pdf_path, anon.audit, ras_path, ogc_label=ogc_label, ocr_word_map=ocr_word_map, disabled_kinds=_disabled) outputs["pdf_raster"] = str(ras_path) _perf_mark("pdf_raster") log.info("PERF %s: done total=%.2fs outputs=%s", pdf_path.name, time.perf_counter() - perf_t0, sorted(outputs.keys())) diff --git a/tests/unit/test_core_address_burn_guard.py b/tests/unit/test_core_address_burn_guard.py new file mode 100644 index 0000000..517ca4a --- /dev/null +++ b/tests/unit/test_core_address_burn_guard.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Plan 1b — Task 4 (P1-2/F-3) : garde-fou du burn adresse géométrique. + +`_search_pdf_address_lines` est un chemin de caviardage INDÉPENDANT de +l'audit : il noircit directement les lignes d'adresse trouvées +géométriquement sur la page (cf. `test_pdf_redaction_directly_masks_finess_address_range`). +Le filtre d'audit de la Task 1 ne le couvre donc PAS. + +Ces tests vérifient que ce chemin est gaté sous la catégorie ADRESSE : +- ADRESSE désactivée → `_search_pdf_address_lines` n'est PAS appliqué ; +- ADRESSE activée (ou disabled vide) → il est appelé comme avant. +""" +import anonymizer_core_refactored_onnx as core +from anonymizer_core_refactored_onnx import ( + PiiHit, + fitz, + redact_pdf_raster, + redact_pdf_vector, +) + + +def _make_address_pdf(tmp_path): + source = tmp_path / "addr.pdf" + doc = fitz.open() + page = doc.new_page() + page.insert_text((72, 72), "15 à 35 rue Claude Boucher Bordeaux Cedex") + page.insert_text((72, 108), "Motif d'hospitalisation : contrôle clinique.") + doc.save(source) + doc.close() + return source + + +# --------------------------------------------------------------------------- +# VECTOR +# --------------------------------------------------------------------------- +def test_vector_address_search_called_when_adresse_enabled(tmp_path, monkeypatch): + if fitz is None: + return + source = _make_address_pdf(tmp_path) + output = tmp_path / "addr.enabled.pdf" + + calls = [] + real = core._search_pdf_address_lines + + def _spy(page): + calls.append(page.number) + return real(page) + + monkeypatch.setattr(core, "_search_pdf_address_lines", _spy) + + # disabled vide → comportement par défaut (adresse cherchée) + redact_pdf_vector(source, [], output, disabled_kinds=set()) + + assert calls, "ADRESSE activée : _search_pdf_address_lines doit être appelé" + redacted = fitz.open(output) + text = redacted[0].get_text() + redacted.close() + # L'adresse a bien été caviardée (le burn géométrique s'applique) + assert "rue Claude Boucher" not in text + # La ligne clinique reste lisible + assert "Motif d'hospitalisation" in text + + +def test_vector_address_search_not_applied_when_adresse_disabled(tmp_path, monkeypatch): + if fitz is None: + return + source = _make_address_pdf(tmp_path) + output = tmp_path / "addr.disabled.pdf" + + calls = [] + real = core._search_pdf_address_lines + + def _spy(page): + calls.append(page.number) + return real(page) + + monkeypatch.setattr(core, "_search_pdf_address_lines", _spy) + + redact_pdf_vector(source, [], output, disabled_kinds={"ADRESSE"}) + + # Le burn géométrique d'adresse ne doit PAS être appliqué. + assert not calls, ( + "ADRESSE désactivée : _search_pdf_address_lines ne doit pas être appliqué" + ) + redacted = fitz.open(output) + text = redacted[0].get_text() + redacted.close() + # L'adresse reste lisible puisque la catégorie est décochée. + assert "rue Claude Boucher" in text + + +# --------------------------------------------------------------------------- +# RASTER +# --------------------------------------------------------------------------- +def test_raster_address_search_called_when_adresse_enabled(tmp_path, monkeypatch): + if fitz is None: + return + source = _make_address_pdf(tmp_path) + output = tmp_path / "addr.raster.enabled.pdf" + + calls = [] + real = core._search_pdf_address_lines + + def _spy(page): + calls.append(page.number) + return real(page) + + monkeypatch.setattr(core, "_search_pdf_address_lines", _spy) + + redact_pdf_raster(source, [], output, disabled_kinds=set()) + + assert calls, "ADRESSE activée (raster) : _search_pdf_address_lines doit être appelé" + + +def test_raster_address_search_not_applied_when_adresse_disabled(tmp_path, monkeypatch): + if fitz is None: + return + source = _make_address_pdf(tmp_path) + output = tmp_path / "addr.raster.disabled.pdf" + + calls = [] + real = core._search_pdf_address_lines + + def _spy(page): + calls.append(page.number) + return real(page) + + monkeypatch.setattr(core, "_search_pdf_address_lines", _spy) + + redact_pdf_raster(source, [], output, disabled_kinds={"ADRESSE"}) + + assert not calls, ( + "ADRESSE désactivée (raster) : _search_pdf_address_lines ne doit pas être appliqué" + ) + + +# --------------------------------------------------------------------------- +# Non-régression : signature positionnelle d'origine + défaut byte-for-byte +# --------------------------------------------------------------------------- +def test_vector_default_signature_still_calls_address_search(tmp_path, monkeypatch): + """Sans disabled_kinds (appel positionnel d'origine), le burn adresse + reste actif — non-régression stricte.""" + if fitz is None: + return + source = _make_address_pdf(tmp_path) + output = tmp_path / "addr.default.pdf" + + calls = [] + real = core._search_pdf_address_lines + + def _spy(page): + calls.append(page.number) + return real(page) + + monkeypatch.setattr(core, "_search_pdf_address_lines", _spy) + + # Appel d'origine : aucun argument disabled. + redact_pdf_vector(source, [PiiHit(0, "OGC", "14", "[OGC]")], output) + + assert calls, "Défaut (pas de disabled) : burn adresse doit rester actif"