Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -943,12 +943,10 @@ def _extract_textlayer_rows(pdf_path: str, page_num: int):
|
|
| 943 |
if the PDF has no text layer, pymupdf is unavailable, or fewer than 2
|
| 944 |
transaction rows are found (guards against non-transaction pages).
|
| 945 |
|
| 946 |
-
|
| 947 |
-
-
|
| 948 |
-
-
|
| 949 |
-
|
| 950 |
-
- Parse each line as a transaction row: first token = date pattern,
|
| 951 |
-
last token = amount pattern, middle tokens = description.
|
| 952 |
"""
|
| 953 |
try:
|
| 954 |
import pymupdf as fitz
|
|
@@ -979,25 +977,81 @@ def _extract_textlayer_rows(pdf_path: str, page_num: int):
|
|
| 979 |
line_words = sorted(lines_by_y[y], key=lambda t: t[0])
|
| 980 |
sorted_lines.append([w for _, w in line_words])
|
| 981 |
|
| 982 |
-
# Date
|
| 983 |
-
|
| 984 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 985 |
amount_re = re.compile(
|
| 986 |
-
r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$
|
|
|
|
|
|
|
| 987 |
)
|
| 988 |
|
| 989 |
rows = []
|
| 990 |
for line in sorted_lines:
|
| 991 |
if len(line) < 2:
|
| 992 |
continue
|
| 993 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
continue
|
| 995 |
-
|
|
|
|
| 996 |
continue
|
| 997 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 998 |
if not desc:
|
| 999 |
continue
|
| 1000 |
-
|
|
|
|
| 1001 |
|
| 1002 |
# Guard: require at least 2 rows to avoid false positives on non-transaction pages
|
| 1003 |
return rows if len(rows) >= 2 else []
|
|
@@ -1085,19 +1139,37 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
|
|
| 1085 |
if not overlap:
|
| 1086 |
continue
|
| 1087 |
|
| 1088 |
-
# Determine missing rows
|
| 1089 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1090 |
for key in tl_freq:
|
| 1091 |
ocr_count = ocr_freq.get(key, 0)
|
| 1092 |
tl_count = tl_freq[key]
|
| 1093 |
-
if tl_count > ocr_count
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1098 |
continue
|
| 1099 |
|
| 1100 |
-
# Build patched grid β inject missing rows
|
| 1101 |
new_grid = [grid[0]]
|
| 1102 |
for ri, row in enumerate(grid[1:], start=1):
|
| 1103 |
new_grid.append(row)
|
|
@@ -1115,9 +1187,25 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
|
|
| 1115 |
new_grid.append(list(row))
|
| 1116 |
to_inject[key] = 0
|
| 1117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1118 |
new_table_html = _grid_to_html(new_grid)
|
| 1119 |
result = result.replace(table_html, new_table_html, 1)
|
| 1120 |
-
log.info("page %d: injected missing duplicate row(s) from text layer", page_num)
|
| 1121 |
|
| 1122 |
return result
|
| 1123 |
|
|
|
|
| 943 |
if the PDF has no text layer, pymupdf is unavailable, or fewer than 2
|
| 944 |
transaction rows are found (guards against non-transaction pages).
|
| 945 |
|
| 946 |
+
Supports multiple date formats:
|
| 947 |
+
- Numeric: MM/DD, MM/DD/YY, MM/DD/YYYY (Chase, TD, BoA, Citi)
|
| 948 |
+
- Month-name: Jan 16, Feb 7, Mar 05 (Capital One, Amex)
|
| 949 |
+
- ISO: YYYY-MM-DD
|
|
|
|
|
|
|
| 950 |
"""
|
| 951 |
try:
|
| 952 |
import pymupdf as fitz
|
|
|
|
| 977 |
line_words = sorted(lines_by_y[y], key=lambda t: t[0])
|
| 978 |
sorted_lines.append([w for _, w in line_words])
|
| 979 |
|
| 980 |
+
# Date patterns β numeric (MM/DD variants) or month-name (Jan 16)
|
| 981 |
+
_MONTHS = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
|
| 982 |
+
date_re = re.compile(
|
| 983 |
+
r"^\d{1,2}/\d{2}(?:/\d{2,4})?$" # MM/DD, MM/DD/YY, MM/DD/YYYY
|
| 984 |
+
r"|^\d{4}-\d{2}-\d{2}$" # YYYY-MM-DD
|
| 985 |
+
r"|^" + _MONTHS + r"$", # "Jan", "Feb" etc (month-name date part 1)
|
| 986 |
+
re.IGNORECASE,
|
| 987 |
+
)
|
| 988 |
+
# When a month-name date appears, the next token is the day number
|
| 989 |
+
month_re = re.compile(r"^" + _MONTHS + r"$", re.IGNORECASE)
|
| 990 |
+
day_re = re.compile(r"^\d{1,2}$")
|
| 991 |
+
|
| 992 |
+
# Amount: -1,234.56 $193.63 -$240.46 - $500.00 (with space after -)
|
| 993 |
amount_re = re.compile(
|
| 994 |
+
r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
|
| 995 |
+
r"|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
|
| 996 |
+
r"|^-\s+\$\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$" # "- $500.00"
|
| 997 |
)
|
| 998 |
|
| 999 |
rows = []
|
| 1000 |
for line in sorted_lines:
|
| 1001 |
if len(line) < 2:
|
| 1002 |
continue
|
| 1003 |
+
|
| 1004 |
+
# Detect date and find where description starts
|
| 1005 |
+
date_str = None
|
| 1006 |
+
desc_start = None
|
| 1007 |
+
|
| 1008 |
+
if date_re.match(line[0]):
|
| 1009 |
+
if month_re.match(line[0]) and len(line) > 1 and day_re.match(line[1]):
|
| 1010 |
+
# Month-name date: "Jan 16" β two tokens
|
| 1011 |
+
date_str = line[0] + " " + line[1]
|
| 1012 |
+
desc_start = 2
|
| 1013 |
+
else:
|
| 1014 |
+
# Single-token numeric date
|
| 1015 |
+
date_str = line[0]
|
| 1016 |
+
desc_start = 1
|
| 1017 |
+
else:
|
| 1018 |
continue
|
| 1019 |
+
|
| 1020 |
+
if desc_start is None or desc_start >= len(line):
|
| 1021 |
continue
|
| 1022 |
+
|
| 1023 |
+
# For Capital One: Trans Date and Post Date are both present
|
| 1024 |
+
# Line looks like: Jan 16 Jan 16 CAPITAL ONE MOBILE PYMT - $500.00
|
| 1025 |
+
# After consuming first date, check if next tokens are also a date
|
| 1026 |
+
remaining = line[desc_start:]
|
| 1027 |
+
if remaining and month_re.match(remaining[0]):
|
| 1028 |
+
if len(remaining) > 1 and day_re.match(remaining[1]):
|
| 1029 |
+
# Skip the post date
|
| 1030 |
+
remaining = remaining[2:]
|
| 1031 |
+
elif len(remaining) > 0:
|
| 1032 |
+
remaining = remaining[1:]
|
| 1033 |
+
|
| 1034 |
+
if len(remaining) < 2:
|
| 1035 |
+
continue
|
| 1036 |
+
|
| 1037 |
+
# Handle "- $500.00" split as two tokens at end
|
| 1038 |
+
if (len(remaining) >= 2
|
| 1039 |
+
and remaining[-2] == "-"
|
| 1040 |
+
and remaining[-1].startswith("$")):
|
| 1041 |
+
amount_candidate = remaining[-2] + " " + remaining[-1]
|
| 1042 |
+
desc_tokens = remaining[:-2]
|
| 1043 |
+
else:
|
| 1044 |
+
amount_candidate = remaining[-1]
|
| 1045 |
+
desc_tokens = remaining[:-1]
|
| 1046 |
+
|
| 1047 |
+
if not amount_re.match(amount_candidate):
|
| 1048 |
+
continue
|
| 1049 |
+
|
| 1050 |
+
desc = " ".join(desc_tokens).strip()
|
| 1051 |
if not desc:
|
| 1052 |
continue
|
| 1053 |
+
|
| 1054 |
+
rows.append({"date": date_str, "desc": desc, "amount": amount_candidate})
|
| 1055 |
|
| 1056 |
# Guard: require at least 2 rows to avoid false positives on non-transaction pages
|
| 1057 |
return rows if len(rows) >= 2 else []
|
|
|
|
| 1139 |
if not overlap:
|
| 1140 |
continue
|
| 1141 |
|
| 1142 |
+
# Determine missing rows.
|
| 1143 |
+
# Case A: row exists in OCR but count is too low (duplicate dropped)
|
| 1144 |
+
# Case B: row exists in text layer but is completely absent from OCR
|
| 1145 |
+
# (entire section dropped by OCR model, e.g. Capital One purchases)
|
| 1146 |
+
# For Case B we build the row content from the text layer directly.
|
| 1147 |
+
to_inject = {} # key -> count of missing copies
|
| 1148 |
+
to_inject_new = {} # key -> row content for brand-new rows
|
| 1149 |
+
|
| 1150 |
for key in tl_freq:
|
| 1151 |
ocr_count = ocr_freq.get(key, 0)
|
| 1152 |
tl_count = tl_freq[key]
|
| 1153 |
+
if tl_count > ocr_count:
|
| 1154 |
+
missing = tl_count - ocr_count
|
| 1155 |
+
if ocr_count > 0:
|
| 1156 |
+
# Case A: restore missing duplicates (existing row template)
|
| 1157 |
+
to_inject[key] = missing
|
| 1158 |
+
else:
|
| 1159 |
+
# Case B: completely new row β build from text layer data
|
| 1160 |
+
# Find the original text-layer row for this key
|
| 1161 |
+
tl_row_data = next(
|
| 1162 |
+
(r for r in tl_rows
|
| 1163 |
+
if (_norm(r["date"]), _norm(r["desc"]), _norm(r["amount"])) == key),
|
| 1164 |
+
None
|
| 1165 |
+
)
|
| 1166 |
+
if tl_row_data:
|
| 1167 |
+
to_inject_new[key] = (missing, tl_row_data)
|
| 1168 |
+
|
| 1169 |
+
if not to_inject and not to_inject_new:
|
| 1170 |
continue
|
| 1171 |
|
| 1172 |
+
# Build patched grid β inject missing duplicate rows (Case A)
|
| 1173 |
new_grid = [grid[0]]
|
| 1174 |
for ri, row in enumerate(grid[1:], start=1):
|
| 1175 |
new_grid.append(row)
|
|
|
|
| 1187 |
new_grid.append(list(row))
|
| 1188 |
to_inject[key] = 0
|
| 1189 |
|
| 1190 |
+
# Case B: append completely missing rows from the text layer.
|
| 1191 |
+
# These are rows the OCR model dropped entirely (not just undercounted).
|
| 1192 |
+
# We build new table rows using the text-layer data, placing values in
|
| 1193 |
+
# the correct columns as identified from the OCR table header.
|
| 1194 |
+
ncols = len(grid[0])
|
| 1195 |
+
for key, (count, tl_row_data) in to_inject_new.items():
|
| 1196 |
+
for _ in range(count):
|
| 1197 |
+
new_row = [""] * ncols
|
| 1198 |
+
new_row[date_col] = tl_row_data["date"]
|
| 1199 |
+
new_row[desc_col] = tl_row_data["desc"]
|
| 1200 |
+
new_row[amt_col] = tl_row_data["amount"]
|
| 1201 |
+
new_grid.append(new_row)
|
| 1202 |
+
log.info(
|
| 1203 |
+
"page %d: injected missing row from text layer: %s %s",
|
| 1204 |
+
page_num, tl_row_data["date"], tl_row_data["desc"][:40]
|
| 1205 |
+
)
|
| 1206 |
+
|
| 1207 |
new_table_html = _grid_to_html(new_grid)
|
| 1208 |
result = result.replace(table_html, new_table_html, 1)
|
|
|
|
| 1209 |
|
| 1210 |
return result
|
| 1211 |
|