rehan953 commited on
Commit
f51e068
Β·
verified Β·
1 Parent(s): d9d6fe1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +52 -72
app.py CHANGED
@@ -594,15 +594,12 @@ def _extract_fused_header_artifacts(header_row):
594
  """
595
  Generic detector for the OCR artifact where the first data row of a table
596
  gets fused into the header cells during OCR.
597
-
598
  The artifact pattern (BOTH signals must fire simultaneously):
599
  - Signal 1 β€” text-fused: a header cell contains KEYWORD + free descriptive text
600
  e.g. "DESCRIPTIONBeginning Balance" "NARRATIONOpening Balance"
601
  - Signal 2 β€” money-fused: a DIFFERENT header cell contains KEYWORD + money amount
602
  e.g. "BALANCE$3,447.10" "AMOUNT 5,000.00"
603
-
604
  Two-signal guard prevents false positives on clean PDFs from any bank.
605
-
606
  Returns:
607
  (cleaned_header : list[str], recovered_row : list[str] | None)
608
  """
@@ -707,15 +704,12 @@ def _promote_misplaced_header_row(grid):
707
  """
708
  Detects and fixes the OCR artifact where real column headers land in a
709
  <td> data row instead of the <th> header row.
710
-
711
  Two scenarios handled:
712
-
713
  Scenario A β€” Simple misplaced header (e.g. TD Bank):
714
  <th>ACCOUNT ACTIVITY</th> ← section title, not a real header
715
  <td>DATE DESCRIPTION</td> ... ← real column headers in a data row
716
  <td>01/05 ...</td> ← data
717
  β†’ Promote the keyword row to header, discard the section title rows above.
718
-
719
  Scenario B β€” Metadata header + misplaced column headers (e.g. Citi page 1):
720
  <th>208479667</th> <th>Beginning Balance:$20.48 Ending Balance:$3.46</th>
721
  <td>Date Description</td> <td>Debits</td> <td>Credits</td> <td>Balance</td>
@@ -725,7 +719,6 @@ def _promote_misplaced_header_row(grid):
725
  β†’ Preserve metadata row cells as a plain-text prefix OUTSIDE the table
726
  by embedding them as a leading data row with colspan (kept for reference).
727
  β†’ Split any "Date Description" fused cells in data rows.
728
-
729
  Guard conditions (no-op if not met β€” safe for all other PDFs):
730
  - Current header keyword score < threshold.
731
  - A data row within the first 5 rows has keyword score >= threshold.
@@ -821,10 +814,8 @@ def _fix_fused_keyvalue_rows(grid):
821
  Fix rows where label+value are fused into the first cell with all other
822
  cells empty β€” common in Interest Summary / Fee Summary sections appended
823
  to a transaction table by OCR.
824
-
825
  e.g. "Beginning Interest Rate0.00%" β†’ label="Beginning Interest Rate" value="0.00%"
826
  "Number of days in this Period31" β†’ label="..." value="31"
827
-
828
  Guard conditions (no-op if not met β€” safe for all other tables):
829
  - All cells except first must be empty.
830
  - A non-space character must immediately precede the digit run (fused).
@@ -912,11 +903,9 @@ def _fix_fused_keyvalue_rows(grid):
912
  def _extract_textlayer_rows(pdf_path: str, page_num: int):
913
  """
914
  Extract structured transaction rows from the PDF text layer using pymupdf.
915
-
916
  Returns list of {"date": str, "desc": str, "amount": str} dicts, or []
917
  if the PDF has no text layer, pymupdf is unavailable, or fewer than 2
918
  transaction rows are found (guards against non-transaction pages).
919
-
920
  Supports multiple date formats:
921
  - Numeric: MM/DD, MM/DD/YY, MM/DD/YYYY (Chase, TD, BoA, Citi)
922
  - Month-name: Jan 16, Feb 7, Mar 05 (Capital One, Amex)
@@ -1047,7 +1036,6 @@ def _extract_textlayer_rows(pdf_path: str, page_num: int):
1047
  log.debug("_extract_textlayer_rows failed (page %d): %s", page_num, e)
1048
  return []
1049
 
1050
-
1051
  def _jb_extract_checks_fallback(text_layer: str) -> List[Tuple[str, str, str]]:
1052
  """
1053
  Johnson Bank: when pdfminer column extraction fails (e.g. pymupdf line text or
@@ -1081,18 +1069,15 @@ def _jb_extract_checks_fallback(text_layer: str) -> List[Tuple[str, str, str]]:
1081
  out.append(t)
1082
  return out
1083
 
1084
-
1085
  def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str:
1086
  """
1087
  Parse Johnson Bank Checks and Daily Account Balance from pdfminer column-separated
1088
  page text and return as HTML tables.
1089
-
1090
  pdfminer outputs multi-column sections as separate column lists:
1091
  Date Number Amount
1092
  04-14 3259 45.72
1093
  becomes:
1094
  "Date\n04-14\n04-09\n\nNumber\n3259\n3260\n\nAmount\n45.72\n1,628.00"
1095
-
1096
  Uses pdfminer (always available) not pymupdf.
1097
  """
1098
  if not text_layer:
@@ -1211,7 +1196,6 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
1211
  """
1212
  Compare OCR table rows against PDF text-layer rows and inject any rows
1213
  the OCR model silently dropped (typically identical consecutive rows).
1214
-
1215
  Guard conditions (all must pass for any injection):
1216
  1. PDF text layer must exist and yield >= 2 transaction rows.
1217
  2. The OCR table must have DATE + DESCRIPTION + AMOUNT columns.
@@ -1456,16 +1440,13 @@ def _reconstruct_separator_rows(grid):
1456
  """
1457
  Detect and reconstruct mid-table separator / label rows that OCR has
1458
  incorrectly split across columns.
1459
-
1460
  The artifact (Bank of America and similar):
1461
  A section label like "Card account # XXXX XXXX XXXX 2889" sits between
1462
  transaction rows as a full-width label. OCR splits the trailing digits
1463
  across columns because they are right-aligned, producing variations like:
1464
-
1465
  2-cell split: ["Card account # XXXX XXXX XXXX 2", "", "889"]
1466
  3-cell split: ["Card account # XXXX XXXX XXXX", "2", "889"]
1467
  clean 1-cell: ["Card account # XXXX XXXX XXXX 2889", "", ""]
1468
-
1469
  Detection criteria (ALL must hold β€” guards safe for all other PDFs):
1470
  1. First cell is NOT a date token (transaction rows always start with date).
1471
  2. First cell contains letters (it is a label, not a bare number).
@@ -1475,7 +1456,6 @@ def _reconstruct_separator_rows(grid):
1475
  β€” these are the split-off tails of the account/card number.
1476
  4. There must be at least one non-empty cell after the first (to detect
1477
  the split; single-cell rows are also handled as clean label rows).
1478
-
1479
  The fix:
1480
  Concatenate all non-empty cells in order, place the result in col 0,
1481
  blank all other cells.
@@ -1551,19 +1531,15 @@ def _merge_split_rows(grid):
1551
  """
1552
  Fix OCR artifact where a transaction row is split across two <tr> rows
1553
  because the description wrapped to a second line in the source PDF.
1554
-
1555
  Two patterns detected (Hardin County Bank and similar monospace statements):
1556
-
1557
  Pattern A β€” description + empty continuation:
1558
  Row N: [desc, '', '', '', ''] ← description, no date/money
1559
  Row N+1: ['', debit, credit, date, bal] ← money/date, no description
1560
  β†’ Merge into: [desc, debit, credit, date, bal]
1561
-
1562
  Pattern B β€” description + text continuation + money:
1563
  Row N: [desc, '', '', '', ''] ← first line of description
1564
  Row N+1: [desc_cont, debit, credit, date, bal] ← continuation + money
1565
  β†’ Merge into: [desc + ' ' + desc_cont, debit, credit, date, bal]
1566
-
1567
  Guard conditions (no-op unless both rows match the pattern):
1568
  - Row N must have non-empty col 0 (description).
1569
  - Row N must have empty date column (col 3 or wherever DATE is).
@@ -1655,11 +1631,9 @@ def _extract_fused_desc_amount(grid):
1655
  """
1656
  Fix OCR artifact where a trailing money amount gets fused into the
1657
  description cell instead of its own debit/credit column.
1658
-
1659
  Example (Hardin County Bank):
1660
  OCR: ['CHASE CREDIT CRD EPAY 8319249882 500.00', '', '', '04/11/25', '16,699.04']
1661
  Fix: ['CHASE CREDIT CRD EPAY 8319249882', '500.00', '', '04/11/25', '16,699.04']
1662
-
1663
  Guard conditions (all must hold):
1664
  - Description ends with a space + money amount (NNN.NN or N,NNN.NN).
1665
  - ALL debit AND credit columns are empty for this row.
@@ -1718,7 +1692,6 @@ def _is_junk_table(grid):
1718
  """
1719
  Detect and suppress known junk tables that are OCR artifacts from
1720
  page headers/footers, not real transaction data.
1721
-
1722
  Current patterns:
1723
  1. CUSTOMER INFORMATION fragment tables (First Horizon Bank):
1724
  Single-column table with header "CUSTOMER INFORMATION" and
@@ -1741,15 +1714,12 @@ def _is_junk_table(grid):
1741
  def _split_fused_multicolumn_header(grid):
1742
  """
1743
  Fix OCR artifact where multiple column headers are fused into one <th> cell.
1744
-
1745
  Example (First Horizon Bank alternating pages):
1746
  Fused: ['DATE DESCRIPTION CARD #', 'DEPOSIT', 'WITHDRAWAL']
1747
  Correct: ['DATE', 'DESCRIPTION', 'DEPOSIT', 'WITHDRAWAL', 'CARD #']
1748
-
1749
  The data rows also have date + description + card# all in col 0:
1750
  Fused: ['01/16 PURCHASE - UBER TRIP... 4919', '', '$21.02']
1751
  Correct: ['01/16', 'PURCHASE - UBER TRIP...', '', '$21.02', '4919']
1752
-
1753
  Detection: first header cell contains 2+ keyword terms separated by spaces,
1754
  AND remaining header cells are valid money column keywords.
1755
  """
@@ -1862,7 +1832,6 @@ def _normalize_daily_balance_table(grid):
1862
  - OCR produces N DATE columns followed by N BALANCE columns
1863
  instead of interleaved DATE|BALANCE|DATE|BALANCE pairs
1864
  - Multiple dates are stacked in one cell "01/02\n01/03\n01/04\n01/05"
1865
-
1866
  Reorders columns and expands stacked date cells into proper rows.
1867
  Only fires when ALL of these hold:
1868
  - Header has exactly 2N columns where N >= 2
@@ -1940,9 +1909,7 @@ def _normalize_checks_paid_table(grid):
1940
  - Remaining headers = "AMOUNT AMOUNT AMOUNT"
1941
  - Data col0 = "01/24 4701 01/18 4704 * 01/18 916831 *" (all groups fused)
1942
  - Remaining data cells = amount values
1943
-
1944
  Expands to proper: DATE | CHECK # | AMOUNT | DATE | CHECK # | AMOUNT | ...
1945
-
1946
  Detection: first header cell matches pattern (DATE CHECK #)+
1947
  and remaining headers are all AMOUNT.
1948
  """
@@ -2194,64 +2161,78 @@ def convert_plaintext_bank_sections(text: str) -> str:
2194
  # Navy Federal full-page text-layer parser (Fix NF-1)
2195
  # --------------------------
2196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2197
  def _parse_nfcu_page(pdf_path: str, page_num: int) -> str:
2198
  """
2199
  Full text-layer parser for Navy Federal Credit Union statements.
2200
-
2201
  GLM-OCR fails on NFCU pages because:
2202
  - The summary table has 5 wide columns with no visible borders
2203
  - Transaction tables have Amount($) and Balance($) spatially far right
2204
  - Multi-line descriptions (wrapped lines) confuse OCR table detection
2205
-
2206
  This function reads the PDF text layer directly via pdfplumber and produces
2207
  correct, complete HTML for every section: summary table, transaction tables,
2208
  items paid, savings, disclosures.
2209
-
2210
  Detection guards (all must pass β€” safe no-op for all other banks):
2211
  1. Page text must contain 'Access No.' AND 'Statement of Account'
2212
  (unique to Navy Federal statement format)
2213
  2. pdfplumber must return extractable text (not a scanned page)
2214
-
2215
  Returns complete HTML string for the page, or "" if not NFCU / any error.
2216
  """
2217
  try:
2218
- # Extract full page text using pymupdf (installed on HuggingFace) or
2219
- # pdfplumber (installed locally) β€” try both so the function works in
2220
- # either environment.
2221
  text = ""
2222
  try:
2223
- import pymupdf as _fitz_nf
2224
- _doc_nf = _fitz_nf.open(pdf_path)
2225
- if page_num >= len(_doc_nf):
2226
- _doc_nf.close()
2227
- return ""
2228
- text = _doc_nf[page_num].get_text() or ""
2229
- _doc_nf.close()
2230
  except Exception:
2231
- pass
2232
-
2233
- if not text:
2234
- try:
2235
- import pdfplumber as _plumber_nf
2236
- with _plumber_nf.open(pdf_path) as _pdf_nf:
2237
- if page_num >= len(_pdf_nf.pages):
2238
- return ""
2239
- text = _pdf_nf.pages[page_num].extract_text() or ""
2240
- except Exception:
2241
- pass
2242
-
2243
- if not text:
2244
- return ""
2245
 
2246
  if not text:
2247
  return ""
2248
 
2249
  # Guard: must match NFCU format on this page OR on page 0
2250
  # (pages 2+ still have "Access No." and "Statement of Account")
 
2251
  is_nfcu = (
2252
- ('Access No.' in text and 'Statement of Account' in text) or
2253
- ('navyfederal' in text.lower()) or
2254
- ('Navy Federal' in text)
 
 
 
 
2255
  )
2256
  if not is_nfcu:
2257
  return ""
@@ -2673,6 +2654,7 @@ def run_ocr(uploaded_file):
2673
  try:
2674
  path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
2675
  is_pdf = path.lower().endswith(".pdf")
 
2676
  parser = get_parser()
2677
 
2678
  page_heights = []
@@ -2707,14 +2689,12 @@ def run_ocr(uploaded_file):
2707
  # detected, the entire page output is replaced with clean text-layer
2708
  # data and none of the garbled OCR pipeline runs at all.
2709
  # Safe no-op for all other banks (_parse_nfcu_page returns "").
2710
- if is_pdf:
2711
- try:
2712
- nfcu_html = _parse_nfcu_page(path, page_num)
2713
- if nfcu_html:
2714
- all_pages.append(nfcu_html)
2715
- continue # skip ALL OCR processing for this page
2716
- except Exception:
2717
- pass # fall through to normal OCR pipeline on any error
2718
 
2719
  # Header inclusion: PDF text -> band words -> OCR band
2720
  hdr = ""
 
594
  """
595
  Generic detector for the OCR artifact where the first data row of a table
596
  gets fused into the header cells during OCR.
 
597
  The artifact pattern (BOTH signals must fire simultaneously):
598
  - Signal 1 β€” text-fused: a header cell contains KEYWORD + free descriptive text
599
  e.g. "DESCRIPTIONBeginning Balance" "NARRATIONOpening Balance"
600
  - Signal 2 β€” money-fused: a DIFFERENT header cell contains KEYWORD + money amount
601
  e.g. "BALANCE$3,447.10" "AMOUNT 5,000.00"
 
602
  Two-signal guard prevents false positives on clean PDFs from any bank.
 
603
  Returns:
604
  (cleaned_header : list[str], recovered_row : list[str] | None)
605
  """
 
704
  """
705
  Detects and fixes the OCR artifact where real column headers land in a
706
  <td> data row instead of the <th> header row.
 
707
  Two scenarios handled:
 
708
  Scenario A β€” Simple misplaced header (e.g. TD Bank):
709
  <th>ACCOUNT ACTIVITY</th> ← section title, not a real header
710
  <td>DATE DESCRIPTION</td> ... ← real column headers in a data row
711
  <td>01/05 ...</td> ← data
712
  β†’ Promote the keyword row to header, discard the section title rows above.
 
713
  Scenario B β€” Metadata header + misplaced column headers (e.g. Citi page 1):
714
  <th>208479667</th> <th>Beginning Balance:$20.48 Ending Balance:$3.46</th>
715
  <td>Date Description</td> <td>Debits</td> <td>Credits</td> <td>Balance</td>
 
719
  β†’ Preserve metadata row cells as a plain-text prefix OUTSIDE the table
720
  by embedding them as a leading data row with colspan (kept for reference).
721
  β†’ Split any "Date Description" fused cells in data rows.
 
722
  Guard conditions (no-op if not met β€” safe for all other PDFs):
723
  - Current header keyword score < threshold.
724
  - A data row within the first 5 rows has keyword score >= threshold.
 
814
  Fix rows where label+value are fused into the first cell with all other
815
  cells empty β€” common in Interest Summary / Fee Summary sections appended
816
  to a transaction table by OCR.
 
817
  e.g. "Beginning Interest Rate0.00%" β†’ label="Beginning Interest Rate" value="0.00%"
818
  "Number of days in this Period31" β†’ label="..." value="31"
 
819
  Guard conditions (no-op if not met β€” safe for all other tables):
820
  - All cells except first must be empty.
821
  - A non-space character must immediately precede the digit run (fused).
 
903
  def _extract_textlayer_rows(pdf_path: str, page_num: int):
904
  """
905
  Extract structured transaction rows from the PDF text layer using pymupdf.
 
906
  Returns list of {"date": str, "desc": str, "amount": str} dicts, or []
907
  if the PDF has no text layer, pymupdf is unavailable, or fewer than 2
908
  transaction rows are found (guards against non-transaction pages).
 
909
  Supports multiple date formats:
910
  - Numeric: MM/DD, MM/DD/YY, MM/DD/YYYY (Chase, TD, BoA, Citi)
911
  - Month-name: Jan 16, Feb 7, Mar 05 (Capital One, Amex)
 
1036
  log.debug("_extract_textlayer_rows failed (page %d): %s", page_num, e)
1037
  return []
1038
 
 
1039
  def _jb_extract_checks_fallback(text_layer: str) -> List[Tuple[str, str, str]]:
1040
  """
1041
  Johnson Bank: when pdfminer column extraction fails (e.g. pymupdf line text or
 
1069
  out.append(t)
1070
  return out
1071
 
 
1072
  def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str:
1073
  """
1074
  Parse Johnson Bank Checks and Daily Account Balance from pdfminer column-separated
1075
  page text and return as HTML tables.
 
1076
  pdfminer outputs multi-column sections as separate column lists:
1077
  Date Number Amount
1078
  04-14 3259 45.72
1079
  becomes:
1080
  "Date\n04-14\n04-09\n\nNumber\n3259\n3260\n\nAmount\n45.72\n1,628.00"
 
1081
  Uses pdfminer (always available) not pymupdf.
1082
  """
1083
  if not text_layer:
 
1196
  """
1197
  Compare OCR table rows against PDF text-layer rows and inject any rows
1198
  the OCR model silently dropped (typically identical consecutive rows).
 
1199
  Guard conditions (all must pass for any injection):
1200
  1. PDF text layer must exist and yield >= 2 transaction rows.
1201
  2. The OCR table must have DATE + DESCRIPTION + AMOUNT columns.
 
1440
  """
1441
  Detect and reconstruct mid-table separator / label rows that OCR has
1442
  incorrectly split across columns.
 
1443
  The artifact (Bank of America and similar):
1444
  A section label like "Card account # XXXX XXXX XXXX 2889" sits between
1445
  transaction rows as a full-width label. OCR splits the trailing digits
1446
  across columns because they are right-aligned, producing variations like:
 
1447
  2-cell split: ["Card account # XXXX XXXX XXXX 2", "", "889"]
1448
  3-cell split: ["Card account # XXXX XXXX XXXX", "2", "889"]
1449
  clean 1-cell: ["Card account # XXXX XXXX XXXX 2889", "", ""]
 
1450
  Detection criteria (ALL must hold β€” guards safe for all other PDFs):
1451
  1. First cell is NOT a date token (transaction rows always start with date).
1452
  2. First cell contains letters (it is a label, not a bare number).
 
1456
  β€” these are the split-off tails of the account/card number.
1457
  4. There must be at least one non-empty cell after the first (to detect
1458
  the split; single-cell rows are also handled as clean label rows).
 
1459
  The fix:
1460
  Concatenate all non-empty cells in order, place the result in col 0,
1461
  blank all other cells.
 
1531
  """
1532
  Fix OCR artifact where a transaction row is split across two <tr> rows
1533
  because the description wrapped to a second line in the source PDF.
 
1534
  Two patterns detected (Hardin County Bank and similar monospace statements):
 
1535
  Pattern A β€” description + empty continuation:
1536
  Row N: [desc, '', '', '', ''] ← description, no date/money
1537
  Row N+1: ['', debit, credit, date, bal] ← money/date, no description
1538
  β†’ Merge into: [desc, debit, credit, date, bal]
 
1539
  Pattern B β€” description + text continuation + money:
1540
  Row N: [desc, '', '', '', ''] ← first line of description
1541
  Row N+1: [desc_cont, debit, credit, date, bal] ← continuation + money
1542
  β†’ Merge into: [desc + ' ' + desc_cont, debit, credit, date, bal]
 
1543
  Guard conditions (no-op unless both rows match the pattern):
1544
  - Row N must have non-empty col 0 (description).
1545
  - Row N must have empty date column (col 3 or wherever DATE is).
 
1631
  """
1632
  Fix OCR artifact where a trailing money amount gets fused into the
1633
  description cell instead of its own debit/credit column.
 
1634
  Example (Hardin County Bank):
1635
  OCR: ['CHASE CREDIT CRD EPAY 8319249882 500.00', '', '', '04/11/25', '16,699.04']
1636
  Fix: ['CHASE CREDIT CRD EPAY 8319249882', '500.00', '', '04/11/25', '16,699.04']
 
1637
  Guard conditions (all must hold):
1638
  - Description ends with a space + money amount (NNN.NN or N,NNN.NN).
1639
  - ALL debit AND credit columns are empty for this row.
 
1692
  """
1693
  Detect and suppress known junk tables that are OCR artifacts from
1694
  page headers/footers, not real transaction data.
 
1695
  Current patterns:
1696
  1. CUSTOMER INFORMATION fragment tables (First Horizon Bank):
1697
  Single-column table with header "CUSTOMER INFORMATION" and
 
1714
  def _split_fused_multicolumn_header(grid):
1715
  """
1716
  Fix OCR artifact where multiple column headers are fused into one <th> cell.
 
1717
  Example (First Horizon Bank alternating pages):
1718
  Fused: ['DATE DESCRIPTION CARD #', 'DEPOSIT', 'WITHDRAWAL']
1719
  Correct: ['DATE', 'DESCRIPTION', 'DEPOSIT', 'WITHDRAWAL', 'CARD #']
 
1720
  The data rows also have date + description + card# all in col 0:
1721
  Fused: ['01/16 PURCHASE - UBER TRIP... 4919', '', '$21.02']
1722
  Correct: ['01/16', 'PURCHASE - UBER TRIP...', '', '$21.02', '4919']
 
1723
  Detection: first header cell contains 2+ keyword terms separated by spaces,
1724
  AND remaining header cells are valid money column keywords.
1725
  """
 
1832
  - OCR produces N DATE columns followed by N BALANCE columns
1833
  instead of interleaved DATE|BALANCE|DATE|BALANCE pairs
1834
  - Multiple dates are stacked in one cell "01/02\n01/03\n01/04\n01/05"
 
1835
  Reorders columns and expands stacked date cells into proper rows.
1836
  Only fires when ALL of these hold:
1837
  - Header has exactly 2N columns where N >= 2
 
1909
  - Remaining headers = "AMOUNT AMOUNT AMOUNT"
1910
  - Data col0 = "01/24 4701 01/18 4704 * 01/18 916831 *" (all groups fused)
1911
  - Remaining data cells = amount values
 
1912
  Expands to proper: DATE | CHECK # | AMOUNT | DATE | CHECK # | AMOUNT | ...
 
1913
  Detection: first header cell matches pattern (DATE CHECK #)+
1914
  and remaining headers are all AMOUNT.
1915
  """
 
2161
  # Navy Federal full-page text-layer parser (Fix NF-1)
2162
  # --------------------------
2163
 
2164
+ def _is_nfcu_document(pdf_path: str) -> bool:
2165
+ """
2166
+ Strict detector for Navy Federal statements.
2167
+ Used to gate NF-specific parsing so other banks are unaffected.
2168
+ """
2169
+ try:
2170
+ import pymupdf as fitz
2171
+ doc = fitz.open(pdf_path)
2172
+ pages_to_check = min(2, len(doc))
2173
+ text = ""
2174
+ for i in range(pages_to_check):
2175
+ text += "\n" + (doc[i].get_text() or "")
2176
+ doc.close()
2177
+ t = text.lower()
2178
+ return (
2179
+ ("statement of account" in t)
2180
+ and ("access no." in t or "routing number" in t)
2181
+ and ("navy federal" in t or "navyfederal.org" in t)
2182
+ )
2183
+ except Exception:
2184
+ return False
2185
+
2186
+
2187
  def _parse_nfcu_page(pdf_path: str, page_num: int) -> str:
2188
  """
2189
  Full text-layer parser for Navy Federal Credit Union statements.
 
2190
  GLM-OCR fails on NFCU pages because:
2191
  - The summary table has 5 wide columns with no visible borders
2192
  - Transaction tables have Amount($) and Balance($) spatially far right
2193
  - Multi-line descriptions (wrapped lines) confuse OCR table detection
 
2194
  This function reads the PDF text layer directly via pdfplumber and produces
2195
  correct, complete HTML for every section: summary table, transaction tables,
2196
  items paid, savings, disclosures.
 
2197
  Detection guards (all must pass β€” safe no-op for all other banks):
2198
  1. Page text must contain 'Access No.' AND 'Statement of Account'
2199
  (unique to Navy Federal statement format)
2200
  2. pdfplumber must return extractable text (not a scanned page)
 
2201
  Returns complete HTML string for the page, or "" if not NFCU / any error.
2202
  """
2203
  try:
 
 
 
2204
  text = ""
2205
  try:
2206
+ import pdfplumber
2207
+ with pdfplumber.open(pdf_path) as pdf:
2208
+ if page_num >= len(pdf.pages):
2209
+ return ""
2210
+ page = pdf.pages[page_num]
2211
+ text = page.extract_text() or ""
 
2212
  except Exception:
2213
+ # Fallback to pymupdf text extraction when pdfplumber isn't available.
2214
+ import pymupdf as fitz
2215
+ doc = fitz.open(pdf_path)
2216
+ if page_num >= len(doc):
2217
+ doc.close()
2218
+ return ""
2219
+ text = doc[page_num].get_text() or ""
2220
+ doc.close()
 
 
 
 
 
 
2221
 
2222
  if not text:
2223
  return ""
2224
 
2225
  # Guard: must match NFCU format on this page OR on page 0
2226
  # (pages 2+ still have "Access No." and "Statement of Account")
2227
+ t = text.lower()
2228
  is_nfcu = (
2229
+ ("statement of account" in t)
2230
+ and (
2231
+ ("access no." in t)
2232
+ or ("routing number" in t)
2233
+ or ("navy federal" in t)
2234
+ or ("navyfederal.org" in t)
2235
+ )
2236
  )
2237
  if not is_nfcu:
2238
  return ""
 
2654
  try:
2655
  path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
2656
  is_pdf = path.lower().endswith(".pdf")
2657
+ is_nfcu_doc = _is_nfcu_document(path) if is_pdf else False
2658
  parser = get_parser()
2659
 
2660
  page_heights = []
 
2689
  # detected, the entire page output is replaced with clean text-layer
2690
  # data and none of the garbled OCR pipeline runs at all.
2691
  # Safe no-op for all other banks (_parse_nfcu_page returns "").
2692
+ if is_pdf and is_nfcu_doc:
2693
+ nfcu_html = _parse_nfcu_page(path, page_num)
2694
+ if nfcu_html:
2695
+ all_pages.append(nfcu_html)
2696
+ continue # skip ALL OCR processing for this page
2697
+ log.warning("NFCU parser returned empty output for page %d", page_num + 1)
 
 
2698
 
2699
  # Header inclusion: PDF text -> band words -> OCR band
2700
  hdr = ""