Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -14,10 +14,12 @@ Primary goals for reconcile rate:
|
|
| 14 |
5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
|
| 15 |
6) promote misplaced header row: when real column headers land in a data row, restructure the grid
|
| 16 |
7) fix fused key-value rows in summary sections (e.g. Interest Summary) appended to transaction tables
|
|
|
|
| 17 |
- footer extraction enabled on all pages with deduplication guard (no double-print if body OCR already captured it)
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
-
|
| 21 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
| 22 |
import asyncio
|
| 23 |
try:
|
|
@@ -71,7 +73,7 @@ ENABLE_CONTRAST = True
|
|
| 71 |
DEFAULT_ZONE_FRAC = 0.12
|
| 72 |
PDF_HEADER_BAND_FRAC = 0.10
|
| 73 |
|
| 74 |
-
ENABLE_FOOTER_OCR = True
|
| 75 |
PDF_FOOTER_BAND_FRAC = 0.88
|
| 76 |
|
| 77 |
MIN_CROP_HEIGHT = 112
|
|
@@ -439,9 +441,9 @@ def _drop_truly_empty_columns(grid):
|
|
| 439 |
|
| 440 |
def _merge_blank_header_text_columns(grid):
|
| 441 |
"""
|
| 442 |
-
If the header row has blank columns, and most body rows have non-empty *text* in that
|
| 443 |
-
merge that column into the nearest non-empty header to the left (usually
|
| 444 |
-
then remove the blank column.
|
| 445 |
This fixes transaction tables where DESCRIPTION is split across two columns.
|
| 446 |
"""
|
| 447 |
if not grid or len(grid) < 2:
|
|
@@ -506,9 +508,10 @@ def _merge_blank_header_text_columns(grid):
|
|
| 506 |
# --------------------------
|
| 507 |
|
| 508 |
# Matches a standalone money value across currencies.
|
| 509 |
-
# Covers: $3,447.10 -1,234.56 Β£500.00
|
| 510 |
_MONEY_RE = re.compile(
|
| 511 |
-
r"^[\$Β£β¬Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{
|
|
|
|
| 512 |
)
|
| 513 |
|
| 514 |
# All column-header keywords recognised across any bank statement format.
|
|
@@ -521,7 +524,6 @@ _HEADER_KW_PATTERNS = [
|
|
| 521 |
r"TRANSACTION\s+DATE",
|
| 522 |
r"ENTRY\s+DATE",
|
| 523 |
r"EFFECTIVE\s+DATE",
|
| 524 |
-
|
| 525 |
# Transaction ID / reference
|
| 526 |
r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
|
| 527 |
r"TXN(?:\s+(?:ID|NO|TYPE))?",
|
|
@@ -531,7 +533,6 @@ _HEADER_KW_PATTERNS = [
|
|
| 531 |
r"VOUCHER(?:\s*(?:NO|NUMBER))?",
|
| 532 |
r"SR\.?\s*NO\.?",
|
| 533 |
r"SERIAL(?:\s*(?:NO|NUMBER))?",
|
| 534 |
-
|
| 535 |
# Description variants
|
| 536 |
r"DESCRIPTION",
|
| 537 |
r"DETAILS?",
|
|
@@ -539,7 +540,6 @@ _HEADER_KW_PATTERNS = [
|
|
| 539 |
r"NARRATION",
|
| 540 |
r"REMARKS?",
|
| 541 |
r"NOTES?",
|
| 542 |
-
|
| 543 |
# Debit variants
|
| 544 |
r"DEBIT",
|
| 545 |
r"DEBITS?",
|
|
@@ -547,7 +547,6 @@ _HEADER_KW_PATTERNS = [
|
|
| 547 |
r"WITHDRAWALS?",
|
| 548 |
r"PAID\s+OUT",
|
| 549 |
r"MONEY\s+OUT",
|
| 550 |
-
|
| 551 |
# Credit variants
|
| 552 |
r"CREDIT",
|
| 553 |
r"CREDITS?",
|
|
@@ -555,10 +554,8 @@ _HEADER_KW_PATTERNS = [
|
|
| 555 |
r"DEPOSITS?",
|
| 556 |
r"PAID\s+IN",
|
| 557 |
r"MONEY\s+IN",
|
| 558 |
-
|
| 559 |
# Amount
|
| 560 |
r"AMOUNT",
|
| 561 |
-
|
| 562 |
# Balance variants
|
| 563 |
r"BALANCE",
|
| 564 |
r"BAL\.?",
|
|
@@ -575,7 +572,6 @@ _HEADER_KW_PATTERNS = [
|
|
| 575 |
r"OPENING\s+BAL\.?",
|
| 576 |
]
|
| 577 |
|
| 578 |
-
|
| 579 |
# Pre-compiled: each pattern anchored at start, case-insensitive
|
| 580 |
_HEADER_KW_RES = [
|
| 581 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
|
@@ -687,8 +683,8 @@ def _row_keyword_score(row):
|
|
| 687 |
"""
|
| 688 |
Return the fraction of non-empty cells in this row that are pure header
|
| 689 |
keywords (e.g. DATE, DESCRIPTION, DEBIT, CREDIT, BALANCE).
|
| 690 |
-
Also handles cells like "DATE DESCRIPTION" where two keywords are
|
| 691 |
-
into one cell β these count as a keyword cell too.
|
| 692 |
"""
|
| 693 |
non_empty = [str(c or "").strip() for c in row if str(c or "").strip()]
|
| 694 |
if not non_empty:
|
|
@@ -696,12 +692,9 @@ def _row_keyword_score(row):
|
|
| 696 |
|
| 697 |
keyword_hits = 0
|
| 698 |
for cell in non_empty:
|
| 699 |
-
# Check if the whole cell is a known keyword
|
| 700 |
if _HEADER_KW_EXACT_RE.match(cell):
|
| 701 |
keyword_hits += 1
|
| 702 |
continue
|
| 703 |
-
# Check if the cell is two or more space-separated keywords
|
| 704 |
-
# e.g. "DATE DESCRIPTION"
|
| 705 |
parts = cell.split()
|
| 706 |
if all(_HEADER_KW_EXACT_RE.match(p) for p in parts) and len(parts) > 1:
|
| 707 |
keyword_hits += 1
|
|
@@ -710,66 +703,25 @@ def _row_keyword_score(row):
|
|
| 710 |
return keyword_hits / len(non_empty)
|
| 711 |
|
| 712 |
|
| 713 |
-
def _split_merged_keyword_cell(cell_text: str, target_ncols: int, col_idx: int, total_cols: int):
|
| 714 |
-
"""
|
| 715 |
-
When a data cell contains multiple space-separated keywords (e.g. "DATE DESCRIPTION"),
|
| 716 |
-
split it into individual keyword tokens. Returns a list of strings to replace
|
| 717 |
-
the single cell β padded/trimmed to fit the available column space.
|
| 718 |
-
"""
|
| 719 |
-
parts = cell_text.strip().split()
|
| 720 |
-
# Only split if every part is a keyword
|
| 721 |
-
if len(parts) > 1 and all(_HEADER_KW_EXACT_RE.match(p) for p in parts):
|
| 722 |
-
return parts
|
| 723 |
-
return [cell_text]
|
| 724 |
-
|
| 725 |
-
|
| 726 |
def _promote_misplaced_header_row(grid):
|
| 727 |
"""
|
| 728 |
-
Detects and fixes the OCR artifact where
|
| 729 |
-
|
| 730 |
-
of the <th> header row.
|
| 731 |
-
|
| 732 |
-
This happens when a bank PDF wraps the transaction section in a larger
|
| 733 |
-
table that also contains section titles (e.g. "ACCOUNT ACTIVITY" and
|
| 734 |
-
"Transactions by Date (continued)"), so the OCR emits:
|
| 735 |
-
|
| 736 |
-
<th>ACCOUNT ACTIVITY</th> β wrong header (section title)
|
| 737 |
-
<td>Transactions by Date β¦</td> β subtitle row
|
| 738 |
-
<td>DATE DESCRIPTION</td> <td>DEBIT</td> <td>CREDIT</td> <td>BALANCE</td>
|
| 739 |
-
<td>01/05 β¦</td> β¦ β real data
|
| 740 |
-
|
| 741 |
-
The fix:
|
| 742 |
-
1. Scan every data row (rows 1+) for a row whose cells are predominantly
|
| 743 |
-
pure header keywords.
|
| 744 |
-
2. When found, promote that row to be the new header (row 0) and discard
|
| 745 |
-
all rows above it (they are section titles / subtitles).
|
| 746 |
-
3. If any cell in the promoted row contains multiple space-joined keywords
|
| 747 |
-
(e.g. "DATE DESCRIPTION"), expand it into separate columns and
|
| 748 |
-
re-align the data rows below accordingly.
|
| 749 |
|
| 750 |
Guard conditions (no-op if not met β safe for all other PDFs):
|
| 751 |
-
-
|
| 752 |
-
|
| 753 |
-
- At least one data row must have keyword score >= threshold.
|
| 754 |
-
- The candidate row must appear within the first 5 data rows (it is
|
| 755 |
-
always near the top of the table, never buried mid-table).
|
| 756 |
"""
|
| 757 |
if not grid or len(grid) < 2:
|
| 758 |
return grid
|
| 759 |
|
| 760 |
-
|
| 761 |
-
current_header_score = _row_keyword_score(current_header)
|
| 762 |
-
|
| 763 |
-
# If the current header already looks like real column headers, do nothing.
|
| 764 |
if current_header_score >= _HEADER_ROW_KEYWORD_THRESHOLD:
|
| 765 |
return grid
|
| 766 |
|
| 767 |
-
# Search the first few data rows for a candidate header row.
|
| 768 |
candidate_idx = None
|
| 769 |
candidate_score = 0.0
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
for i in range(1, search_limit):
|
| 773 |
score = _row_keyword_score(grid[i])
|
| 774 |
if score >= _HEADER_ROW_KEYWORD_THRESHOLD and score > candidate_score:
|
| 775 |
candidate_score = score
|
|
@@ -778,7 +730,6 @@ def _promote_misplaced_header_row(grid):
|
|
| 778 |
if candidate_idx is None:
|
| 779 |
return grid
|
| 780 |
|
| 781 |
-
# --- Promote the candidate row ---
|
| 782 |
candidate_row = grid[candidate_idx]
|
| 783 |
|
| 784 |
# Expand any merged keyword cells (e.g. "DATE DESCRIPTION" β ["DATE", "DESCRIPTION"])
|
|
@@ -792,28 +743,21 @@ def _promote_misplaced_header_row(grid):
|
|
| 792 |
expanded_header.append(cell_s)
|
| 793 |
|
| 794 |
new_ncols = len(expanded_header)
|
| 795 |
-
|
| 796 |
-
col_expansion = new_ncols - old_ncols # how many extra columns were added
|
| 797 |
|
| 798 |
-
# Re-align data rows below the candidate (expand first cell if needed)
|
| 799 |
new_data_rows = []
|
| 800 |
for row in grid[candidate_idx + 1:]:
|
| 801 |
if col_expansion > 0 and row:
|
| 802 |
-
# The first cell of a data row often contains DATE + start of DESCRIPTION
|
| 803 |
-
# fused together (e.g. "01/05 TD ZELLE RECEIVED,...").
|
| 804 |
-
# Split it into [date_part, description_part] to fill the expanded columns.
|
| 805 |
first_cell = str(row[0] or "").strip()
|
| 806 |
date_match = re.match(r"^(\d{1,2}/\d{1,2})\s+(.*)", first_cell, re.DOTALL)
|
| 807 |
if date_match and col_expansion == 1:
|
| 808 |
new_row = [date_match.group(1), date_match.group(2).strip()] + list(row[1:])
|
| 809 |
else:
|
| 810 |
-
# Can't split cleanly β pad with empty cells to maintain column count
|
| 811 |
new_row = list(row) + [""] * col_expansion
|
| 812 |
new_data_rows.append(new_row)
|
| 813 |
else:
|
| 814 |
new_data_rows.append(list(row))
|
| 815 |
|
| 816 |
-
# Normalise all rows to new_ncols width
|
| 817 |
def pad(row, n):
|
| 818 |
r = list(row)
|
| 819 |
while len(r) < n:
|
|
@@ -827,17 +771,13 @@ def _promote_misplaced_header_row(grid):
|
|
| 827 |
return new_grid
|
| 828 |
|
| 829 |
|
| 830 |
-
|
| 831 |
# --------------------------
|
| 832 |
# Fix 3: Fused key-value rows in summary sections
|
| 833 |
# --------------------------
|
| 834 |
|
| 835 |
-
# Matches a value suffix
|
| 836 |
-
#
|
| 837 |
-
#
|
| 838 |
-
# "Number of days in this Period31" β label="Number of days in this Period" value="31"
|
| 839 |
-
# "Interest Earned this Period0.00" β label="Interest Earned this Period" value="0.00"
|
| 840 |
-
# Pattern: label text (non-digit) followed by a numeric/percentage token at the end.
|
| 841 |
_FUSED_KV_RE = re.compile(
|
| 842 |
r"^(.+?)\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d+)?%?)$",
|
| 843 |
re.DOTALL,
|
|
@@ -846,39 +786,22 @@ _FUSED_KV_RE = re.compile(
|
|
| 846 |
|
| 847 |
def _fix_fused_keyvalue_rows(grid):
|
| 848 |
"""
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
This happens when a bank statement appends a summary section (e.g. Interest
|
| 853 |
-
Summary, Fee Summary) after the main transaction table without a clear
|
| 854 |
-
column separator. The OCR wraps it inside the same table and each line
|
| 855 |
-
becomes a single-cell row like:
|
| 856 |
-
|
| 857 |
-
<td>Beginning Interest Rate0.00%</td> <td></td> <td></td> ...
|
| 858 |
-
|
| 859 |
-
The fix:
|
| 860 |
-
- For every data row where exactly the first cell is non-empty and all
|
| 861 |
-
other cells are empty, AND the first cell ends with a numeric/percentage
|
| 862 |
-
token fused directly onto text (no space separator), split it into
|
| 863 |
-
label (col 0) and value (last column of the table).
|
| 864 |
-
- If the cell already has a natural space before the value it is NOT
|
| 865 |
-
fused β leave it alone. This prevents touching normal description rows
|
| 866 |
-
that happen to end with a number.
|
| 867 |
|
| 868 |
Guard conditions (no-op if not met β safe for all other tables):
|
| 869 |
-
-
|
| 870 |
-
-
|
| 871 |
-
|
| 872 |
-
- The match must produce a non-empty label AND a non-empty value.
|
| 873 |
"""
|
| 874 |
if not grid or len(grid) < 2:
|
| 875 |
return grid
|
| 876 |
|
| 877 |
ncols = len(grid[0])
|
| 878 |
-
new_grid = [grid[0]]
|
| 879 |
|
| 880 |
for row in grid[1:]:
|
| 881 |
-
# Guard: only process rows where first cell is non-empty and rest are empty
|
| 882 |
cells = [str(c or "").strip() for c in row]
|
| 883 |
if not cells:
|
| 884 |
new_grid.append(row)
|
|
@@ -891,12 +814,8 @@ def _fix_fused_keyvalue_rows(grid):
|
|
| 891 |
new_grid.append(row)
|
| 892 |
continue
|
| 893 |
|
| 894 |
-
#
|
| 895 |
-
|
| 896 |
-
# We check by requiring the character just before the digit run is NOT
|
| 897 |
-
# a space.
|
| 898 |
-
fused_match = re.search(r"\S\d", first)
|
| 899 |
-
if not fused_match:
|
| 900 |
new_grid.append(row)
|
| 901 |
continue
|
| 902 |
|
|
@@ -912,7 +831,6 @@ def _fix_fused_keyvalue_rows(grid):
|
|
| 912 |
new_grid.append(row)
|
| 913 |
continue
|
| 914 |
|
| 915 |
-
# Build the fixed row: label in col 0, value in last col, rest empty
|
| 916 |
new_row = [""] * ncols
|
| 917 |
new_row[0] = label
|
| 918 |
new_row[ncols - 1] = value
|
|
@@ -921,6 +839,316 @@ def _fix_fused_keyvalue_rows(grid):
|
|
| 921 |
return new_grid
|
| 922 |
|
| 923 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
# --------------------------
|
| 925 |
# Normalizer entry point
|
| 926 |
# --------------------------
|
|
@@ -934,7 +1162,8 @@ def normalize_html_tables(text: str) -> str:
|
|
| 934 |
4. Fix 1 β clean fused header artifacts and recover the lost first data row.
|
| 935 |
5. Fix 2 β promote a misplaced header row when real column headers landed
|
| 936 |
in a <td> data row instead of the <th> header row.
|
| 937 |
-
6.
|
|
|
|
| 938 |
"""
|
| 939 |
if not text or "<table" not in text.lower():
|
| 940 |
return text
|
|
@@ -953,6 +1182,9 @@ def normalize_html_tables(text: str) -> str:
|
|
| 953 |
grid = _drop_truly_empty_columns(grid)
|
| 954 |
grid = _merge_blank_header_text_columns(grid)
|
| 955 |
|
|
|
|
|
|
|
|
|
|
| 956 |
# Fix 1: fused-header artifact (two-signal guard β safe on clean PDFs)
|
| 957 |
grid, recovered_row = _clean_header_artifacts(grid)
|
| 958 |
if recovered_row is not None:
|
|
@@ -961,7 +1193,7 @@ def normalize_html_tables(text: str) -> str:
|
|
| 961 |
# Fix 2: misplaced header row promotion (keyword-score guard β safe on clean PDFs)
|
| 962 |
grid = _promote_misplaced_header_row(grid)
|
| 963 |
|
| 964 |
-
|
| 965 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 966 |
|
| 967 |
out.append(_grid_to_html(grid) if grid else table_html)
|
|
@@ -1104,16 +1336,17 @@ def run_ocr(uploaded_file):
|
|
| 1104 |
if hdr and hdr.strip():
|
| 1105 |
parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
|
| 1106 |
|
| 1107 |
-
# Main OCR
|
| 1108 |
if page_md and page_md.strip():
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
#
|
|
|
|
| 1117 |
if ENABLE_FOOTER_OCR and page_num < len(page_images):
|
| 1118 |
ftr = ""
|
| 1119 |
if is_pdf:
|
|
@@ -1124,8 +1357,6 @@ def run_ocr(uploaded_file):
|
|
| 1124 |
ftr = ocr_zone(page_images[page_num], fs, 1.0)
|
| 1125 |
if ftr and ftr.strip():
|
| 1126 |
ftr_clean = normalize_money_glyphs(ftr.strip())
|
| 1127 |
-
# Deduplication: check if the first meaningful line of the footer
|
| 1128 |
-
# already appears anywhere in the assembled page content so far.
|
| 1129 |
ftr_first_line = next(
|
| 1130 |
(ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()),
|
| 1131 |
""
|
|
|
|
| 14 |
5) recover fused first data row generically (two-signal guard: text fusion + money fusion must both fire)
|
| 15 |
6) promote misplaced header row: when real column headers land in a data row, restructure the grid
|
| 16 |
7) fix fused key-value rows in summary sections (e.g. Interest Summary) appended to transaction tables
|
| 17 |
+
8) reconstruct mid-table separator rows split across columns by OCR (e.g. 'Card account # XXXX 2 | | 889')
|
| 18 |
- footer extraction enabled on all pages with deduplication guard (no double-print if body OCR already captured it)
|
| 19 |
+
- Fix 4: cross-validate OCR table row counts against PDF text layer; inject missing duplicate rows
|
| 20 |
+
(safe no-op for scanned PDFs and non-transaction pages)
|
| 21 |
"""
|
| 22 |
|
|
|
|
| 23 |
# Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
|
| 24 |
import asyncio
|
| 25 |
try:
|
|
|
|
| 73 |
DEFAULT_ZONE_FRAC = 0.12
|
| 74 |
PDF_HEADER_BAND_FRAC = 0.10
|
| 75 |
|
| 76 |
+
ENABLE_FOOTER_OCR = True # enabled β 3-step fallback with dedup guard
|
| 77 |
PDF_FOOTER_BAND_FRAC = 0.88
|
| 78 |
|
| 79 |
MIN_CROP_HEIGHT = 112
|
|
|
|
| 441 |
|
| 442 |
def _merge_blank_header_text_columns(grid):
|
| 443 |
"""
|
| 444 |
+
If the header row has blank columns, and most body rows have non-empty *text* in that
|
| 445 |
+
blank column, merge that column into the nearest non-empty header to the left (usually
|
| 446 |
+
DESCRIPTION), then remove the blank column.
|
| 447 |
This fixes transaction tables where DESCRIPTION is split across two columns.
|
| 448 |
"""
|
| 449 |
if not grid or len(grid) < 2:
|
|
|
|
| 508 |
# --------------------------
|
| 509 |
|
| 510 |
# Matches a standalone money value across currencies.
|
| 511 |
+
# Covers: $3,447.10 -1,234.56 Β£500.00 Rs1000 etc.
|
| 512 |
_MONEY_RE = re.compile(
|
| 513 |
+
r"^(?:[\$Β£β¬Β₯]|Rs\.?|INR|PKR)?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$",
|
| 514 |
+
re.IGNORECASE,
|
| 515 |
)
|
| 516 |
|
| 517 |
# All column-header keywords recognised across any bank statement format.
|
|
|
|
| 524 |
r"TRANSACTION\s+DATE",
|
| 525 |
r"ENTRY\s+DATE",
|
| 526 |
r"EFFECTIVE\s+DATE",
|
|
|
|
| 527 |
# Transaction ID / reference
|
| 528 |
r"TRANSACTION(?:\s+(?:ID|TYPE|NO|NUMBER))?",
|
| 529 |
r"TXN(?:\s+(?:ID|NO|TYPE))?",
|
|
|
|
| 533 |
r"VOUCHER(?:\s*(?:NO|NUMBER))?",
|
| 534 |
r"SR\.?\s*NO\.?",
|
| 535 |
r"SERIAL(?:\s*(?:NO|NUMBER))?",
|
|
|
|
| 536 |
# Description variants
|
| 537 |
r"DESCRIPTION",
|
| 538 |
r"DETAILS?",
|
|
|
|
| 540 |
r"NARRATION",
|
| 541 |
r"REMARKS?",
|
| 542 |
r"NOTES?",
|
|
|
|
| 543 |
# Debit variants
|
| 544 |
r"DEBIT",
|
| 545 |
r"DEBITS?",
|
|
|
|
| 547 |
r"WITHDRAWALS?",
|
| 548 |
r"PAID\s+OUT",
|
| 549 |
r"MONEY\s+OUT",
|
|
|
|
| 550 |
# Credit variants
|
| 551 |
r"CREDIT",
|
| 552 |
r"CREDITS?",
|
|
|
|
| 554 |
r"DEPOSITS?",
|
| 555 |
r"PAID\s+IN",
|
| 556 |
r"MONEY\s+IN",
|
|
|
|
| 557 |
# Amount
|
| 558 |
r"AMOUNT",
|
|
|
|
| 559 |
# Balance variants
|
| 560 |
r"BALANCE",
|
| 561 |
r"BAL\.?",
|
|
|
|
| 572 |
r"OPENING\s+BAL\.?",
|
| 573 |
]
|
| 574 |
|
|
|
|
| 575 |
# Pre-compiled: each pattern anchored at start, case-insensitive
|
| 576 |
_HEADER_KW_RES = [
|
| 577 |
re.compile(r"^(" + p + r")(.*)", re.IGNORECASE | re.DOTALL)
|
|
|
|
| 683 |
"""
|
| 684 |
Return the fraction of non-empty cells in this row that are pure header
|
| 685 |
keywords (e.g. DATE, DESCRIPTION, DEBIT, CREDIT, BALANCE).
|
| 686 |
+
Also handles cells like "DATE DESCRIPTION" where two keywords are
|
| 687 |
+
space-joined into one cell β these count as a keyword cell too.
|
| 688 |
"""
|
| 689 |
non_empty = [str(c or "").strip() for c in row if str(c or "").strip()]
|
| 690 |
if not non_empty:
|
|
|
|
| 692 |
|
| 693 |
keyword_hits = 0
|
| 694 |
for cell in non_empty:
|
|
|
|
| 695 |
if _HEADER_KW_EXACT_RE.match(cell):
|
| 696 |
keyword_hits += 1
|
| 697 |
continue
|
|
|
|
|
|
|
| 698 |
parts = cell.split()
|
| 699 |
if all(_HEADER_KW_EXACT_RE.match(p) for p in parts) and len(parts) > 1:
|
| 700 |
keyword_hits += 1
|
|
|
|
| 703 |
return keyword_hits / len(non_empty)
|
| 704 |
|
| 705 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
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 |
Guard conditions (no-op if not met β safe for all other PDFs):
|
| 712 |
+
- Current header keyword score < threshold.
|
| 713 |
+
- A data row within the first 5 rows has keyword score >= threshold.
|
|
|
|
|
|
|
|
|
|
| 714 |
"""
|
| 715 |
if not grid or len(grid) < 2:
|
| 716 |
return grid
|
| 717 |
|
| 718 |
+
current_header_score = _row_keyword_score(grid[0])
|
|
|
|
|
|
|
|
|
|
| 719 |
if current_header_score >= _HEADER_ROW_KEYWORD_THRESHOLD:
|
| 720 |
return grid
|
| 721 |
|
|
|
|
| 722 |
candidate_idx = None
|
| 723 |
candidate_score = 0.0
|
| 724 |
+
for i in range(1, min(len(grid), 6)):
|
|
|
|
|
|
|
| 725 |
score = _row_keyword_score(grid[i])
|
| 726 |
if score >= _HEADER_ROW_KEYWORD_THRESHOLD and score > candidate_score:
|
| 727 |
candidate_score = score
|
|
|
|
| 730 |
if candidate_idx is None:
|
| 731 |
return grid
|
| 732 |
|
|
|
|
| 733 |
candidate_row = grid[candidate_idx]
|
| 734 |
|
| 735 |
# Expand any merged keyword cells (e.g. "DATE DESCRIPTION" β ["DATE", "DESCRIPTION"])
|
|
|
|
| 743 |
expanded_header.append(cell_s)
|
| 744 |
|
| 745 |
new_ncols = len(expanded_header)
|
| 746 |
+
col_expansion = new_ncols - len(candidate_row)
|
|
|
|
| 747 |
|
|
|
|
| 748 |
new_data_rows = []
|
| 749 |
for row in grid[candidate_idx + 1:]:
|
| 750 |
if col_expansion > 0 and row:
|
|
|
|
|
|
|
|
|
|
| 751 |
first_cell = str(row[0] or "").strip()
|
| 752 |
date_match = re.match(r"^(\d{1,2}/\d{1,2})\s+(.*)", first_cell, re.DOTALL)
|
| 753 |
if date_match and col_expansion == 1:
|
| 754 |
new_row = [date_match.group(1), date_match.group(2).strip()] + list(row[1:])
|
| 755 |
else:
|
|
|
|
| 756 |
new_row = list(row) + [""] * col_expansion
|
| 757 |
new_data_rows.append(new_row)
|
| 758 |
else:
|
| 759 |
new_data_rows.append(list(row))
|
| 760 |
|
|
|
|
| 761 |
def pad(row, n):
|
| 762 |
r = list(row)
|
| 763 |
while len(r) < n:
|
|
|
|
| 771 |
return new_grid
|
| 772 |
|
| 773 |
|
|
|
|
| 774 |
# --------------------------
|
| 775 |
# Fix 3: Fused key-value rows in summary sections
|
| 776 |
# --------------------------
|
| 777 |
|
| 778 |
+
# Matches a value suffix fused onto a label with no space.
|
| 779 |
+
# e.g. "Beginning Interest Rate0.00%" β label + "0.00%"
|
| 780 |
+
# "Number of days in this Period31" β label + "31"
|
|
|
|
|
|
|
|
|
|
| 781 |
_FUSED_KV_RE = re.compile(
|
| 782 |
r"^(.+?)\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d+)?%?)$",
|
| 783 |
re.DOTALL,
|
|
|
|
| 786 |
|
| 787 |
def _fix_fused_keyvalue_rows(grid):
|
| 788 |
"""
|
| 789 |
+
Fix rows where label+value are fused into the first cell with all other
|
| 790 |
+
cells empty β common in Interest Summary / Fee Summary sections appended
|
| 791 |
+
to a transaction table by OCR.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
|
| 793 |
Guard conditions (no-op if not met β safe for all other tables):
|
| 794 |
+
- All cells except first must be empty.
|
| 795 |
+
- A non-space character must immediately precede the digit run (fused).
|
| 796 |
+
- Both label and value parts must be non-empty after splitting.
|
|
|
|
| 797 |
"""
|
| 798 |
if not grid or len(grid) < 2:
|
| 799 |
return grid
|
| 800 |
|
| 801 |
ncols = len(grid[0])
|
| 802 |
+
new_grid = [grid[0]]
|
| 803 |
|
| 804 |
for row in grid[1:]:
|
|
|
|
| 805 |
cells = [str(c or "").strip() for c in row]
|
| 806 |
if not cells:
|
| 807 |
new_grid.append(row)
|
|
|
|
| 814 |
new_grid.append(row)
|
| 815 |
continue
|
| 816 |
|
| 817 |
+
# Require no-space immediately before the digit run
|
| 818 |
+
if not re.search(r"\S\d", first):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
new_grid.append(row)
|
| 820 |
continue
|
| 821 |
|
|
|
|
| 831 |
new_grid.append(row)
|
| 832 |
continue
|
| 833 |
|
|
|
|
| 834 |
new_row = [""] * ncols
|
| 835 |
new_row[0] = label
|
| 836 |
new_row[ncols - 1] = value
|
|
|
|
| 839 |
return new_grid
|
| 840 |
|
| 841 |
|
| 842 |
+
# --------------------------
|
| 843 |
+
# Fix 4: PDF text-layer row-count patch
|
| 844 |
+
# --------------------------
|
| 845 |
+
|
| 846 |
+
def _extract_textlayer_rows(pdf_path: str, page_num: int):
|
| 847 |
+
"""
|
| 848 |
+
Extract structured transaction rows from the PDF text layer using pymupdf.
|
| 849 |
+
|
| 850 |
+
Returns list of {"date": str, "desc": str, "amount": str} dicts, or []
|
| 851 |
+
if the PDF has no text layer, pymupdf is unavailable, or fewer than 2
|
| 852 |
+
transaction rows are found (guards against non-transaction pages).
|
| 853 |
+
|
| 854 |
+
How it works:
|
| 855 |
+
- Read all words with bounding boxes from the text layer.
|
| 856 |
+
- Group words into logical lines by clustering on y-coordinate
|
| 857 |
+
(words within 5pt of each other vertically share a line).
|
| 858 |
+
- Parse each line as a transaction row: first token = date pattern,
|
| 859 |
+
last token = amount pattern, middle tokens = description.
|
| 860 |
+
"""
|
| 861 |
+
try:
|
| 862 |
+
import pymupdf as fitz
|
| 863 |
+
|
| 864 |
+
doc = fitz.open(pdf_path)
|
| 865 |
+
page = doc[page_num]
|
| 866 |
+
words = page.get_text("words")
|
| 867 |
+
doc.close()
|
| 868 |
+
|
| 869 |
+
if not words:
|
| 870 |
+
return []
|
| 871 |
+
|
| 872 |
+
# Group words by y-bucket (5pt tolerance)
|
| 873 |
+
lines_by_y = {}
|
| 874 |
+
for w in words:
|
| 875 |
+
x0, y0, word = float(w[0]), float(w[1]), w[4]
|
| 876 |
+
bucket = None
|
| 877 |
+
for existing_y in lines_by_y:
|
| 878 |
+
if abs(existing_y - y0) <= 5:
|
| 879 |
+
bucket = existing_y
|
| 880 |
+
break
|
| 881 |
+
if bucket is None:
|
| 882 |
+
bucket = y0
|
| 883 |
+
lines_by_y.setdefault(bucket, []).append((x0, word))
|
| 884 |
+
|
| 885 |
+
sorted_lines = []
|
| 886 |
+
for y in sorted(lines_by_y):
|
| 887 |
+
line_words = sorted(lines_by_y[y], key=lambda t: t[0])
|
| 888 |
+
sorted_lines.append([w for _, w in line_words])
|
| 889 |
+
|
| 890 |
+
# Date: MM/DD/YY, MM/DD/YYYY, MM/DD
|
| 891 |
+
date_re = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$")
|
| 892 |
+
# Amount: -1,234.56 $193.63 -$240.46
|
| 893 |
+
amount_re = re.compile(
|
| 894 |
+
r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
|
| 895 |
+
)
|
| 896 |
+
|
| 897 |
+
rows = []
|
| 898 |
+
for line in sorted_lines:
|
| 899 |
+
if len(line) < 2:
|
| 900 |
+
continue
|
| 901 |
+
if not date_re.match(line[0]):
|
| 902 |
+
continue
|
| 903 |
+
if not amount_re.match(line[-1]):
|
| 904 |
+
continue
|
| 905 |
+
desc = " ".join(line[1:-1]).strip()
|
| 906 |
+
if not desc:
|
| 907 |
+
continue
|
| 908 |
+
rows.append({"date": line[0], "desc": desc, "amount": line[-1]})
|
| 909 |
+
|
| 910 |
+
# Guard: require at least 2 rows to avoid false positives on non-transaction pages
|
| 911 |
+
return rows if len(rows) >= 2 else []
|
| 912 |
+
|
| 913 |
+
except Exception as e:
|
| 914 |
+
log.debug("_extract_textlayer_rows failed (page %d): %s", page_num, e)
|
| 915 |
+
return []
|
| 916 |
+
|
| 917 |
+
|
| 918 |
+
def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str:
|
| 919 |
+
"""
|
| 920 |
+
Compare OCR table rows against PDF text-layer rows and inject any rows
|
| 921 |
+
the OCR model silently dropped (typically identical consecutive rows).
|
| 922 |
+
|
| 923 |
+
Guard conditions (all must pass for any injection):
|
| 924 |
+
1. PDF text layer must exist and yield >= 2 transaction rows.
|
| 925 |
+
2. The OCR table must have DATE + DESCRIPTION + AMOUNT columns.
|
| 926 |
+
3. At least one (date, desc, amount) key must appear in both OCR and
|
| 927 |
+
text layer β ensures we are patching the right table.
|
| 928 |
+
4. Only injects copies of rows ALREADY present in OCR (ocr_count > 0)
|
| 929 |
+
β never invents new row content.
|
| 930 |
+
5. Entire function wrapped in try/except β any error returns page_md unchanged.
|
| 931 |
+
"""
|
| 932 |
+
if not page_md or "<table" not in page_md.lower():
|
| 933 |
+
return page_md
|
| 934 |
+
|
| 935 |
+
try:
|
| 936 |
+
tl_rows = _extract_textlayer_rows(pdf_path, page_num)
|
| 937 |
+
if not tl_rows:
|
| 938 |
+
return page_md # scanned PDF or non-transaction page β safe no-op
|
| 939 |
+
|
| 940 |
+
def _norm(s):
|
| 941 |
+
return re.sub(r"\s+", " ", str(s or "").strip().lower())
|
| 942 |
+
|
| 943 |
+
# Build text-layer frequency map
|
| 944 |
+
tl_freq = {}
|
| 945 |
+
for r in tl_rows:
|
| 946 |
+
key = (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"]))
|
| 947 |
+
tl_freq[key] = tl_freq.get(key, 0) + 1
|
| 948 |
+
|
| 949 |
+
table_pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
|
| 950 |
+
result = page_md
|
| 951 |
+
|
| 952 |
+
for tbl_match in table_pattern.finditer(page_md):
|
| 953 |
+
table_html = tbl_match.group(0)
|
| 954 |
+
|
| 955 |
+
p = TableGridParser()
|
| 956 |
+
p.feed(table_html)
|
| 957 |
+
grid = _build_grid(p.rows)
|
| 958 |
+
if len(grid) < 2:
|
| 959 |
+
continue
|
| 960 |
+
|
| 961 |
+
# Identify DATE, DESCRIPTION, AMOUNT column indices
|
| 962 |
+
header = [str(c or "").strip().upper() for c in grid[0]]
|
| 963 |
+
date_col = desc_col = amt_col = None
|
| 964 |
+
for ci, h in enumerate(header):
|
| 965 |
+
if re.search(r"\bDATE\b", h) and date_col is None:
|
| 966 |
+
date_col = ci
|
| 967 |
+
elif re.search(r"\b(DESCRIPTION|DETAILS?|NARRATION|PARTICULARS?)\b", h) and desc_col is None:
|
| 968 |
+
desc_col = ci
|
| 969 |
+
elif re.search(r"\b(AMOUNT|DEBIT|CREDIT|DR|CR)\b", h) and amt_col is None:
|
| 970 |
+
amt_col = ci
|
| 971 |
+
|
| 972 |
+
if date_col is None or desc_col is None or amt_col is None:
|
| 973 |
+
continue # not a transaction table
|
| 974 |
+
|
| 975 |
+
# Build OCR frequency map
|
| 976 |
+
ocr_freq = {}
|
| 977 |
+
ocr_rows_indexed = []
|
| 978 |
+
for ri, row in enumerate(grid[1:], start=1):
|
| 979 |
+
cells = [str(c or "").strip() for c in row]
|
| 980 |
+
if len(cells) <= max(date_col, desc_col, amt_col):
|
| 981 |
+
continue
|
| 982 |
+
d = _norm(cells[date_col])
|
| 983 |
+
desc = _norm(cells[desc_col])
|
| 984 |
+
amt = _norm(cells[amt_col])
|
| 985 |
+
if not d or not desc:
|
| 986 |
+
continue
|
| 987 |
+
key = (d, desc, amt)
|
| 988 |
+
ocr_freq[key] = ocr_freq.get(key, 0) + 1
|
| 989 |
+
ocr_rows_indexed.append((ri, key))
|
| 990 |
+
|
| 991 |
+
# Guard: overlap required
|
| 992 |
+
overlap = set(ocr_freq.keys()) & set(tl_freq.keys())
|
| 993 |
+
if not overlap:
|
| 994 |
+
continue
|
| 995 |
+
|
| 996 |
+
# Determine missing rows
|
| 997 |
+
to_inject = {}
|
| 998 |
+
for key in tl_freq:
|
| 999 |
+
ocr_count = ocr_freq.get(key, 0)
|
| 1000 |
+
tl_count = tl_freq[key]
|
| 1001 |
+
if tl_count > ocr_count and ocr_count > 0:
|
| 1002 |
+
# Guard: only restore rows already present in OCR
|
| 1003 |
+
to_inject[key] = tl_count - ocr_count
|
| 1004 |
+
|
| 1005 |
+
if not to_inject:
|
| 1006 |
+
continue
|
| 1007 |
+
|
| 1008 |
+
# Build patched grid β inject missing rows after last occurrence
|
| 1009 |
+
new_grid = [grid[0]]
|
| 1010 |
+
for ri, row in enumerate(grid[1:], start=1):
|
| 1011 |
+
new_grid.append(row)
|
| 1012 |
+
cells = [str(c or "").strip() for c in row]
|
| 1013 |
+
if len(cells) <= max(date_col, desc_col, amt_col):
|
| 1014 |
+
continue
|
| 1015 |
+
d = _norm(cells[date_col])
|
| 1016 |
+
desc = _norm(cells[desc_col])
|
| 1017 |
+
amt = _norm(cells[amt_col])
|
| 1018 |
+
key = (d, desc, amt)
|
| 1019 |
+
if key in to_inject and to_inject[key] > 0:
|
| 1020 |
+
last_occ = max(idx for idx, k in ocr_rows_indexed if k == key)
|
| 1021 |
+
if ri == last_occ:
|
| 1022 |
+
for _ in range(to_inject[key]):
|
| 1023 |
+
new_grid.append(list(row))
|
| 1024 |
+
to_inject[key] = 0
|
| 1025 |
+
|
| 1026 |
+
new_table_html = _grid_to_html(new_grid)
|
| 1027 |
+
result = result.replace(table_html, new_table_html, 1)
|
| 1028 |
+
log.info("page %d: injected missing duplicate row(s) from text layer", page_num)
|
| 1029 |
+
|
| 1030 |
+
return result
|
| 1031 |
+
|
| 1032 |
+
except Exception as e:
|
| 1033 |
+
log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
|
| 1034 |
+
return page_md # always safe β return original on any error
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
|
| 1038 |
+
# --------------------------
|
| 1039 |
+
# Fix 5: Mid-table separator row reconstruction
|
| 1040 |
+
# --------------------------
|
| 1041 |
+
|
| 1042 |
+
# Date pattern for transaction rows: MM/DD, MM/DD/YY, MM/DD/YYYY
|
| 1043 |
+
_DATE_RE = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$")
|
| 1044 |
+
|
| 1045 |
+
# Standalone money amount (may be split off a separator label by OCR)
|
| 1046 |
+
_SPLIT_AMOUNT_RE = re.compile(
|
| 1047 |
+
r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
|
| 1048 |
+
)
|
| 1049 |
+
|
| 1050 |
+
|
| 1051 |
+
def _reconstruct_separator_rows(grid):
|
| 1052 |
+
"""
|
| 1053 |
+
Detect and reconstruct mid-table separator / label rows that OCR has
|
| 1054 |
+
incorrectly split across columns.
|
| 1055 |
+
|
| 1056 |
+
The artifact (Bank of America and similar):
|
| 1057 |
+
A section label like "Card account # XXXX XXXX XXXX 2889" sits between
|
| 1058 |
+
transaction rows as a full-width label. Because the table has columns
|
| 1059 |
+
(Date, Description, Amount) and "2889" is right-aligned, OCR splits it:
|
| 1060 |
+
col 0: "Card account # XXXX XXXX XXXX 2"
|
| 1061 |
+
col 1: "" β description empty
|
| 1062 |
+
col 2: "889" β trailing digits treated as amount
|
| 1063 |
+
|
| 1064 |
+
How to detect a split separator row (ALL conditions must hold):
|
| 1065 |
+
- The row has exactly 1 non-empty cell OR the last non-empty cell is a
|
| 1066 |
+
short numeric fragment (1-4 digits, no decimal, no sign) that is NOT
|
| 1067 |
+
a plausible standalone amount.
|
| 1068 |
+
- The first cell does NOT match a date pattern (separators are never dates).
|
| 1069 |
+
- The first cell is non-empty and looks like a label (has letters).
|
| 1070 |
+
- The last cell (if non-empty and different from first) is a short digit
|
| 1071 |
+
fragment that makes sense as the tail of the label in col 0.
|
| 1072 |
+
|
| 1073 |
+
The fix:
|
| 1074 |
+
- Concatenate all non-empty cells (trimming the spurious digit fragment
|
| 1075 |
+
back onto the label), place the result in col 0, and blank all other cells.
|
| 1076 |
+
|
| 1077 |
+
Guard conditions (no-op if not met β safe for all other tables):
|
| 1078 |
+
- Grid must have at least 2 columns and 2 rows.
|
| 1079 |
+
- First cell must not be a date (transaction rows are left alone).
|
| 1080 |
+
- Last cell, if different from first, must be a short pure-digit fragment
|
| 1081 |
+
(1-4 digits, no decimal point) β this is very specific and avoids
|
| 1082 |
+
touching legitimate amount cells like "-14.19".
|
| 1083 |
+
- Entire row must have at most 2 non-empty cells total.
|
| 1084 |
+
"""
|
| 1085 |
+
if not grid or len(grid) < 2:
|
| 1086 |
+
return grid
|
| 1087 |
+
|
| 1088 |
+
ncols = len(grid[0])
|
| 1089 |
+
if ncols < 2:
|
| 1090 |
+
return grid
|
| 1091 |
+
|
| 1092 |
+
new_grid = [grid[0]] # keep header unchanged
|
| 1093 |
+
|
| 1094 |
+
for row in grid[1:]:
|
| 1095 |
+
cells = [str(c or "").strip() for c in row]
|
| 1096 |
+
|
| 1097 |
+
# Pad to ncols if needed
|
| 1098 |
+
while len(cells) < ncols:
|
| 1099 |
+
cells.append("")
|
| 1100 |
+
|
| 1101 |
+
non_empty = [(i, c) for i, c in enumerate(cells) if c]
|
| 1102 |
+
|
| 1103 |
+
# Guard 1: must have 1 or 2 non-empty cells total
|
| 1104 |
+
if len(non_empty) == 0 or len(non_empty) > 2:
|
| 1105 |
+
new_grid.append(row)
|
| 1106 |
+
continue
|
| 1107 |
+
|
| 1108 |
+
first_idx, first_val = non_empty[0]
|
| 1109 |
+
|
| 1110 |
+
# Guard 2: first cell must not be a date token
|
| 1111 |
+
if _DATE_RE.match(first_val):
|
| 1112 |
+
new_grid.append(row)
|
| 1113 |
+
continue
|
| 1114 |
+
|
| 1115 |
+
# Guard 3: first cell must contain letters (it's a label, not a number)
|
| 1116 |
+
if not re.search(r"[A-Za-z]", first_val):
|
| 1117 |
+
new_grid.append(row)
|
| 1118 |
+
continue
|
| 1119 |
+
|
| 1120 |
+
if len(non_empty) == 1:
|
| 1121 |
+
# Only one cell has content β already a clean label row, ensure
|
| 1122 |
+
# it is in col 0 and all other cells are blank.
|
| 1123 |
+
new_row = [""] * ncols
|
| 1124 |
+
new_row[0] = first_val
|
| 1125 |
+
new_grid.append(new_row)
|
| 1126 |
+
continue
|
| 1127 |
+
|
| 1128 |
+
# Exactly 2 non-empty cells
|
| 1129 |
+
last_idx, last_val = non_empty[1]
|
| 1130 |
+
|
| 1131 |
+
# Guard 4: last cell must be a short pure-digit fragment (1-4 digits,
|
| 1132 |
+
# no decimal, no sign, no $) β the hallmark of a split card/account number.
|
| 1133 |
+
if not re.fullmatch(r"\d{1,4}", last_val):
|
| 1134 |
+
new_grid.append(row)
|
| 1135 |
+
continue
|
| 1136 |
+
|
| 1137 |
+
# Guard 5: the standalone fragment must NOT be a plausible amount on its own
|
| 1138 |
+
# (amounts have decimals; a bare 2-4 digit integer alone could be a year or
|
| 1139 |
+
# ref number but not a transaction amount in a USD statement)
|
| 1140 |
+
if _SPLIT_AMOUNT_RE.match(last_val) and "." in last_val:
|
| 1141 |
+
new_grid.append(row)
|
| 1142 |
+
continue
|
| 1143 |
+
|
| 1144 |
+
# Reconstruct: join first_val + last_val (they are adjacent parts of the label)
|
| 1145 |
+
reconstructed = (first_val + last_val).strip()
|
| 1146 |
+
new_row = [""] * ncols
|
| 1147 |
+
new_row[0] = reconstructed
|
| 1148 |
+
new_grid.append(new_row)
|
| 1149 |
+
|
| 1150 |
+
return new_grid
|
| 1151 |
+
|
| 1152 |
# --------------------------
|
| 1153 |
# Normalizer entry point
|
| 1154 |
# --------------------------
|
|
|
|
| 1162 |
4. Fix 1 β clean fused header artifacts and recover the lost first data row.
|
| 1163 |
5. Fix 2 β promote a misplaced header row when real column headers landed
|
| 1164 |
in a <td> data row instead of the <th> header row.
|
| 1165 |
+
6. Fix 3 β split fused key-value rows in summary sections.
|
| 1166 |
+
7. Emit normalised <table> HTML.
|
| 1167 |
"""
|
| 1168 |
if not text or "<table" not in text.lower():
|
| 1169 |
return text
|
|
|
|
| 1182 |
grid = _drop_truly_empty_columns(grid)
|
| 1183 |
grid = _merge_blank_header_text_columns(grid)
|
| 1184 |
|
| 1185 |
+
# Fix 5: reconstruct mid-table separator rows split across columns by OCR
|
| 1186 |
+
grid = _reconstruct_separator_rows(grid)
|
| 1187 |
+
|
| 1188 |
# Fix 1: fused-header artifact (two-signal guard β safe on clean PDFs)
|
| 1189 |
grid, recovered_row = _clean_header_artifacts(grid)
|
| 1190 |
if recovered_row is not None:
|
|
|
|
| 1193 |
# Fix 2: misplaced header row promotion (keyword-score guard β safe on clean PDFs)
|
| 1194 |
grid = _promote_misplaced_header_row(grid)
|
| 1195 |
|
| 1196 |
+
# Fix 3: fused key-value rows in summary sections (no-space guard β safe on all PDFs)
|
| 1197 |
grid = _fix_fused_keyvalue_rows(grid)
|
| 1198 |
|
| 1199 |
out.append(_grid_to_html(grid) if grid else table_html)
|
|
|
|
| 1336 |
if hdr and hdr.strip():
|
| 1337 |
parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
|
| 1338 |
|
| 1339 |
+
# Main OCR body: stabilize then patch missing duplicate rows from text layer
|
| 1340 |
if page_md and page_md.strip():
|
| 1341 |
+
stabilized = stabilize_tables_and_text(page_md.strip())
|
| 1342 |
+
# Fix 4: restore any rows OCR dropped by cross-checking the PDF text layer.
|
| 1343 |
+
# Safe no-op for scanned PDFs (no text layer) and non-transaction pages.
|
| 1344 |
+
if is_pdf:
|
| 1345 |
+
stabilized = _patch_ocr_with_textlayer(stabilized, path, page_num)
|
| 1346 |
+
parts.append(stabilized)
|
| 1347 |
+
|
| 1348 |
+
# Footer extraction: PDF text layer -> band words -> OCR crop
|
| 1349 |
+
# Deduplication guard prevents double-printing when body OCR already captured it.
|
| 1350 |
if ENABLE_FOOTER_OCR and page_num < len(page_images):
|
| 1351 |
ftr = ""
|
| 1352 |
if is_pdf:
|
|
|
|
| 1357 |
ftr = ocr_zone(page_images[page_num], fs, 1.0)
|
| 1358 |
if ftr and ftr.strip():
|
| 1359 |
ftr_clean = normalize_money_glyphs(ftr.strip())
|
|
|
|
|
|
|
| 1360 |
ftr_first_line = next(
|
| 1361 |
(ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()),
|
| 1362 |
""
|