rehan953 commited on
Commit
47c1654
Β·
verified Β·
1 Parent(s): 0e84643

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +92 -46
app.py CHANGED
@@ -703,11 +703,52 @@ def _row_keyword_score(row):
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.
@@ -719,6 +760,7 @@ def _promote_misplaced_header_row(grid):
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)):
@@ -745,6 +787,17 @@ def _promote_misplaced_header_row(grid):
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:
@@ -765,6 +818,9 @@ def _promote_misplaced_header_row(grid):
765
  return r[:n]
766
 
767
  new_grid = [pad(expanded_header, new_ncols)]
 
 
 
768
  for row in new_data_rows:
769
  new_grid.append(pad(row, new_ncols))
770
 
@@ -1055,32 +1111,26 @@ def _reconstruct_separator_rows(grid):
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
@@ -1093,56 +1143,52 @@ def _reconstruct_separator_rows(grid):
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)
 
703
  return keyword_hits / len(non_empty)
704
 
705
 
706
+ # Pattern: header cell contains balance/account info β€” do NOT promote away from it.
707
+ # Matches things like "Beginning Balance:", "Ending Balance:", account numbers, "$20.48$3.46"
708
+ _BALANCE_INFO_RE = re.compile(
709
+ r"(beginning|ending|opening|closing)\s+balance"
710
+ r"|account\s*#?\s*\d{5,}"
711
+ r"|\b\d{7,}\b"
712
+ r"|\$\d{1,3}(?:,\d{3})*\.\d{2}",
713
+ re.IGNORECASE,
714
+ )
715
+
716
+
717
+ def _header_contains_balance_info(header_row):
718
+ """
719
+ Return True if any cell in the header row contains balance or account
720
+ information β€” indicating this IS a legitimate header row even if its
721
+ keyword score is low (e.g. Citi's "208479667 | Beginning Balance:$20.48 | ...").
722
+ """
723
+ for cell in header_row:
724
+ if _BALANCE_INFO_RE.search(str(cell or "")):
725
+ return True
726
+ return False
727
+
728
+
729
  def _promote_misplaced_header_row(grid):
730
  """
731
  Detects and fixes the OCR artifact where real column headers land in a
732
  <td> data row instead of the <th> header row.
733
 
734
+ Two scenarios handled:
735
+
736
+ Scenario A β€” Simple misplaced header (e.g. TD Bank):
737
+ <th>ACCOUNT ACTIVITY</th> ← section title, not a real header
738
+ <td>DATE DESCRIPTION</td> ... ← real column headers in a data row
739
+ <td>01/05 ...</td> ← data
740
+ β†’ Promote the keyword row to header, discard the section title rows above.
741
+
742
+ Scenario B β€” Metadata header + misplaced column headers (e.g. Citi page 1):
743
+ <th>208479667</th> <th>Beginning Balance:$20.48 Ending Balance:$3.46</th>
744
+ <td>Date Description</td> <td>Debits</td> <td>Credits</td> <td>Balance</td>
745
+ <td>04/01 DEBIT CARD... 8.01</td> ...
746
+ β†’ The existing th row has balance/account metadata (NOT a real column header).
747
+ β†’ Promote the keyword data row to header.
748
+ β†’ Preserve metadata row cells as a plain-text prefix OUTSIDE the table
749
+ by embedding them as a leading data row with colspan (kept for reference).
750
+ β†’ Split any "Date Description" fused cells in data rows.
751
+
752
  Guard conditions (no-op if not met β€” safe for all other PDFs):
753
  - Current header keyword score < threshold.
754
  - A data row within the first 5 rows has keyword score >= threshold.
 
760
  if current_header_score >= _HEADER_ROW_KEYWORD_THRESHOLD:
761
  return grid
762
 
763
+ # Search the first few data rows for a candidate keyword header row.
764
  candidate_idx = None
765
  candidate_score = 0.0
766
  for i in range(1, min(len(grid), 6)):
 
787
  new_ncols = len(expanded_header)
788
  col_expansion = new_ncols - len(candidate_row)
789
 
790
+ # If the existing header row contains balance/account metadata (Scenario B),
791
+ # preserve it as the first data row so the information is not lost.
792
+ # This row will have its content in col 0 (joined) and blanks elsewhere.
793
+ metadata_row = None
794
+ if _header_contains_balance_info(grid[0]):
795
+ meta_cells = [str(c or "").strip() for c in grid[0]]
796
+ meta_text = " ".join(c for c in meta_cells if c).strip()
797
+ if meta_text:
798
+ metadata_row = [meta_text] + [""] * (new_ncols - 1)
799
+
800
+ # Re-align data rows below the candidate header
801
  new_data_rows = []
802
  for row in grid[candidate_idx + 1:]:
803
  if col_expansion > 0 and row:
 
818
  return r[:n]
819
 
820
  new_grid = [pad(expanded_header, new_ncols)]
821
+ # Insert metadata row first if present (preserves Beginning/Ending Balance)
822
+ if metadata_row is not None:
823
+ new_grid.append(pad(metadata_row, new_ncols))
824
  for row in new_data_rows:
825
  new_grid.append(pad(row, new_ncols))
826
 
 
1111
 
1112
  The artifact (Bank of America and similar):
1113
  A section label like "Card account # XXXX XXXX XXXX 2889" sits between
1114
+ transaction rows as a full-width label. OCR splits the trailing digits
1115
+ across columns because they are right-aligned, producing variations like:
1116
+
1117
+ 2-cell split: ["Card account # XXXX XXXX XXXX 2", "", "889"]
1118
+ 3-cell split: ["Card account # XXXX XXXX XXXX", "2", "889"]
1119
+ clean 1-cell: ["Card account # XXXX XXXX XXXX 2889", "", ""]
1120
+
1121
+ Detection criteria (ALL must hold β€” guards safe for all other PDFs):
1122
+ 1. First cell is NOT a date token (transaction rows always start with date).
1123
+ 2. First cell contains letters (it is a label, not a bare number).
1124
+ 3. All cells except the first either:
1125
+ a. are empty, OR
1126
+ b. are a short pure-digit fragment (1-4 digits, no decimal, no sign)
1127
+ β€” these are the split-off tails of the account/card number.
1128
+ 4. There must be at least one non-empty cell after the first (to detect
1129
+ the split; single-cell rows are also handled as clean label rows).
1130
 
1131
  The fix:
1132
+ Concatenate all non-empty cells in order, place the result in col 0,
1133
+ blank all other cells.
 
 
 
 
 
 
 
 
1134
  """
1135
  if not grid or len(grid) < 2:
1136
  return grid
 
1143
 
1144
  for row in grid[1:]:
1145
  cells = [str(c or "").strip() for c in row]
 
 
1146
  while len(cells) < ncols:
1147
  cells.append("")
1148
 
1149
  non_empty = [(i, c) for i, c in enumerate(cells) if c]
1150
 
1151
+ # Completely empty row β€” leave as-is
1152
+ if len(non_empty) == 0:
1153
  new_grid.append(row)
1154
  continue
1155
 
1156
  first_idx, first_val = non_empty[0]
1157
 
1158
+ # Guard 1: first cell must not be a date token
1159
  if _DATE_RE.match(first_val):
1160
  new_grid.append(row)
1161
  continue
1162
 
1163
+ # Guard 2: first cell must contain letters (label, not a bare number)
1164
  if not re.search(r"[A-Za-z]", first_val):
1165
  new_grid.append(row)
1166
  continue
1167
 
1168
+ # Single non-empty cell β€” already a clean label row
1169
  if len(non_empty) == 1:
 
 
1170
  new_row = [""] * ncols
1171
  new_row[0] = first_val
1172
  new_grid.append(new_row)
1173
  continue
1174
 
1175
+ # Multiple non-empty cells: check that every cell AFTER the first
1176
+ # is a short pure-digit fragment (1-4 digits, no decimal, no sign).
1177
+ # This is the key generic guard β€” it allows 2, 3, or more cells
1178
+ # as long as all the trailing cells are digit-only fragments.
1179
+ # Real transaction rows always have amounts with decimals (-14.19)
1180
+ # or descriptions with letters, so they never pass this check.
1181
+ trailing = [val for _, val in non_empty[1:]]
1182
+ all_trailing_are_digit_fragments = all(
1183
+ re.fullmatch(r"\d{1,4}", v) for v in trailing
1184
+ )
1185
 
1186
+ if not all_trailing_are_digit_fragments:
 
 
 
1187
  new_grid.append(row)
1188
  continue
1189
 
1190
+ # Reconstruct: concatenate all non-empty cells in order
1191
+ reconstructed = "".join(val for _, val in non_empty).strip()
1192
  new_row = [""] * ncols
1193
  new_row[0] = reconstructed
1194
  new_grid.append(new_row)