File size: 36,349 Bytes
d618a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b54d77d
 
 
 
 
 
 
 
 
 
d618a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd10e70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d34c94f
 
 
b54d77d
 
 
 
cd10e70
 
 
 
 
 
 
 
 
 
 
 
d618a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b54d77d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3453432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9d6ac3
 
 
 
 
 
 
 
 
 
 
 
 
cd10e70
 
 
 
 
 
 
 
 
 
 
d618a6e
cd10e70
d618a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9d6ac3
 
 
 
 
d618a6e
 
 
 
 
 
 
 
3453432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b54d77d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d618a6e
 
 
 
 
 
 
 
b54d77d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d618a6e
 
 
 
b54d77d
 
 
 
 
d618a6e
f9d6ac3
d618a6e
 
 
b54d77d
 
 
 
 
 
 
d618a6e
f9d6ac3
d618a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd10e70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d618a6e
 
cd10e70
 
 
 
 
d618a6e
 
 
 
 
 
 
 
cd10e70
 
d618a6e
 
 
 
d34c94f
 
 
 
d618a6e
 
 
 
d34c94f
 
 
d618a6e
 
 
 
 
 
 
 
 
 
 
 
 
 
d34c94f
 
 
 
 
 
b54d77d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd10e70
 
 
 
 
d618a6e
 
 
 
 
 
 
 
f9d6ac3
 
 
 
 
 
 
 
 
 
 
 
 
 
d618a6e
 
 
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
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
"""Heat code extraction for MTR (Material Test Report) PDFs.

Public API:
    extract_heat_codes(file_bytes, filename=None) -> list[str]
        Returns a deduplicated, order-preserving list of heat codes found
        in the document. Empty list if none found β€” caller's responsibility
        to route to the no-heat-code fallback.

Strategy (in order; results unioned, then filtered + deduped):
    1. pdfplumber table extraction β€” look for columns whose header matches
       a heat-code label and pull the column's non-empty values.
    2. Label-anchored regex on extracted text β€” handles inline "Heat No.: ABC123"
       and similar patterns.
    3. Filename hint β€” if the filename contains a plausible heat-code-shaped
       token AND that token also appears in document text, include it.
    4. OCR fallback for image-only PDFs β€” runs at 300 DPI with psm 6, then
       reruns strategies 2+3 on the OCR text.

Filtering rejects:
    - ASTM/SA/EN/ISO/ASME spec numbers ("A105", "SA-312", "EN 10204", "ASME B16.5")
    - Standalone 4-digit years (2020–2030)
    - Common ALL-CAPS technical words (MAX, MIN, PASS, FAIL, etc.)
    - Template placeholders ("__", "___", "β€”", "-")
    - Single characters or 1–3 char tokens (too short to be heat codes)

Designed to drop into `app/lib/utils/heat_code.py` in the email automation
repo with no changes β€” public API and imports are repo-compatible.
"""
from __future__ import annotations

import io
import logging
import re
from typing import Iterable

logger = logging.getLogger(__name__)


# ─── Label patterns ────────────────────────────────────────────────────────────
# Synonyms for "heat code" that may appear as labels in MTRs. Case-insensitive.
# Order matters only for logging; matching is union.
_LABEL_PATTERNS: tuple[str, ...] = (
    r"heat\s*code",
    r"heat\s*no\.?",
    r"heat\s*nr\.?",
    r"heat\s*#",
    r"heat\s*number",
    r"lot\s*no\.?",      # Bonney Forge and other small-mill MTRs
    r"lot\s*number",
)

# Matches a single label occurrence, capturing label and trailing token.
# Heat code token: 4-15 chars, alphanumeric with optional dashes.
_LABEL_ANCHORED_RE = re.compile(
    r"(?i)(?P<label>" + "|".join(_LABEL_PATTERNS) + r")"
    r"\s*[:\-]?\s*"
    r"(?P<code>[A-Z0-9][A-Z0-9\-]{2,14})",
)

# Standalone heat-code shape (used for filename/table validation).
# Must start with alphanumeric, 4-15 chars total, alphanumeric + dashes.
_CODE_SHAPE_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-]{3,14}$", re.IGNORECASE)

# Pipe-spec patterns to reject (e.g., "40S", "304L", "80SS" β€” digit-led + short).
# Heat codes like "1ZZWN" survive because they have multiple letters in a row.
_PIPE_SPEC_RE = re.compile(r"^\d{1,4}[A-Z]{1,2}$", re.IGNORECASE)

# Phone number pattern: e.g. "910-738-8904". Common false positive when
# OCR picks up the vendor's contact info from the document header/footer.
_PHONE_RE = re.compile(r"^\d{3}-\d{3}-\d{4}$")

# Sales order pattern: single letter followed by 6+ digits, often with
# multiple leading zeros (e.g., "W000009810" is a WOI sales order #).
# Real heat codes don't follow this shape β€” they have at most 4 digits
# after a single letter, or use dashes when longer.
_ORDER_NUMBER_RE = re.compile(r"^[A-Z]\d{6,}$", re.IGNORECASE)

# Tokens to reject regardless of context.
_SPEC_PREFIXES: tuple[str, ...] = (
    "ASTM", "ASME", "SA-", "EN", "ISO", "DIN", "API", "AISI", "JIS", "NACE",
    "MSS", "MR0",
)

# All-caps words that look code-like but are technical jargon.
_REJECT_WORDS: frozenset[str] = frozenset({
    "MAX", "MIN", "AVG", "STD", "NA", "N/A", "TBD", "TBC", "ACCEPT", "REJECT",
    "PASS", "FAIL", "PASSED", "FAILED", "OK", "ACCEPTED",
    "REMARK", "REMARKS", "NOTE", "NOTES", "TOTAL", "VALUE", "RESULT",
    "TYPE", "GRADE", "SIZE", "DESCRIPTION", "QTY", "QUANTITY",
    "TEMP", "SPEC", "SPECIFICATION", "REV", "DATE", "PAGE", "BLANK", "NONE",
    "LADLE", "PRODUCT", "MILL", "NORMALIZED", "ANNEALED", "TEMPERED",
    "CUSTOMER", "ITEM", "ORDER", "PO", "INVOICE", "QUANTITY",
    "HEAT", "TREATMENT", "COMPOSITION", "ANALYSIS",
    "BUNDLE", "DRAWING", "MFG", "MFGID", "POR", "TAG",
    "DIMENSION", "DIMENSIONAL", "VISUAL", "CHEMICAL", "MECHANICAL", "HARDNESS",
    "TENSILE", "YIELD", "ELONG", "ELONGATION", "REDUCTION",
    "RESULT", "RESULTS", "EXAMINATION", "INSPECTION", "MATERIAL",
    "TENSION", "LOCATION", "ABBREVIATION", "WELDED", "SEAMLESS",
    "MARKING", "CERTIFIED", "CERTIFICATE", "REPORT",
})

# Spec number patterns to reject (e.g., A312, SA-105, A105-21).
_SPEC_NUM_RE = re.compile(r"^[A-Z]{1,4}-?\d{2,5}(-\d{1,3})?$", re.IGNORECASE)

# Year-like standalone tokens (2010-2039). Expanded from 2020-2030 because
# MTRs reference older standards years like "ISO 9001:2015" and Tesseract
# may misread small text producing tokens in this range.
_YEAR_RE = re.compile(r"^(20[0-3]\d)$")

# Material-spec pattern: "A106B", "A105N", "A234B" β€” letter A + 3 digits +
# single trailing letter. ASTM material grade specs. Distinct from real
# heat codes like "A8633" (letter + 4 digits, no trailing letter).
_MATERIAL_SPEC_RE = re.compile(r"^A\d{3}[A-Z]$", re.IGNORECASE)

# Standards-prefix patterns: anywhere in the document text, if a token
# follows one of these prefixes, that token is a standards reference, NOT
# a heat code. Examples: "ISO 15156", "NACE MR 0103", "ASTM A312", "EN 10204".
# The capture group is the standards number we want to reject.
_STANDARDS_REFERENCE_RES: tuple[re.Pattern[str], ...] = (
    re.compile(r"\bISO\s+(\d{3,6})\b", re.IGNORECASE),
    re.compile(r"\bNACE\s*MR\s*(\d{3,6})\b", re.IGNORECASE),
    re.compile(r"\bMR\s*(\d{3,6})\b", re.IGNORECASE),
    # ASTM revisions can carry a single trailing letter (e.g. "A370-12a"),
    # so allow an optional lowercase letter at the end of the revision suffix.
    re.compile(r"\bASTM\s+([A-Z]?\d{3,5}[A-Z]?(?:-\d{1,3}[a-zA-Z]?)?)\b", re.IGNORECASE),
    # Capture the SA prefix when present so "SA105-21" makes it into the
    # reject set (not just "105-21"). Otherwise "ASME SA105-21" in the doc
    # text wouldn't filter "SA105-21" as an extracted candidate.
    re.compile(r"\bASME\s+((?:SA-?)?[A-Z]?\d{2,5}[A-Z]?(?:[.\-]\d{1,4}[a-zA-Z]?)?)\b", re.IGNORECASE),
    re.compile(r"\bEN\s+(\d{3,6}(?::\d{4})?)\b", re.IGNORECASE),
    re.compile(r"\bDIN\s+(\d{3,6})\b", re.IGNORECASE),
    re.compile(r"\bAPI\s+(\d+L?)\b", re.IGNORECASE),
    re.compile(r"\bAISI\s+(\d{3,4}[A-Z]?)\b", re.IGNORECASE),
    re.compile(r"\bJIS\s+(\w+)\b", re.IGNORECASE),
    re.compile(r"\bMSS\s*SP\s*(\d{1,4})\b", re.IGNORECASE),
    re.compile(r"\bPED\s+(\d{4}/\d{2}/[A-Z]+)\b", re.IGNORECASE),
    # "ISO 9001:2015" β€” both numbers are bad
    re.compile(r"(\d{4}):(\d{4})"),
    # "A106/A106M-18" or "SA106/SA106M" β€” slash-separated material specs
    re.compile(r"\b([A-Z]?\d{3,4}[A-Z]?(?:-\d{1,3})?)/([A-Z]?\d{3,4}[A-Z]?(?:-\d{1,3})?)\b"),
)


# ─── Public API ────────────────────────────────────────────────────────────────

def extract_heat_codes(file_bytes: bytes, filename: str | None = None) -> list[str]:
    """Extract heat codes from an MTR PDF.

    Returns a deduplicated, ordered list. Empty if no codes can be confidently
    extracted β€” caller files to a `_no_heat_code/` fallback path.
    """
    candidates: list[tuple[str, str]] = []   # (code, source) for logging

    # Strategy 1: pdfplumber tables (text-layer PDFs)
    try:
        for code in _from_pdfplumber_tables(file_bytes):
            candidates.append((code, "table"))
    except Exception as e:
        logger.debug("pdfplumber table extraction failed: %s", e)

    # Strategy 2: column-position aware extraction (text-layer)
    # Handles MTRs where the heat label is a column header and the value sits
    # directly below it in the same X-range (DSI, 24L0604, A8633, KB419-1).
    try:
        for code in _from_column_position(file_bytes):
            candidates.append((code, "column"))
    except Exception as e:
        logger.debug("column-position extraction failed: %s", e)

    # Strategy 3: label-anchored regex on text-layer extract
    text_layer = _text_layer(file_bytes)
    for code in _from_label_anchored(text_layer):
        candidates.append((code, "text_label"))

    # Strategy 4: filename hint
    if filename:
        for code in _from_filename(filename, text_layer):
            candidates.append((code, "filename"))

    # Strategy 5: OCR fallback if we found nothing or text layer is thin
    if not candidates or len(text_layer) < 500:
        try:
            ocr_text = _ocr_text(file_bytes)
            for code in _from_label_anchored(ocr_text):
                candidates.append((code, "ocr_label"))
            for code in _from_ocr_column_heuristic(ocr_text):
                candidates.append((code, "ocr_column"))
            if filename:
                for code in _from_filename(filename, ocr_text):
                    candidates.append((code, "ocr_filename"))
        except Exception as e:
            logger.debug("OCR fallback failed: %s", e)

    # Strategy 6: targeted cell-region OCR. Last-resort recovery for MTRs
    # where the heat code sits in a small bordered table cell that standard
    # OCR can't read at default DPI (e.g. WOI MTRs with 1ZZWN). We use
    # pytesseract's image_to_data at 600 DPI to find the bbox of the
    # "HEAT" / "CODE" / "LOT" label words, crop a generous region adjacent
    # to each label, apply aggressive preprocessing (autocontrast,
    # contrast x3, double sharpen), and OCR each crop with psm 6.
    # Skip if we already have plausible-looking candidates AND text_layer
    # was thick (cheap escape hatch β€” the strategy is slow).
    if not candidates or len(text_layer) < 500:
        try:
            for code in _from_targeted_cell_ocr(file_bytes):
                candidates.append((code, "ocr_cell"))
        except Exception as e:
            logger.debug("Targeted cell-region OCR failed: %s", e)

    # Strategy 7: EasyOCR (deep-learning OCR). Final fallback for documents
    # where Tesseract genuinely cannot read the heat code at any DPI / PSM
    # combination β€” typically small or thin-font codes inside multi-column
    # tables (e.g., Flotite's G18M). EasyOCR uses a CRNN-based recognizer
    # that handles such cases better than Tesseract's classical pipeline.
    # The dependency is heavy (~2GB with torch) so we lazy-import and only
    # invoke when (a) cheaper strategies haven't produced clean results
    # and (b) the document looks image-only. Falls back gracefully if
    # easyocr isn't installed in the runtime β€” code still works without it.
    if (not candidates or len(text_layer) < 500) and _easyocr_should_run(candidates):
        try:
            for code in _from_easyocr(file_bytes):
                candidates.append((code, "easyocr"))
        except Exception as e:
            logger.debug("EasyOCR fallback failed: %s", e)

    # OCR commonly confuses the digit '0' with the letter 'O' (e.g. "JN021-1"
    # becomes "JNO21-1"). None of our 14 confirmed real heat codes uses the
    # letter O β€” every one has digit 0 in positions where O would appear. So
    # for OCR-sourced candidates we replace O→0 in-place. This avoids creating
    # duplicate folders ("JNO21-1" AND "JN021-1") and produces the canonical
    # form. Text-layer candidates are left alone β€” they have the correct chars.
    for idx in range(len(candidates)):
        code, source = candidates[idx]
        if source.startswith("ocr") and ("O" in code or "o" in code):
            normalized = code.replace("O", "0").replace("o", "0")
            if normalized != code:
                candidates[idx] = (normalized, f"{source}_o2zero")

    # Build the set of tokens that appear as standards-references in the
    # document text. Combine text_layer + ocr_text if both were collected.
    combined_text = text_layer
    if not candidates or len(text_layer) < 500:
        # OCR fallback may have produced text we should also scan.
        try:
            combined_text = (text_layer or "") + "\n" + _ocr_text(file_bytes)
        except Exception:
            pass
    standards_tokens = _collect_standards_numbers(combined_text)

    # Filter + dedupe (case-insensitive on code; preserve first-seen casing)
    filtered = _filter_and_dedupe(candidates, standards_tokens)

    if logger.isEnabledFor(logging.DEBUG):
        logger.debug(
            "Heat code extraction: %d raw candidates β†’ %d after filter. Final: %s",
            len(candidates), len(filtered), filtered,
        )

    return filtered


# ─── Strategy implementations ──────────────────────────────────────────────────

def _text_layer(file_bytes: bytes) -> str:
    """Extract embedded text layer via pdfplumber. '' if image-only."""
    try:
        import pdfplumber
        with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
            return "\n".join((page.extract_text() or "") for page in pdf.pages[:3])
    except Exception:
        return ""


def _ocr_text(file_bytes: bytes) -> str:
    """OCR first 3 pages at 300 DPI with multiple page-segmentation modes
    to maximize chances of catching small/isolated heat-code text.

    psm 6  β€” uniform block of text. Good for regular tables.
    psm 11 β€” sparse text. Good for small isolated values in form boxes
             (e.g., MTR W000009810's "1ZZWN" in the small HEAT CODE cell).
    psm 4  β€” single column. Good for column-aligned tables (Flotite).

    Outputs are concatenated; downstream code is order-insensitive and
    dedupes candidates.
    """
    from pdf2image import convert_from_bytes
    import pytesseract
    imgs = convert_from_bytes(file_bytes, dpi=300, first_page=1, last_page=3)
    parts: list[str] = []
    for img in imgs:
        for psm in ("6", "11", "4"):
            try:
                parts.append(pytesseract.image_to_string(img, config=f"--psm {psm}"))
            except Exception as e:
                logger.debug("OCR psm=%s failed: %s", psm, e)
    return "\n".join(parts)


def _from_pdfplumber_tables(file_bytes: bytes) -> Iterable[str]:
    """Find tables whose header column matches a heat label; yield column values."""
    import pdfplumber

    label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS))

    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        for page in pdf.pages[:3]:
            for table in page.extract_tables() or []:
                if not table or len(table) < 2:
                    continue

                # Find heat-label column index by scanning header rows.
                heat_col_idx: int | None = None
                for row in table[: min(3, len(table))]:
                    for idx, cell in enumerate(row):
                        if cell and label_re.search(str(cell)):
                            heat_col_idx = idx
                            break
                    if heat_col_idx is not None:
                        break

                if heat_col_idx is None:
                    continue

                # Pull values from data rows in that column.
                for row in table:
                    if heat_col_idx >= len(row):
                        continue
                    cell = row[heat_col_idx]
                    if not cell:
                        continue
                    for token in _tokenize_cell(str(cell)):
                        if _CODE_SHAPE_RE.match(token):
                            yield token


def _from_column_position(file_bytes: bytes) -> Iterable[str]:
    """Find the X-position of a heat-label word, then yield tokens whose
    x-center sits within Β±half the label's width on the lines directly below.

    Handles MTRs where the heat label is a column header (in a wide
    whitespace-aligned table) and the data row sits below, e.g. DSI:
        Part   Specif.   HEAT No   C   Si   Mn   ...
        BODY   ASTM A216 WCB-2021    VC9387   0.213 ...
    """
    import pdfplumber

    label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS))

    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        for page in pdf.pages[:3]:
            words = page.extract_words(use_text_flow=True) or []
            if not words:
                continue

            # Group words by approximate line (round y0 to nearest 2pt).
            lines: dict[int, list[dict]] = {}
            for w in words:
                key = round(w["top"] / 2)
                lines.setdefault(key, []).append(w)

            sorted_keys = sorted(lines.keys())

            # Look for a label word; capture its x-range; scan lines below.
            for i, key in enumerate(sorted_keys):
                line_words = lines[key]
                # Reconstruct the line text to test label patterns against
                # multi-word labels like "HEAT No" or "Heat Code".
                # Match individual words first, then consider their pairings.
                for j, word in enumerate(line_words):
                    pair_text = word["text"]
                    if j + 1 < len(line_words):
                        pair_text = pair_text + " " + line_words[j + 1]["text"]

                    if not label_re.search(pair_text):
                        continue

                    # X-range of the label (use bbox of just this word, or
                    # span both words if the match was over the pair).
                    x0 = word["x0"]
                    x1 = line_words[j + 1]["x1"] if (
                        j + 1 < len(line_words) and label_re.search(pair_text)
                        and not label_re.search(word["text"])
                    ) else word["x1"]
                    label_center = (x0 + x1) / 2
                    half_width = max((x1 - x0) / 2, 20.0)   # min 20pt to be forgiving

                    # Scan up to 20 lines below for tokens whose center is within
                    # half-width of the label. Multi-row MTR tables (KB419-1
                    # case has 6 rows; some have more) need a generous lookahead
                    # or we miss data rows farther down the table.
                    for next_key in sorted_keys[i + 1 : i + 21]:
                        for nw in lines[next_key]:
                            nw_center = (nw["x0"] + nw["x1"]) / 2
                            if abs(nw_center - label_center) <= half_width:
                                tok = nw["text"].strip().strip(",;|")
                                if _CODE_SHAPE_RE.match(tok):
                                    yield tok


# Cached EasyOCR reader. Module-level so we pay the model-loading cost once
# per process, not per MTR. ~50-100MB RAM + ~200MB models downloaded on first
# call (cached in ~/.EasyOCR/ thereafter).
_EASYOCR_READER = None


def _easyocr_should_run(candidates: list) -> bool:
    """Decide whether the EasyOCR fallback is worth the time. Run if we
    have very few candidates so far (zero or one) β€” this is the case for
    image-only docs where Tesseract is struggling. Skip for docs that
    already look healthy."""
    return len(candidates) <= 2


def _from_easyocr(file_bytes: bytes) -> Iterable[str]:
    """Deep-learning OCR fallback via EasyOCR. Recovers heat codes that
    Tesseract cannot read at any configuration (typically small / thin
    fonts in multi-column tables).

    Lazy-imports easyocr to avoid the heavy torch dependency cost when
    the runtime doesn't have it installed. The reader instance is cached
    at module scope so subsequent MTRs in the same process don't pay the
    model-load cost (~3-5 seconds first call, instant after).
    """
    global _EASYOCR_READER

    try:
        import easyocr
        from pdf2image import convert_from_bytes
        import numpy as np
    except ImportError:
        logger.debug("EasyOCR not available β€” install easyocr to enable")
        return

    try:
        if _EASYOCR_READER is None:
            # CPU-only, English-only. Set verbose=False to suppress the
            # noisy progress bars during model loading.
            _EASYOCR_READER = easyocr.Reader(["en"], gpu=False, verbose=False)
            logger.info("EasyOCR reader initialized")

        imgs = convert_from_bytes(
            file_bytes, dpi=400, first_page=1, last_page=2
        )
        for img in imgs:
            arr = np.array(img)
            # detail=0 returns just the text strings (no boxes/scores).
            results = _EASYOCR_READER.readtext(arr, detail=0)
            for text in results:
                if not text:
                    continue
                for tok in _tokenize_cell(text):
                    if _CODE_SHAPE_RE.match(tok):
                        yield tok
    except Exception as e:
        logger.debug("EasyOCR run failed: %s", e)


def _from_targeted_cell_ocr(file_bytes: bytes) -> Iterable[str]:
    """Targeted cell-region OCR for MTRs where the heat code sits in a
    small bordered table cell that default-DPI Tesseract can't read.

    Approach:
        1. Render the PDF at 600 DPI (3-4x larger than _ocr_text).
        2. Use image_to_data to find the bbox of every "HEAT" / "CODE" /
           "LOT" / "NO." label word.
        3. For each label box, crop a generous adjacent region (~1200Γ—400
           pixels at 600 DPI = ~2x2 inch chunk to the right/below the
           label).
        4. Apply aggressive preprocessing: grayscale β†’ autocontrast β†’
           contrast x3 β†’ double sharpen. Recovers small/faint text that
           default OCR misses.
        5. OCR each crop with psm 6 and yield heat-code-shaped tokens.

    Proven to recover '1ZZWN' from MTR W000009810 which was unreadable
    by every other strategy.
    """
    try:
        from pdf2image import convert_from_bytes
        from PIL import ImageEnhance, ImageFilter, ImageOps
        import pytesseract
    except ImportError:
        return

    # Render at 600 DPI. Cap at 2 pages β€” performance vs coverage tradeoff.
    try:
        imgs = convert_from_bytes(file_bytes, dpi=600, first_page=1, last_page=2)
    except Exception:
        return

    # Code-indicator words. We only crop when "HEAT" is followed by one
    # of these in the next few words, OR when the word itself is one of
    # these (e.g., a standalone "CODE" header in some MTRs). This avoids
    # spuriously cropping around "HEAT TREATMENT" which sits adjacent to
    # mechanical-properties values in many MTR layouts.
    code_indicators = {"CODE", "NO", "NO.", "NR", "NR.", "NUMBER", "#"}
    lot_indicators = {"LOT"}

    for img in imgs:
        try:
            # Find word-level bboxes.
            data = pytesseract.image_to_data(
                img, output_type=pytesseract.Output.DICT
            )
        except Exception:
            continue

        n = len(data.get("text", []))
        for i in range(n):
            word = (data["text"][i] or "").strip().upper()
            if not word:
                continue

            # Determine if this is a valid heat-code label position.
            is_heat_label = False
            if word == "HEAT":
                # Check next 1-2 words for a code indicator on the same line.
                for k in range(1, 3):
                    if i + k >= n:
                        break
                    nxt = (data["text"][i + k] or "").strip().upper().rstrip(".")
                    if not nxt:
                        continue
                    # Allow column-aligned lookahead β€” same approximate row.
                    if abs(int(data["top"][i + k]) - int(data["top"][i])) > 30:
                        break
                    if nxt in code_indicators or nxt in {"CODE", "NO", "NR", "NUMBER"}:
                        is_heat_label = True
                        break
                    # Stop searching after first non-matching word.
                    break
            elif word in lot_indicators:
                # "LOT NO." style label β€” check next word for NO/NUMBER
                if i + 1 < n:
                    nxt = (data["text"][i + 1] or "").strip().upper().rstrip(".")
                    if nxt in {"NO", "NUMBER", "#"}:
                        is_heat_label = True
            elif word == "CODE":
                # Standalone CODE only valid if preceded by HEAT.
                if i > 0:
                    prev = (data["text"][i - 1] or "").strip().upper()
                    if prev == "HEAT":
                        # Already handled by the "HEAT" branch above; skip
                        # to avoid duplicate crops around the same label.
                        continue

            if not is_heat_label:
                continue

            try:
                x0 = int(data["left"][i])
                y0 = int(data["top"][i])
                # Generous crop: ~1200Γ—400 px at 600 DPI is ~2 inches.
                x1 = min(img.width, x0 + 1200)
                y1 = min(img.height, y0 + 400)
                crop = img.crop((x0, y0, x1, y1))
                # Aggressive preprocessing.
                g = crop.convert("L")
                g = ImageOps.autocontrast(g, cutoff=1)
                g = ImageEnhance.Contrast(g).enhance(3.0)
                g = g.filter(ImageFilter.SHARPEN)
                g = g.filter(ImageFilter.SHARPEN)
                # OCR with psm 6 (uniform block of text).
                txt = pytesseract.image_to_string(g, config="--psm 6")
            except Exception:
                continue
            # Yield heat-code-shaped tokens from the crop.
            for tok in _tokenize_cell(txt):
                if _CODE_SHAPE_RE.match(tok):
                    yield tok


def _from_ocr_column_heuristic(ocr_text: str) -> Iterable[str]:
    """For OCR text: when a line contains a heat label, yield code-shaped
    tokens from the next 3 lines. Cheap-and-pretty-good for the cases where
    the OCR preserves line breaks between header and data rows.
    """
    if not ocr_text:
        return
    label_re = re.compile(r"(?i)" + "|".join(_LABEL_PATTERNS))
    # Section headers that mark the end of a "heat code" data region.
    # When we hit a line containing one of these (and no heat-code-shaped
    # tokens), stop scanning β€” we've left the heat code table and entered
    # a different section (mechanical properties, chemical analysis, etc.)
    # that contains tensile-strength numbers, yield values, etc. that look
    # heat-code-shaped but are not.
    section_break_re = re.compile(
        r"\b(?:MECHANICAL|CHEMICAL|TENSILE|HARDNESS|IMPACT|HYDROSTATIC|"
        r"PROPERTIES|COMPOSITIONS?|TREATMENT|ANALYSIS|BEND|EDDY|MICROSTRUCTURE|"
        r"REMARKS?|NOTES?|"
        # Address-block markers: catches lines like "4815 West 5th Street
        # Lumberton NC 28358" that contain ZIP codes / street numbers OCR'd
        # as heat-code-shaped tokens.
        r"STREET|AVENUE|ROAD|BOULEVARD|DRIVE|HIGHWAY|"
        r"WEBSITE|EMAIL|PHONE|FAX|TEL)\b",
        re.IGNORECASE,
    )
    lines = ocr_text.splitlines()
    for i, line in enumerate(lines):
        if not label_re.search(line):
            continue
        # Look at the next 20 non-empty lines OR until we hit a section
        # break. Multi-row MTR tables can have 6+ data rows (KB419-1 case);
        # a tight lookahead drops rows past the first few. The section-
        # break stop prevents this wider lookahead from leaking into
        # downstream sections like mechanical/chemical properties.
        looked = 0
        for j in range(i + 1, min(i + 40, len(lines))):
            nxt = lines[j].strip()
            if not nxt:
                continue
            # If this line contains a section-break keyword, we've left the
            # heat-code data region β€” stop scanning unconditionally. Words
            # like "CHEMICAL", "MECHANICAL", "TENSILE" themselves match the
            # code-shape regex but are clearly section markers, not heat
            # codes.
            if section_break_re.search(nxt):
                break
            looked += 1
            if looked > 20:
                break
            for tok in _tokenize_cell(nxt):
                if _CODE_SHAPE_RE.match(tok):
                    yield tok


def _from_label_anchored(text: str) -> Iterable[str]:
    """Yield candidates following 'Heat No.', 'Lot No.', etc."""
    if not text:
        return
    for m in _LABEL_ANCHORED_RE.finditer(text):
        yield m.group("code")


def _from_filename(filename: str, doc_text: str) -> Iterable[str]:
    """If filename contains a heat-code-shaped token that appears (exactly or
    fuzzily) in document text, yield it.

    Fuzzy match handles OCR-mangled docs where e.g. "SD55822" in the filename
    shows up as "S055822" in the OCR text (D→0 confusion). Uses simple
    same-length / one-edit-away check (good enough for OCR drift).
    """
    # Split filename on whitespace and underscore ONLY β€” preserve dashes so
    # codes like "Y211213D40-1" and "KB419-1" survive intact.
    base = filename.rsplit(".", 1)[0]   # drop .pdf
    tokens = re.split(r"[\s_]+", base)
    text_upper = doc_text.upper() if doc_text else ""

    for tok in tokens:
        if not _CODE_SHAPE_RE.match(tok):
            continue
        tok_upper = tok.upper()
        if tok_upper in text_upper:
            yield tok
        elif _fuzzy_token_in_text(tok_upper, text_upper, max_edits=2):
            yield tok


def _fuzzy_token_in_text(token: str, text: str, max_edits: int = 2) -> bool:
    """True if `text` contains a same-length window within `max_edits` of `token`.

    Cheap-and-good-enough: scans every same-length substring and counts char
    differences. O(len(text) * len(token)) but n is small (n ≀ 15) and we
    only call it as a fallback.
    """
    n = len(token)
    if n == 0 or len(text) < n:
        return False
    for i in range(len(text) - n + 1):
        window = text[i : i + n]
        diff = sum(1 for a, b in zip(token, window) if a != b)
        if diff <= max_edits:
            return True
    return False


def _tokenize_cell(cell_text: str) -> list[str]:
    """Split a table cell into candidate tokens, preserving dashes."""
    return [t.strip() for t in re.split(r"[\s,;|]+", cell_text) if t.strip()]


def _collect_standards_numbers(doc_text: str) -> set[str]:
    """Scan document text for standards references (ISO 15156, NACE MR 0103,
    ASTM A312, EN 10204, etc.) and return the set of standard-number tokens
    that should never be treated as heat codes.

    Returned tokens are uppercased and stripped of leading non-alphanumerics
    so they can be compared directly against `code.upper()` in the filter.
    """
    bad: set[str] = set()
    if not doc_text:
        return bad
    for pat in _STANDARDS_REFERENCE_RES:
        for m in pat.finditer(doc_text):
            for grp in m.groups():
                if not grp:
                    continue
                tok = re.sub(r"^[^A-Za-z0-9]+", "", grp).upper()
                if tok:
                    bad.add(tok)
                    # Also reject the no-dash form (e.g., "A106-18" β†’ also drop "A106")
                    no_dash = tok.split("-")[0]
                    if no_dash != tok:
                        bad.add(no_dash)
    return bad


def _filter_and_dedupe(
    candidates: list[tuple[str, str]],
    standards_tokens: set[str] | None = None,
) -> list[str]:
    """Apply rejection rules + dedupe case-insensitively, preserving order.
    Also drops substring duplicates: if 'KB419' and 'KB419-1' are both present,
    keep only the longer one (avoid over-counting when codes get split).
    `standards_tokens` is an optional set of uppercased tokens that were
    detected as standards references in the document text β€” these are rejected
    regardless of their shape."""
    standards_tokens = standards_tokens or set()
    seen: dict[str, str] = {}   # upper -> original-cased

    for code, _source in candidates:
        code = code.strip()
        upper = code.upper()

        if upper in seen:
            continue
        if upper in standards_tokens:
            continue
        if not _is_plausible_heat_code(code):
            continue
        seen[upper] = code

    # Substring dedup: drop any code that is a strict prefix OR strict suffix
    # of another. Prefix case handles e.g. KB419 / KB419-1 splits. Suffix case
    # handles OCR-truncation: "N021-1" is the suffix of "JN021-1" β€” happens
    # when Tesseract drops the leading letter of a heat code.
    uppers = list(seen.keys())
    keep = set(uppers)
    for u in uppers:
        for other in uppers:
            if u == other or len(other) <= len(u):
                continue
            if other.startswith(u) or other.endswith(u):
                keep.discard(u)
                break
    return [seen[u] for u in uppers if u in keep]


def _is_plausible_heat_code(code: str) -> bool:
    upper = code.upper()

    if not _CODE_SHAPE_RE.match(code):
        return False
    if upper in _REJECT_WORDS:
        return False
    if _YEAR_RE.match(upper):
        return False
    # Pure-numeric tokens with 8 or more digits look like dates (YYYYMMDD)
    # or order/quantity values, not heat codes. The largest pure-numeric
    # real heat code we've seen is 5 digits (10720); 8+ digits is always
    # something else.
    if upper.isdigit() and len(upper) >= 8:
        return False
    # Pure-numeric tokens starting with '0' are not heat codes β€” they're
    # OCR artifacts from chemical-analysis decimal values (".035" read as
    # "0035") or from index/sequence numbers. None of our 14 confirmed
    # heat codes starts with a digit-0; pure-numeric reals (10720, etc.)
    # all start with 1–9.
    if upper.isdigit() and upper.startswith("0"):
        return False
    # Pure-numeric 4-digit tokens are not heat codes in our confirmed
    # samples β€” the smallest pure-numeric real code is 10720 (5 digits).
    # 4-digit pure numerics are typically mechanical test values
    # (3400 PSI, 2500 PSI, etc.) or quantities.
    if upper.isdigit() and len(upper) == 4:
        return False
    # Phone numbers: 910-738-8904 style. Common false positive when OCR
    # picks up the vendor's contact info from a document header/footer.
    if _PHONE_RE.match(upper):
        return False
    # Sales order numbers: single letter + 6+ digits, often W000... style.
    # Real heat codes max out at letter + 4 digits before a dash.
    if _ORDER_NUMBER_RE.match(upper):
        return False
    # Material-spec pattern (A106B, A105N, A234B) β€” letter A + 3 digits +
    # single trailing letter. Real heat codes like A8633 have 4 digits and
    # no trailing letter, so they're not affected.
    if _MATERIAL_SPEC_RE.match(upper):
        return False
    # Pipe-spec rejection (40S, 304L, 80SS-style schedule/grade designators).
    if _PIPE_SPEC_RE.match(upper):
        return False
    # Spec numbers β€” only reject when the token explicitly starts with a known
    # spec prefix (ASTM, ASME, SA-, EN, ISO, etc.). Pure shape ("D460", "KB419-1")
    # overlaps with real heat codes; we don't reject those on shape alone.
    if any(upper.startswith(p) for p in _SPEC_PREFIXES):
        return False
    # Reject tokens with no digits at all. Real heat codes always contain at
    # least one digit (verified across all 14 confirmed samples). This catches
    # both pure-alpha tokens (DIMENSION) and alpha+dash tokens (CON-REDUCER,
    # WP304-S, etc.) that the per-char isalpha() check would miss.
    digits = [c for c in upper if c.isdigit()]
    if not digits:
        return False

    # Reject tokens whose ONLY digit(s) are zero(s). This is the OCR-mangled-
    # English-word signature: Tesseract reads letter O as digit 0, so words
    # like ACCORDING, BELOW, FROM, DONE, COMPOSITION, etc. become ACC0RDING,
    # BEL0W, FR0M, D0NE, C0MP0SITI0N. None of our 14 confirmed real heat codes
    # have all-zero digits; they always have at least one 1–9 digit.
    if all(d == "0" for d in digits):
        return False

    return True