Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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.
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
|
|
|
|
|
|
| 1072 |
|
| 1073 |
The fix:
|
| 1074 |
-
|
| 1075 |
-
|
| 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 |
-
#
|
| 1104 |
-
if len(non_empty) == 0
|
| 1105 |
new_grid.append(row)
|
| 1106 |
continue
|
| 1107 |
|
| 1108 |
first_idx, first_val = non_empty[0]
|
| 1109 |
|
| 1110 |
-
# Guard
|
| 1111 |
if _DATE_RE.match(first_val):
|
| 1112 |
new_grid.append(row)
|
| 1113 |
continue
|
| 1114 |
|
| 1115 |
-
# Guard
|
| 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 |
-
#
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
#
|
| 1132 |
-
#
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
|
|
|
|
|
|
| 1136 |
|
| 1137 |
-
|
| 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:
|
| 1145 |
-
reconstructed = (
|
| 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)
|