rehan953 commited on
Commit
ade3f1c
Β·
verified Β·
1 Parent(s): 9e49eb8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +111 -23
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
- How it works:
947
- - Read all words with bounding boxes from the text layer.
948
- - Group words into logical lines by clustering on y-coordinate
949
- (words within 5pt of each other vertically share a line).
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: MM/DD/YY, MM/DD/YYYY, MM/DD
983
- date_re = re.compile(r"^\d{1,2}/\d{2}(?:/\d{2,4})?$")
984
- # Amount: -1,234.56 $193.63 -$240.46
 
 
 
 
 
 
 
 
 
 
985
  amount_re = re.compile(
986
- r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\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
- if not date_re.match(line[0]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
994
  continue
995
- if not amount_re.match(line[-1]):
 
996
  continue
997
- desc = " ".join(line[1:-1]).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
998
  if not desc:
999
  continue
1000
- rows.append({"date": line[0], "desc": desc, "amount": line[-1]})
 
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
- to_inject = {}
 
 
 
 
 
 
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 and ocr_count > 0:
1094
- # Guard: only restore rows already present in OCR
1095
- to_inject[key] = tl_count - ocr_count
1096
-
1097
- if not to_inject:
 
 
 
 
 
 
 
 
 
 
 
 
1098
  continue
1099
 
1100
- # Build patched grid β€” inject missing rows after last occurrence
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