rehan953 commited on
Commit
0e84643
Β·
verified Β·
1 Parent(s): 7a3f1ab

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +357 -126
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 blank column,
443
- merge that column into the nearest non-empty header to the left (usually DESCRIPTION),
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 1000 etc.
510
  _MONEY_RE = re.compile(
511
- r"^[\$£€Β₯]?\s*-?\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?$"
 
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 space-joined
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 the real column headers (DATE,
729
- DESCRIPTION, DEBIT, CREDIT, BALANCE …) land in a <td> data row instead
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
- - The current header row must NOT already look like real column headers
752
- (keyword score < threshold).
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
- current_header = grid[0]
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
- search_limit = min(len(grid), 6) # rows 1..5
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
- old_ncols = len(candidate_row)
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 that looks like a number or percentage fused onto the
836
- # end of a label string. Examples:
837
- # "Beginning Interest Rate0.00%" β†’ label="Beginning Interest Rate" value="0.00%"
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
- Generic fix for rows where a label and its value are fused into the first
850
- cell while all other cells in that row are empty.
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
- - The row must have ALL cells empty except the first.
870
- - The first cell must match the fused pattern (text immediately followed
871
- by a digit with no whitespace gap before the digit run).
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]] # keep header unchanged
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
- # Guard: fused pattern requires NO whitespace immediately before the
895
- # numeric suffix β€” "Rate0.00%" is fused, "Rate 0.00%" is not.
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. Emit normalised <table> HTML.
 
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
- # Fix 3: fused key-value rows in summary sections (no-space guard β€” safe on all PDFs)
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 markdown, stabilized
1108
  if page_md and page_md.strip():
1109
- parts.append(stabilize_tables_and_text(page_md.strip()))
1110
-
1111
- # Footer extraction.
1112
- # Uses the same 3-step fallback as header: PDF text layer -> band words -> OCR crop.
1113
- # Deduplication guard: if the footer text is already present in the main body OCR
1114
- # output (accidental capture on some pages), we skip appending it to avoid
1115
- # printing it twice. Comparison is done on a normalised lowercase snippet of the
1116
- # first non-empty footer line so minor whitespace differences don't matter.
 
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
  ""