""" post_processor.py — Post-traitement OCR spécialisé documents métier. Complémentaire à ocr_cleaner.py (qui gère arabic_norm, fuzzy FR/AR). Ce module cible : - Vocabulaire factures / devis / bons de commande (FR + EN) - Confusions caractères typiques petits fonts : l↔1, O↔0, rn↔m, etc. - Mots fusionnés : 'Nameand' → 'Name and', 'TotalAmount' → 'Total Amount' - Patterns numériques : montants, dates, numéros de facture - Reconstruction de lignes adaptative (seuil dynamique / colonnes) Coordonnées attendues : format LayoutXLM [x1, y1, x2, y2] normalisé 0-1000. """ import re import logging from typing import List, Dict, Tuple, Optional import numpy as np logger = logging.getLogger(__name__) try: from rapidfuzz import process as _rfuzz, fuzz as _fuzz _HAS_RAPIDFUZZ = True except ImportError: _HAS_RAPIDFUZZ = False # ───────────────────────────────────────────────────────────────────────────── # Vocabulaire domaine (FR + EN) # ───────────────────────────────────────────────────────────────────────────── INVOICE_VOCAB: Dict[str, str] = { # ── Titres document ───────────────────────────────────────────────────── "Involce": "Invoice", "lnvoice": "Invoice", "Inv0ice": "Invoice", "Invoce": "Invoice", "Invo1ce": "Invoice", "Invo ice": "Invoice", "INVOLCE": "INVOICE", "LNVOICE": "INVOICE", "Fachre": "Facture", "Factnre": "Facture", "Facure": "Facture", "Rec eipt": "Receipt", "Rece1pt": "Receipt", "Recei pt": "Receipt", "Rec3ipt": "Receipt", "Rec3pt": "Receipt", "Quotat1on": "Quotation", "Quotat ion": "Quotation", "Pu rchase": "Purchase", "Purchas3": "Purchase", # ── Champs courants ───────────────────────────────────────────────────── "Dat3": "Date", "Oate": "Date", "Date:": "Date:", "Nurnber": "Number", "Num8er": "Number", "Nurnbers": "Numbers", "Arnount": "Amount", "Arnounts": "Amounts", "Am0unt": "Amount", "Ouantity": "Quantity", "Qnantity": "Quantity", "Qty.": "Qty.", "Descnption": "Description", "Descr1ption": "Description", "Payrnent": "Payment", "Payement": "Payment", "Paym3nt": "Payment", "T0tal": "Total", "T0TAL": "TOTAL", "Tota1": "Total", "Subtota1": "Subtotal", "Sub-tota1": "Subtotal", "Sub total": "Subtotal", "Custorner": "Customer", "Cust0mer": "Customer", "Supplrer": "Supplier", "Supp1ier": "Supplier", "Conpany": "Company", "Cornpany": "Company", "Compamy": "Company", "Adress": "Address", "Adrress": "Address", "Addres": "Address", "Teleph0ne": "Telephone", "Te1ephone": "Telephone", "Ernail": "Email", "Erna1l": "Email", "Ternis": "Terms", "Teirms": "Terms", "Disc0unt": "Discount", "D1scount": "Discount", "Sh1pping": "Shipping", "Sh ipping": "Shipping", "Acco unt": "Account", "Acc0unt": "Account", "ltern": "Item", "ltems": "Items", "Pr1ce": "Price", "Un1t": "Unit", "De1ivery": "Delivery", "Del1very": "Delivery", "lncludes": "Includes", "lncluding": "Including", "S1gnature": "Signature", # ── Abréviations / tokens courts ──────────────────────────────────────── "lnv": "Inv", "Ref.": "Ref.", "T.V.A": "T.V.A", "V.A.T": "V.A.T", "H.T": "H.T", "T.T.C": "T.T.C", "N/A": "N/A", "n/a": "n/a", # ── Telecom / Utilities (Vodacom, MTN, Orange…) ────────────────────────── "Ivoice": "Invoice", "lvoice": "Invoice", "Invoi ce": "Invoice", "Celphone": "Cellphone", "Ce1lphone": "Cellphone", "Cell phone":"Cellphone", "Moblle": "Mobile", "Mob1le": "Mobile", "M0bile": "Mobile", "Serv1ce": "Service", "Serv ice": "Service", "Serv!ce": "Service", "Subscr1ption":"Subscription", "Subscript1on":"Subscription", "Roam1ng": "Roaming", "R0aming": "Roaming", "Bund1e": "Bundle", "Bund le": "Bundle", "Bal ance": "Balance", "Ba1ance": "Balance", "Ba1ance:": "Balance:", "0utstanding":"Outstanding","Outstand1ng":"Outstanding", "Curr ent": "Current", "C0nnection": "Connection", "Contr act": "Contract", "Contra ct": "Contract", "Acc0unt": "Account", "Accc ount": "Account", "lnclusive": "Inclusive", "lnc1usive": "Inclusive", "Vaild": "Valid", "Va1id": "Valid", "Va|id": "Valid", "Unt1l": "Until", "Unti1": "Until", "Voda com": "Vodacom", "V0dacom": "Vodacom", "Vodac0m": "Vodacom", "0range": "Orange", "0rance": "Orange", "Oper ator": "Operator", "0perator": "Operator", "Plan": "Plan", "Ta riff": "Tariff", "Tar1ff": "Tariff", "Usag e": "Usage", "Us age": "Usage", "lnternet": "Internet", "lntemet": "Internet", "Ca11": "Call", "Ca1l": "Call", "SM S": "SMS", "MMS": "MMS", "G8": "GB", "M8": "MB", "K8": "KB", } # ───────────────────────────────────────────────────────────────────────────── # Prefixes connus pour la détection de mots fusionnés # ───────────────────────────────────────────────────────────────────────────── _KNOWN_PREFIXES = sorted([ "Total", "Subtotal", "Sub", "Tax", "Net", "Gross", "Unit", "Item", "Line", "Date", "Due", "Pay", "Ship", "Bill", "Name", "Last", "First", "Order", "Invoice", "Facture", "Reference", "Number", "Amount", "Price", "Quantity", "Description", "Delivery", "Account", "Customer", "Supplier", "Company", "Discount", "Signature", "Payment", "Address", "Email", "Phone", "From", "To", "And", "Or", "Per", "For", "With", ], key=len, reverse=True) _PREFIX_RE = re.compile( r'(' + '|'.join(re.escape(p) for p in _KNOWN_PREFIXES) + r')([A-Z][a-z])', re.IGNORECASE, ) # ───────────────────────────────────────────────────────────────────────────── # Patterns regex pour documents métier # ───────────────────────────────────────────────────────────────────────────── _REGEX_PATTERNS: List[Tuple] = [ # Dates : 01/0l/2024 ou 0l.01.2024 → corriger l→1, O→0 (re.compile(r'\b(\d{1,2})[./]([Ol\d]{2})[./](\d{2,4})\b'), lambda m: m.group(0).replace('l', '1').replace('O', '0')), # Numéros de facture : INV-OO1 → INV-001 (re.compile(r'\b(INV|FAC|BON|DEV|REF|NO|N°)[.\-/]?([A-Z0-9Ol\-]+)\b', re.I), lambda m: m.group(0).replace('O', '0').replace('l', '1')), # Montants : suppression espace intrusif (1 2.34 → 12.34) dans contexte numérique (re.compile(r'\b(\d+)\s(\d{2,3})[,.](\d{2})\b'), r'\1\2.\3'), # Pourcentages : l8% → 18%, O% → 0% (re.compile(r'\b([Ol\d]+)\s*%'), lambda m: m.group(0).replace('O', '0').replace('l', '1')), # TVA / VAT : TVA l9% → TVA 19% (re.compile(r'(TVA|VAT|TPS|GST|TAX)\s*:?\s*([Ol\d,. ]+%)', re.I), lambda m: m.group(0).replace('O', '0').replace('l', '1')), # Ligatures typographiques (re.compile('fi'), 'fi'), (re.compile('fl'), 'fl'), (re.compile('ff'), 'ff'), (re.compile('\u00a0'), ' '), # espace insécable → espace normal ] # ───────────────────────────────────────────────────────────────────────────── # Correction confusions G/O dans contexte numérique (police Vodacom et autres) # ───────────────────────────────────────────────────────────────────────────── # Caractères alpha fréquemment confondus avec des chiffres par l'OCR # sur les polices télécom à empattement (Vodacom, MTN…). _NUMERIC_CHAR_SUBS: Dict[str, str] = { 'O': '0', # Lettre O → zéro (confusion la plus fréquente) 'G': '0', # Lettre G → zéro (spécifique polices Vodacom) 'l': '1', # lettre l minuscule → un 'I': '1', # lettre I majuscule → un 'Z': '2', # Z → 2 'S': '5', # S → 5 'B': '8', # B → 8 } # Ensemble de tous les caractères considérés "digit-like" _DIGIT_LIKE: frozenset = frozenset('0123456789' + ''.join(_NUMERIC_CHAR_SUBS.keys())) # Pattern pour détecter les tokens ressemblant à un numéro de téléphone SA # (avant ou après normalisation G/O) — utilisé pour activer la validation. _PHONE_RE = re.compile( r'^[\+]?[\d OGlI]{9,14}$' # 9 à 14 chars, tous digit-like ou + ) def _fix_numeric_token(text: str) -> str: """ Corrige les confusions G/O/l/I/Z/S/B → 0/0/1/1/2/5/8 dans les tokens à dominante numérique. Heuristique : si ≥ 60 % des caractères sont des chiffres ou leurs homophones visuels (_DIGIT_LIKE), le token est traité comme numérique et les substitutions sont appliquées. Conservateur par conception : les mots purement alphabétiques (ratio < 0.6) ne sont jamais modifiés, ce qui évite de corrompre du texte légitime. """ if len(text) < 2: return text digit_like_count = sum(1 for c in text if c in _DIGIT_LIKE) if digit_like_count / len(text) < 0.60: return text # Pas assez numérique — on ne touche pas return ''.join(_NUMERIC_CHAR_SUBS.get(c, c) for c in text) def _validate_phone_number(text: str) -> Optional[str]: """ Valide et normalise un numéro de téléphone au format Vodacom/SA. Appliquer APRÈS _fix_numeric_token (les G/O sont déjà remplacés). Formats en entrée acceptés : - 0XXXXXXXXX (10 chiffres, préfixe 06/07/08) → inchangé - 27XXXXXXXXX (11 chiffres sans +) → +27XXXXXXXXX - +27XXXXXXXXX (12 chars avec +) → inchangé - Avec séparateurs espaces / tirets / points → nettoyés Retourne : - La chaîne normalisée si le pattern est valide. - None si le token ne ressemble pas à un numéro de téléphone. """ # Supprimer séparateurs courants clean = re.sub(r'[\s\-.]', '', text) # Format international : +27 ou 27 suivi de 9 chiffres (06x-08x) m = re.match(r'^\+?(27)([6-8]\d{8})$', clean) if m: return f"+27{m.group(2)}" # Format local : 0[6-8] + 8 chiffres = 10 chiffres m = re.match(r'^(0[6-8]\d{8})$', clean) if m: return m.group(1) return None # Token non reconnu comme numéro de téléphone # ───────────────────────────────────────────────────────────────────────────── # Correction et reconstruction des adresses e-mail # ───────────────────────────────────────────────────────────────────────────── # Extensions de domaine les plus communes pour détecter les fragments TLD _KNOWN_TLDS: frozenset = frozenset({ 'com', 'org', 'net', 'fr', 'co', 'za', 'uk', 'de', 'eu', 'io', 'gov', 'edu', 'biz', 'info', 'ma', 'tn', 'dz', 'cm', 'us', 'ca', 'it', 'es', 'nl', 'be', 'ch', 'at', 'rw', 'ng', }) def _fix_email_text(text: str) -> str: """ Corrige les erreurs OCR courantes dans un token contenant '@'. Confusions ciblées : - Espaces autour de @ : 'user @ domain' → 'user@domain' - Virgule à la place du point : 'name,com' → 'name.com' - Points doublés : 'domain..com' → 'domain.com' - Fusion rn → m dans le domaine : 'grnail.com' → 'gmail.com' - '@' mal reconnu (0, α, a) : 'user0domain' → 'user@domain' (uniquement si le pattern user + séparateur + domain.tld est trouvé) Les emails étant insensibles à la casse, la sortie est entièrement mise en minuscules. """ # Supprimer les espaces autour de @ text = re.sub(r'\s*@\s*', '@', text) if '@' not in text: return text.lower() local, _, domain = text.partition('@') # Corrections dans la partie domaine uniquement domain = domain.replace('rn', 'm') # rn → m (ex: grnail → gmail) domain = re.sub(r',([a-zA-Z])', r'.\1', domain) # virgule → point domain = re.sub(r'\.{2,}', '.', domain) # double point → simple domain = domain.strip('.,') return (local + '@' + domain).lower() def _merge_split_emails(words: List[Dict]) -> List[Dict]: """ Reconstruit les adresses e-mail fragmentées sur plusieurs tokens OCR. docTR sépare fréquemment une adresse en plusieurs tokens selon les caractères spéciaux (@, .) qu'il traite comme séparateurs de mots : 'user' '@' 'domain.com' → 'user@domain.com' 'user@domain' '.' 'com' → 'user@domain.com' 'user' '@' 'domain' '.com' → 'user@domain.com' 'user' '@' 'domain' '.' 'com' → 'user@domain.com' Stratégie : Cas A — '@' isolé : fusionner le token gauche + '@' + token(s) droit(s) Cas B — token contient '@' : absorber les fragments TLD à droite ('. ', ',com', 'fr', '.org', etc.) La bounding box résultante enveloppe tous les tokens absorbés. """ if not words: return words result: List[Dict] = [] i = 0 while i < len(words): text = words[i].get('text', '').strip() # ── Cas A : '@' isolé entre deux tokens ────────────────────────────── if text == '@' and result and i + 1 < len(words): prev_w = result[-1] next_w = words[i + 1] merged = prev_w['text'].rstrip() + '@' + next_w['text'].lstrip() # Absorber les fragments TLD contigus à droite j = i + 2 while j < len(words) and j < i + 5: nxt = words[j].get('text', '').strip().lower().lstrip(',.') if nxt in _KNOWN_TLDS or re.match(r'^[,.]?[a-z]{2,6}$', words[j].get('text', '')): merged += '.' + nxt j += 1 else: break last_absorbed = words[j - 1] merged_w = dict(prev_w) merged_w['original_text'] = prev_w.get('original_text', prev_w['text']) merged_w['text'] = _fix_email_text(merged) merged_w['corrections'] = prev_w.get('corrections', []) + ['email_merged'] merged_w['box'] = [ prev_w['box'][0], min(prev_w['box'][1], last_absorbed['box'][1]), last_absorbed['box'][2], max(prev_w['box'][3], last_absorbed['box'][3]), ] result[-1] = merged_w i = j continue # ── Cas B : token contient '@' → absorber fragments TLD à droite ───── if '@' in text: merged = text j = i + 1 while j < len(words) and j < i + 4: nxt = words[j].get('text', '').strip() nxt_low = nxt.lower().lstrip(',.') if (nxt in ('.', ',') or nxt_low in _KNOWN_TLDS or re.match(r'^[,.]?[a-z]{2,6}$', nxt)): merged += '.' + nxt_low j += 1 else: break merged_w = dict(words[i]) orig = merged_w.get('original_text', text) fixed = _fix_email_text(merged) if fixed != text or j > i + 1: merged_w['original_text'] = orig merged_w['text'] = fixed corr = merged_w.get('corrections', []) tag = 'email_merged' if j > i + 1 else 'email_fixed' merged_w['corrections'] = corr + [tag] if j > i + 1: last = words[j - 1] merged_w['box'] = [ words[i]['box'][0], min(words[i]['box'][1], last['box'][1]), last['box'][2], max(words[i]['box'][3], last['box'][3]), ] result.append(merged_w) i = j continue result.append(words[i]) i += 1 return result # ───────────────────────────────────────────────────────────────────────────── # Fonctions internes de correction # ───────────────────────────────────────────────────────────────────────────── def _split_merged_words(text: str) -> str: """ Sépare les mots fusionnés courants dans les documents OCR. 'Nameand' → 'Name and' 'TotalAmount' → 'Total Amount' 'InvoiceDate' → 'Invoice Date' """ # Cas 1 : deux mots connus collés (préfixe connu + Majuscule) result = _PREFIX_RE.sub(r'\1 \2', text) # Cas 2 : mot très long avec transition minuscule→Majuscule if len(result) > 14 and not result.isupper(): result = re.sub(r'([a-z])([A-Z])', r'\1 \2', result) return result def _detect_context(word: str) -> str: """Retourne 'numeric' | 'alpha' selon le type dominant du token.""" digits = sum(c.isdigit() for c in word) letters = sum(c.isalpha() for c in word) return 'numeric' if digits > letters else 'alpha' def _fix_char_confusions(text: str, context: str) -> str: """ Corrige les confusions caractère typiques petits fonts selon le contexte. Contexte 'numeric' : O→0, l→1 entre chiffres ou en position numérique. Contexte 'alpha' : 0→O en position alphabétique (rare, conservateur). """ if context == 'numeric': # O isolé entre chiffres ou en position finale numérique text = re.sub(r'(?<=\d)O(?=\d)', '0', text) text = re.sub(r'(?<=\d)l(?=\d)', '1', text) text = re.sub(r'\bO\b', '0', text) # O seul = probablement 0 return text def _apply_vocab(text: str) -> Tuple[str, str]: """Correction via dictionnaire métier. Retourne (texte, type_correction).""" if text in INVOICE_VOCAB: return INVOICE_VOCAB[text], 'vocab_exact' lower = text.lower() for k, v in INVOICE_VOCAB.items(): if k.lower() == lower: if text.isupper(): corrected = v.upper() elif text and text[0].isupper(): corrected = v.capitalize() else: corrected = v.lower() return corrected, 'vocab_case' return text, '' def _apply_regex_patterns(text: str) -> Tuple[str, str]: """Application des patterns regex. Retourne (texte, type_correction).""" for pattern, replacement in _REGEX_PATTERNS: try: new_text = pattern.sub(replacement, text) except (TypeError, AttributeError): continue if new_text != text: return new_text, 'regex_pattern' return text, '' # ───────────────────────────────────────────────────────────────────────────── # Fonction principale de post-traitement # ───────────────────────────────────────────────────────────────────────────── def clean_invoice_ocr( words: List[Dict], split_merged: bool = True, apply_vocab: bool = True, apply_regex: bool = True, confidence_threshold: float = 0.65, fuzzy_threshold: int = 90, ) -> List[Dict]: """ Pipeline de post-traitement OCR pour documents métier (factures, devis…). S'exécute APRÈS ocr_cleaner.clean_ocr_output() (qui gère arabic_norm, fuzzy FR/AR). Ce module cible l'anglais métier et les patterns numériques. Args: words : liste de dicts {text, box, confidence, ...} split_merged : sépare les mots fusionnés apply_vocab : vocabulaire exact + insensible casse apply_regex : patterns dates / montants / numéros confidence_threshold : seuil en dessous duquel on applique toutes corrections fuzzy_threshold : seuil rapidfuzz pour corrections approximatives Returns: Liste enrichie avec 'original_text' et 'corrections' (liste des types). """ vocab_keys = list(INVOICE_VOCAB.keys()) cleaned = [] # Pré-étape : reconstruction des emails fragmentés (multi-token → 1 token) # Doit s'exécuter SUR LA LISTE complète avant la boucle mot par mot. words = _merge_split_emails(list(words)) for w in words: word = dict(w) text = word.get("text", "") conf = word.get("confidence", 1.0) corrections: List[str] = [] original = text if not text: cleaned.append(word) continue # 1. Vocabulaire exact (priorité maximale, toujours appliqué) if apply_vocab: text, ctype = _apply_vocab(text) if ctype: corrections.append(ctype) # 2. Patterns regex (montants, dates, numéros — toujours appliqués) if apply_regex: text, ctype = _apply_regex_patterns(text) if ctype: corrections.append(ctype) # 2b. Correction confusion G/O dans tokens numériques (systématique) # Appliqué inconditionnellement car la confusion est liée à la police # (pas à la confiance du modèle OCR). fixed_num = _fix_numeric_token(text) if fixed_num != text: text = fixed_num corrections.append('numeric_char_fix') # 2c. Correction token email simple (déjà complet, mais erreurs OCR internes) # ex : 'user@grnail,com' → 'user@gmail.com' # Appliqué inconditionnellement si '@' présent et non déjà traité. if '@' in text and 'email_merged' not in corrections and 'email_fixed' not in corrections: fixed_email = _fix_email_text(text) if fixed_email != text: text = fixed_email corrections.append('email_fixed') # 2e. Validation / normalisation numéros de téléphone SA # Activée uniquement si le token ressemble à un numéro (longueur + chars). if _PHONE_RE.match(text.replace(' ', '').replace('-', '')): normed = _validate_phone_number(text) if normed and normed != text: text = normed corrections.append('phone_normalized') # 3. Corrections sur mots à faible confiance if conf < confidence_threshold: # 3a. Séparation mots fusionnés (mots longs uniquement) if split_merged and len(text) > 8: new_text = _split_merged_words(text) if new_text != text: text = new_text corrections.append('split_merged') # 3b. Confusion caractères selon contexte if len(text) <= 10: ctx = _detect_context(text) new_text = _fix_char_confusions(text, ctx) if new_text != text: text = new_text corrections.append(f'char_fix_{ctx}') # 3c. Fuzzy matching vocabulaire (mots courts non encore corrigés) if _HAS_RAPIDFUZZ and not corrections and 3 <= len(text) <= 15: match = _rfuzz.extractOne( text, vocab_keys, scorer=_fuzz.ratio, score_cutoff=fuzzy_threshold, ) if match: text = INVOICE_VOCAB[match[0]] corrections.append(f'fuzzy_{match[1]}') # Mise à jour du mot if text != original: word["original_text"] = original word["text"] = text else: word.setdefault("original_text", original) word["corrections"] = corrections cleaned.append(word) changed = sum(1 for w in cleaned if w.get("corrections")) logger.info("post_processor : %d correction(s) sur %d mots.", changed, len(cleaned)) return cleaned # ───────────────────────────────────────────────────────────────────────────── # Reconstruction de lignes adaptative (conscience des colonnes) # ───────────────────────────────────────────────────────────────────────────── def reconstruct_lines_table_aware(words: List[Dict]) -> List[List[Dict]]: """ Regroupe les mots en lignes avec un seuil de hauteur dynamique. Avantage vs seuil fixe : - Adapté à toute taille de police (petits tableaux, gros titres) - Utilise le y_center (milieu vertical) plutôt que y_top - Threshold = 60 % de la hauteur médiane des mots Coordonnées attendues : [x1, y1, x2, y2] normalisées 0-1000. Returns: Liste de lignes, chaque ligne = liste de mots triés par x_min. """ if not words: return [] # Hauteur médiane des mots heights = [max(1, w["box"][3] - w["box"][1]) for w in words] median_h = float(np.median(heights)) threshold = median_h * 0.60 # Y-center de chaque mot for w in words: w["_yc"] = (w["box"][1] + w["box"][3]) / 2 sorted_words = sorted(words, key=lambda w: w["_yc"]) lines: List[List[Dict]] = [] current_line = [sorted_words[0]] current_y_avg = sorted_words[0]["_yc"] for w in sorted_words[1:]: if abs(w["_yc"] - current_y_avg) <= threshold: current_line.append(w) current_y_avg = float(np.mean([x["_yc"] for x in current_line])) else: lines.append(sorted(current_line, key=lambda x: x["box"][0])) current_line = [w] current_y_avg = w["_yc"] if current_line: lines.append(sorted(current_line, key=lambda x: x["box"][0])) # Nettoyer l'attribut temporaire for w in words: w.pop("_yc", None) return lines def detect_column_boundaries(words: List[Dict], n_bins: int = 60, min_gap_ratio: float = 0.025) -> List[float]: """ Détecte les séparations de colonnes par histogramme des x_min. Returns: Liste de positions x (0-1000) séparant les colonnes. Vide si document mono-colonne. """ if len(words) < 8: return [] x_starts = [w["box"][0] for w in words] max_x = max(w["box"][2] for w in words) hist, edges = np.histogram(x_starts, bins=n_bins, range=(0, max_x)) min_gap_px = max_x * min_gap_ratio gaps = [] in_gap = False gap_start = 0.0 for i, count in enumerate(hist): x_pos = edges[i] if count == 0 and not in_gap: in_gap = True gap_start = x_pos elif count > 0 and in_gap: in_gap = False gap_end = x_pos if gap_end - gap_start >= min_gap_px: gaps.append((gap_start + gap_end) / 2) return gaps def lines_to_text(lines: List[List[Dict]], column_boundaries: Optional[List[float]] = None) -> str: """ Convertit les lignes reconstruites en texte final. Si column_boundaries fourni : insère une tabulation entre colonnes (utile pour rendre les tableaux lisibles dans le texte brut). """ text_lines = [] for line in lines: if column_boundaries: n_cols = len(column_boundaries) + 1 cols: Dict[int, List[str]] = {i: [] for i in range(n_cols)} for w in line: col_idx = sum(1 for b in column_boundaries if w["box"][0] > b) cols[col_idx].append(w["text"]) text_lines.append( "\t".join(" ".join(cols.get(i, [])) for i in range(n_cols)).rstrip() ) else: text_lines.append(" ".join(w["text"] for w in line)) return "\n".join(text_lines) # ───────────────────────────────────────────────────────────────────────────── # Rapport des corrections post-processor # ───────────────────────────────────────────────────────────────────────────── def correction_report_invoice(words: List[Dict]) -> List[Dict]: """ Génère le rapport des corrections appliquées par clean_invoice_ocr(). Compatible avec le format DataFrame attendu par process_document.py. """ report = [] for w in words: orig = w.get("original_text", w["text"]) if orig != w["text"]: report.append({ "original_text": orig, "text": w["text"], "correction": ", ".join(w.get("corrections", ["post"])), "confidence": round(w.get("confidence", 1.0), 3), }) return report