File size: 31,553 Bytes
63874d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
"""
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