Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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
|
| 2224 |
-
|
| 2225 |
-
|
| 2226 |
-
|
| 2227 |
-
|
| 2228 |
-
|
| 2229 |
-
_doc_nf.close()
|
| 2230 |
except Exception:
|
| 2231 |
-
|
| 2232 |
-
|
| 2233 |
-
|
| 2234 |
-
|
| 2235 |
-
|
| 2236 |
-
|
| 2237 |
-
|
| 2238 |
-
|
| 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 |
-
(
|
| 2253 |
-
|
| 2254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2712 |
-
|
| 2713 |
-
|
| 2714 |
-
|
| 2715 |
-
|
| 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 = ""
|