fix(redact): masquer tokens collés à ponctuation ("Douar,nécessitant")

Fuite détectée lors du QC batch 22 : le nom "Douar" était dans l'audit
(NOM page 6) mais restait visible dans le PDF redacted_vector. Cause :
dans get_text('words') le word était 'Douar,nécessitant' (virgule collée
sans espace). _search_whole_word faisait un == strict après strip des
ponctuations frontières, mais la virgule était au MILIEU — pas stripée.
→ aucun match → aucun rectangle → fuite.

Fix : passe 2 dans _search_whole_word avec regex word-boundary sur le
texte complet du word (pattern `(?<![A-Za-zÀ-ÿ])token(?![A-Za-zÀ-ÿ])`)
+ bbox proportionnelle au ratio chars matched / chars total du word.
Approximation exacte sur polices monospace, précision ±pixels sur
polices proportionnelles — couverte par le rectangle de redaction.

Validation bout-en-bout sur trackare-BA042686-23090597 : "Douar" masqué
(0 page résiduelle). QC strict retombe de 1 anomalie à 0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 14:10:34 +02:00
parent ea214db170
commit e2e2a7c8e3

View File

@@ -3929,15 +3929,42 @@ def _search_ocr_words(ocr_words: List[Tuple[str, float, float, float, float]], t
def _search_whole_word(page, token: str) -> list: def _search_whole_word(page, token: str) -> list:
"""Cherche un token comme mot entier (pas substring) via get_text('words'). """Cherche un token comme mot entier (pas substring) via get_text('words').
Évite les faux positifs de page.search_for() qui fait du substring matching. Évite les faux positifs de page.search_for() qui fait du substring matching.
Gère les noms composés (JEAN-PIERRE) qui peuvent être splittés par le PDF.""" Gère les noms composés (JEAN-PIERRE) qui peuvent être splittés par le PDF.
Gère aussi les tokens collés à de la ponctuation sans espace : "Douar,nécessitant"
(passe 2 avec regex word-boundary + bbox proportionnelle)."""
rects = [] rects = []
token_lower = token.lower().strip() token_lower = token.lower().strip()
words = page.get_text("words") words = page.get_text("words")
# Passe 1 : comparaison stricte après strip des ponctuations frontières.
# Couvre la majorité des cas normaux (mots bien séparés).
for w in words: for w in words:
# w = (x0, y0, x1, y1, word, block_no, line_no, word_no) # w = (x0, y0, x1, y1, word, block_no, line_no, word_no)
word_text = w[4].strip(".,;:!?()[]{}\"'«»-–—/\\") word_text = w[4].strip(".,;:!?()[]{}\"'«»-–—/\\")
if word_text.lower() == token_lower: if word_text.lower() == token_lower:
rects.append(fitz.Rect(w[0], w[1], w[2], w[3])) rects.append(fitz.Rect(w[0], w[1], w[2], w[3]))
# Passe 2 : token collé à d'autres mots via ponctuation interne.
# Ex: "Douar,nécessitant" où "Douar" doit être masqué. Le strip ci-dessus
# ne marche pas (la virgule est au milieu). Utiliser regex word-boundary
# sur le texte complet du word, calculer bbox proportionnelle.
if not rects and len(token_lower) >= 3:
pattern = re.compile(
r"(?<![A-Za-zÀ-ÿ])" + re.escape(token_lower) + r"(?![A-Za-zÀ-ÿ])",
re.IGNORECASE,
)
for w in words:
word_text = w[4]
if len(word_text) < len(token_lower):
continue
for m in pattern.finditer(word_text):
# Bbox proportionnelle : approximation pour polices proportionnelles,
# exacte pour chasses fixes. Marge de quelques pixels couverte par
# le rectangle de redaction.
wlen = len(word_text)
start_ratio = m.start() / wlen
end_ratio = m.end() / wlen
x0 = w[0] + (w[2] - w[0]) * start_ratio
x1 = w[0] + (w[2] - w[0]) * end_ratio
rects.append(fitz.Rect(x0, w[1], x1, w[3]))
# Fallback pour noms composés avec tiret (JEAN-PIERRE) splittés par le PDF # Fallback pour noms composés avec tiret (JEAN-PIERRE) splittés par le PDF
if not rects and "-" in token: if not rects and "-" in token:
parts = [p for p in token.split("-") if p] parts = [p for p in token.split("-") if p]