rehan953 commited on
Commit
f6baa95
Β·
verified Β·
1 Parent(s): ba118bb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +39 -49
app.py CHANGED
@@ -83,7 +83,6 @@ MIN_CROP_PIXELS = 112 * 112
83
 
84
  _parser = None
85
 
86
-
87
  def get_parser():
88
  global _parser
89
  if _parser is None:
@@ -91,7 +90,6 @@ def get_parser():
91
  _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
92
  return _parser
93
 
94
-
95
  # ---------------------------------------------------------------------------
96
  # Best-effort config tweaks (safe to fail on read-only HF env)
97
  # ---------------------------------------------------------------------------
@@ -123,7 +121,6 @@ try:
123
  except Exception:
124
  pass
125
 
126
-
127
  # --------------------------
128
  # Header/footer helpers
129
  # --------------------------
@@ -140,7 +137,6 @@ def get_header_footer_zones(regions, norm_height=1000):
140
  return None, None
141
  return min(y_tops) / norm_height, max(y_bottoms) / norm_height
142
 
143
-
144
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
145
  try:
146
  import pymupdf as fitz
@@ -154,7 +150,6 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
154
  except Exception:
155
  return ""
156
 
157
-
158
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
159
  try:
160
  import pymupdf as fitz
@@ -175,7 +170,6 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
175
  except Exception:
176
  return ""
177
 
178
-
179
  def ocr_zone(image_path, y_start_frac, y_end_frac):
180
  zone_name = "header" if y_end_frac < 0.5 else "footer"
181
  try:
@@ -221,7 +215,6 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
221
  log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True)
222
  return ""
223
 
224
-
225
  def fix_account_number(hdr: str) -> str:
226
  if not hdr:
227
  return hdr
@@ -236,7 +229,6 @@ def fix_account_number(hdr: str) -> str:
236
  hdr = hdr[len(acct):].lstrip()
237
  return hdr
238
 
239
-
240
  # --------------------------
241
  # Table stabilization helpers
242
  # --------------------------
@@ -256,7 +248,6 @@ def close_unclosed_html(md: str) -> str:
256
  md += ("</%s>" % tag) * (opened - closed)
257
  return md
258
 
259
-
260
  def looks_like_markdown_table(block: str) -> bool:
261
  lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
262
  if len(lines) < 2:
@@ -266,7 +257,6 @@ def looks_like_markdown_table(block: str) -> bool:
266
  sep = lines[1].replace(" ", "")
267
  return ("---" in sep) and ("|" in sep)
268
 
269
-
270
  def md_table_to_html(block: str) -> str:
271
  lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
272
  if len(lines) < 2:
@@ -292,7 +282,6 @@ def md_table_to_html(block: str) -> str:
292
  html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>")
293
  return "<table>\n" + "\n".join(html_rows) + "\n</table>"
294
 
295
-
296
  def normalize_money_glyphs(text: str) -> str:
297
  if not text:
298
  return text
@@ -310,7 +299,6 @@ def normalize_money_glyphs(text: str) -> str:
310
  t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
311
  return t
312
 
313
-
314
  # ---- Generic HTML table normalizer ----
315
  class TableGridParser(HTMLParser):
316
  """Parse a <table> into rows of (text, colspan, rowspan)."""
@@ -346,7 +334,6 @@ class TableGridParser(HTMLParser):
346
  if self._in_cell:
347
  self._cell_text.append(data)
348
 
349
-
350
  def _build_grid(rows_data):
351
  if not rows_data:
352
  return []
@@ -372,7 +359,6 @@ def _build_grid(rows_data):
372
  row.append("")
373
  return grid
374
 
375
-
376
  def _grid_to_html(grid):
377
  if not grid:
378
  return ""
@@ -393,13 +379,11 @@ def _grid_to_html(grid):
393
  lines.append("</table>")
394
  return "\n".join(lines)
395
 
396
-
397
  def _is_amount_like(s: str) -> bool:
398
  if not s:
399
  return False
400
  return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
401
 
402
-
403
  def _drop_truly_empty_columns(grid):
404
  """
405
  Drop columns that are empty in header AND almost always empty in body.
@@ -438,7 +422,6 @@ def _drop_truly_empty_columns(grid):
438
  new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
439
  return new_grid
440
 
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
@@ -502,7 +485,6 @@ def _merge_blank_header_text_columns(grid):
502
  new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
503
  return new_grid
504
 
505
-
506
  # --------------------------
507
  # Shared keyword definitions
508
  # --------------------------
@@ -588,7 +570,6 @@ _HEADER_KW_EXACT_RE = re.compile(
588
  # to be considered a misplaced header row.
589
  _HEADER_ROW_KEYWORD_THRESHOLD = 0.5
590
 
591
-
592
  # --------------------------
593
  # Fix 1: Fused-header recovery
594
  # --------------------------
@@ -607,7 +588,6 @@ def _split_keyword_remainder(cell_text: str):
607
  return keyword, remainder
608
  return s, ""
609
 
610
-
611
  def _extract_fused_header_artifacts(header_row):
612
  """
613
  Generic detector for the OCR artifact where the first data row of a table
@@ -661,7 +641,6 @@ def _extract_fused_header_artifacts(header_row):
661
 
662
  return list(header_row), None
663
 
664
-
665
  def _clean_header_artifacts(grid):
666
  """
667
  Entry point for Fix 1 called from normalize_html_tables.
@@ -674,7 +653,6 @@ def _clean_header_artifacts(grid):
674
  grid[0] = cleaned_header
675
  return grid, recovered_row
676
 
677
-
678
  # --------------------------
679
  # Fix 2: Misplaced header row promotion
680
  # --------------------------
@@ -702,7 +680,6 @@ def _row_keyword_score(row):
702
 
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(
@@ -713,7 +690,6 @@ _BALANCE_INFO_RE = re.compile(
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
@@ -725,7 +701,6 @@ def _header_contains_balance_info(header_row):
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
@@ -826,7 +801,6 @@ def _promote_misplaced_header_row(grid):
826
 
827
  return new_grid
828
 
829
-
830
  # --------------------------
831
  # Fix 3: Fused key-value rows in summary sections
832
  # --------------------------
@@ -840,7 +814,6 @@ _FUSED_KV_RE = re.compile(
840
  re.DOTALL,
841
  )
842
 
843
-
844
  def _fix_fused_keyvalue_rows(grid):
845
  """
846
  Fix rows where label+value are fused into the first cell with all other
@@ -930,7 +903,6 @@ def _fix_fused_keyvalue_rows(grid):
930
 
931
  return new_grid
932
 
933
-
934
  # --------------------------
935
  # Fix 4: PDF text-layer row-count patch
936
  # --------------------------
@@ -1074,6 +1046,40 @@ def _extract_textlayer_rows(pdf_path: str, page_num: int):
1074
  return []
1075
 
1076
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1077
  def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str:
1078
  """
1079
  Parse Johnson Bank Checks and Daily Account Balance from pdfminer column-separated
@@ -1141,6 +1147,9 @@ def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, nee
1141
  break
1142
  i += 1
1143
 
 
 
 
1144
  if checks:
1145
  rows = "\n".join(
1146
  "<tr><td>" + d + "</td><td>" + n + "</td><td>" + a + "</td></tr>"
@@ -1148,7 +1157,7 @@ def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, nee
1148
  )
1149
  output.append("Checks")
1150
  output.append(
1151
- "<table>\n<tr><th>Date</th><th>Number</th><th>Amount</th></tr>\n"
1152
  + rows + "\n</table>"
1153
  )
1154
 
@@ -1429,8 +1438,6 @@ def _patch_ocr_with_textlayer(page_md: str, pdf_path: str, page_num: int) -> str
1429
  log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
1430
  return page_md # always safe β€” return original on any error
1431
 
1432
-
1433
-
1434
  # --------------------------
1435
  # Fix 5: Mid-table separator row reconstruction
1436
  # --------------------------
@@ -1443,7 +1450,6 @@ _SPLIT_AMOUNT_RE = re.compile(
1443
  r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
1444
  )
1445
 
1446
-
1447
  def _reconstruct_separator_rows(grid):
1448
  """
1449
  Detect and reconstruct mid-table separator / label rows that OCR has
@@ -1643,7 +1649,6 @@ def _merge_split_rows(grid):
1643
 
1644
  return new_grid
1645
 
1646
-
1647
  def _extract_fused_desc_amount(grid):
1648
  """
1649
  Fix OCR artifact where a trailing money amount gets fused into the
@@ -1707,7 +1712,6 @@ def _extract_fused_desc_amount(grid):
1707
 
1708
  return new_grid
1709
 
1710
-
1711
  def _is_junk_table(grid):
1712
  """
1713
  Detect and suppress known junk tables that are OCR artifacts from
@@ -1732,7 +1736,6 @@ def _is_junk_table(grid):
1732
  return True
1733
  return False
1734
 
1735
-
1736
  def _split_fused_multicolumn_header(grid):
1737
  """
1738
  Fix OCR artifact where multiple column headers are fused into one <th> cell.
@@ -1851,7 +1854,6 @@ def _split_fused_multicolumn_header(grid):
1851
 
1852
  return new_grid
1853
 
1854
-
1855
  def _normalize_daily_balance_table(grid):
1856
  """
1857
  Fix OCR artifact in DAILY BALANCE SUMMARY tables where:
@@ -1929,7 +1931,6 @@ def _normalize_daily_balance_table(grid):
1929
 
1930
  return [new_header] + new_rows
1931
 
1932
-
1933
  def _normalize_checks_paid_table(grid):
1934
  """
1935
  Fix OCR artifact in CHECKS PAID SUMMARY tables where:
@@ -2007,7 +2008,6 @@ def _normalize_checks_paid_table(grid):
2007
 
2008
  return new_rows
2009
 
2010
-
2011
  # ── Johnson Bank plain-text section converter ─────────────────────────────
2012
  # Johnson Bank formats Deposits, Withdrawals, Checks, and Daily Account Balance
2013
  # as plain text columns (no HTML tables). This converter detects those sections
@@ -2035,7 +2035,6 @@ _JB_DATE_DD = re.compile(r"^\d{2}-\d{2}\s+")
2035
  _JB_TXN_ROW = re.compile(r"^(\d{2}-\d{2})\s+(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
2036
  _JB_BAL_ROW = re.compile(r"^(\d{2}-\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
2037
 
2038
-
2039
  def _jb_rows_to_html(header_cols, rows):
2040
  th = "".join("<th>" + h + "</th>" for h in header_cols)
2041
  parts = ["<table>", "<tr>" + th + "</tr>"]
@@ -2045,12 +2044,10 @@ def _jb_rows_to_html(header_cols, rows):
2045
  parts.append("</table>")
2046
  return "\n".join(parts)
2047
 
2048
-
2049
  def _jb_section_has_table(lines, start, end):
2050
  """Return True if any line in lines[start:end] contains a <table tag."""
2051
  return any("<table" in l.lower() for l in lines[start:end])
2052
 
2053
-
2054
  def _find_first_data_row(lines, start):
2055
  """Return index of first non-blank line after start, or None."""
2056
  for i in range(start, min(start + 20, len(lines))):
@@ -2058,7 +2055,6 @@ def _find_first_data_row(lines, start):
2058
  return i
2059
  return None
2060
 
2061
-
2062
  def convert_plaintext_bank_sections(text: str) -> str:
2063
  """
2064
  Convert Johnson Bank plain-text transaction sections to HTML tables.
@@ -2192,7 +2188,6 @@ def convert_plaintext_bank_sections(text: str) -> str:
2192
 
2193
  return "\n".join(out)
2194
 
2195
-
2196
  def normalize_html_tables(text: str) -> str:
2197
  """
2198
  For every <table>...</table>:
@@ -2269,7 +2264,6 @@ def normalize_html_tables(text: str) -> str:
2269
  out.append(text[last:])
2270
  return "".join(out)
2271
 
2272
-
2273
  def stabilize_tables_and_text(page_md: str) -> str:
2274
  if not page_md:
2275
  return page_md
@@ -2289,7 +2283,6 @@ def stabilize_tables_and_text(page_md: str) -> str:
2289
  stabilized = normalize_html_tables(stabilized)
2290
  return close_unclosed_html(stabilized)
2291
 
2292
-
2293
  # --------------------------
2294
  # PDF rendering with padding
2295
  # --------------------------
@@ -2329,7 +2322,6 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
2329
  doc.close()
2330
  return page_images, page_heights
2331
 
2332
-
2333
  # --------------------------
2334
  # GLM-OCR result extraction
2335
  # --------------------------
@@ -2350,7 +2342,6 @@ def get_page_md_and_regions(page_result):
2350
  regions = r.get("regions") or []
2351
  return md, regions
2352
 
2353
-
2354
  # --------------------------
2355
  # Main entry
2356
  # --------------------------
@@ -2414,7 +2405,7 @@ def run_ocr(uploaded_file):
2414
  # Run OUTSIDE _patch_ocr_with_textlayer so exceptions there don't block it.
2415
  if is_pdf:
2416
  try:
2417
- needs_checks = "checks" not in stabilized.lower() or "<th>Number</th>" not in stabilized
2418
  needs_dab = "daily account balance" not in stabilized.lower()
2419
  if needs_checks or needs_dab:
2420
  # Try pdfminer first (column-aware), fall back to pymupdf
@@ -2495,7 +2486,6 @@ def run_ocr(uploaded_file):
2495
  except Exception:
2496
  pass
2497
 
2498
-
2499
  with gr.Blocks(title="GLM-OCR") as demo:
2500
  gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
2501
  file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
 
83
 
84
  _parser = None
85
 
 
86
  def get_parser():
87
  global _parser
88
  if _parser is None:
 
90
  _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
91
  return _parser
92
 
 
93
  # ---------------------------------------------------------------------------
94
  # Best-effort config tweaks (safe to fail on read-only HF env)
95
  # ---------------------------------------------------------------------------
 
121
  except Exception:
122
  pass
123
 
 
124
  # --------------------------
125
  # Header/footer helpers
126
  # --------------------------
 
137
  return None, None
138
  return min(y_tops) / norm_height, max(y_bottoms) / norm_height
139
 
 
140
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
141
  try:
142
  import pymupdf as fitz
 
150
  except Exception:
151
  return ""
152
 
 
153
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
154
  try:
155
  import pymupdf as fitz
 
170
  except Exception:
171
  return ""
172
 
 
173
  def ocr_zone(image_path, y_start_frac, y_end_frac):
174
  zone_name = "header" if y_end_frac < 0.5 else "footer"
175
  try:
 
215
  log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True)
216
  return ""
217
 
 
218
  def fix_account_number(hdr: str) -> str:
219
  if not hdr:
220
  return hdr
 
229
  hdr = hdr[len(acct):].lstrip()
230
  return hdr
231
 
 
232
  # --------------------------
233
  # Table stabilization helpers
234
  # --------------------------
 
248
  md += ("</%s>" % tag) * (opened - closed)
249
  return md
250
 
 
251
  def looks_like_markdown_table(block: str) -> bool:
252
  lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
253
  if len(lines) < 2:
 
257
  sep = lines[1].replace(" ", "")
258
  return ("---" in sep) and ("|" in sep)
259
 
 
260
  def md_table_to_html(block: str) -> str:
261
  lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
262
  if len(lines) < 2:
 
282
  html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>")
283
  return "<table>\n" + "\n".join(html_rows) + "\n</table>"
284
 
 
285
  def normalize_money_glyphs(text: str) -> str:
286
  if not text:
287
  return text
 
299
  t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
300
  return t
301
 
 
302
  # ---- Generic HTML table normalizer ----
303
  class TableGridParser(HTMLParser):
304
  """Parse a <table> into rows of (text, colspan, rowspan)."""
 
334
  if self._in_cell:
335
  self._cell_text.append(data)
336
 
 
337
  def _build_grid(rows_data):
338
  if not rows_data:
339
  return []
 
359
  row.append("")
360
  return grid
361
 
 
362
  def _grid_to_html(grid):
363
  if not grid:
364
  return ""
 
379
  lines.append("</table>")
380
  return "\n".join(lines)
381
 
 
382
  def _is_amount_like(s: str) -> bool:
383
  if not s:
384
  return False
385
  return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s.strip()) is not None
386
 
 
387
  def _drop_truly_empty_columns(grid):
388
  """
389
  Drop columns that are empty in header AND almost always empty in body.
 
422
  new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
423
  return new_grid
424
 
 
425
  def _merge_blank_header_text_columns(grid):
426
  """
427
  If the header row has blank columns, and most body rows have non-empty *text* in that
 
485
  new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
486
  return new_grid
487
 
 
488
  # --------------------------
489
  # Shared keyword definitions
490
  # --------------------------
 
570
  # to be considered a misplaced header row.
571
  _HEADER_ROW_KEYWORD_THRESHOLD = 0.5
572
 
 
573
  # --------------------------
574
  # Fix 1: Fused-header recovery
575
  # --------------------------
 
588
  return keyword, remainder
589
  return s, ""
590
 
 
591
  def _extract_fused_header_artifacts(header_row):
592
  """
593
  Generic detector for the OCR artifact where the first data row of a table
 
641
 
642
  return list(header_row), None
643
 
 
644
  def _clean_header_artifacts(grid):
645
  """
646
  Entry point for Fix 1 called from normalize_html_tables.
 
653
  grid[0] = cleaned_header
654
  return grid, recovered_row
655
 
 
656
  # --------------------------
657
  # Fix 2: Misplaced header row promotion
658
  # --------------------------
 
680
 
681
  return keyword_hits / len(non_empty)
682
 
 
683
  # Pattern: header cell contains balance/account info β€” do NOT promote away from it.
684
  # Matches things like "Beginning Balance:", "Ending Balance:", account numbers, "$20.48$3.46"
685
  _BALANCE_INFO_RE = re.compile(
 
690
  re.IGNORECASE,
691
  )
692
 
 
693
  def _header_contains_balance_info(header_row):
694
  """
695
  Return True if any cell in the header row contains balance or account
 
701
  return True
702
  return False
703
 
 
704
  def _promote_misplaced_header_row(grid):
705
  """
706
  Detects and fixes the OCR artifact where real column headers land in a
 
801
 
802
  return new_grid
803
 
 
804
  # --------------------------
805
  # Fix 3: Fused key-value rows in summary sections
806
  # --------------------------
 
814
  re.DOTALL,
815
  )
816
 
 
817
  def _fix_fused_keyvalue_rows(grid):
818
  """
819
  Fix rows where label+value are fused into the first cell with all other
 
903
 
904
  return new_grid
905
 
 
906
  # --------------------------
907
  # Fix 4: PDF text-layer row-count patch
908
  # --------------------------
 
1046
  return []
1047
 
1048
 
1049
+ def _jb_extract_checks_fallback(text_layer: str) -> List[Tuple[str, str, str]]:
1050
+ """
1051
+ Johnson Bank: when pdfminer column extraction fails (e.g. pymupdf line text or
1052
+ multi-column layout), extract (date, check#, amount) triplets from the Checks
1053
+ subsection. Anchored by 'Checks (' ... 'Daily Account Balance' only.
1054
+ """
1055
+ if not text_layer:
1056
+ return []
1057
+ m = re.search(
1058
+ r"Checks\s*\([^\n]*\n(.*?)(?=^Daily\s+Account\s+Balance\s*$)",
1059
+ text_layer,
1060
+ re.DOTALL | re.MULTILINE | re.IGNORECASE,
1061
+ )
1062
+ if not m:
1063
+ return []
1064
+ chunk = m.group(1)
1065
+ chunk = re.sub(
1066
+ r"^\s*Date\s+Number\s+Amount\s*$",
1067
+ "",
1068
+ chunk,
1069
+ flags=re.MULTILINE | re.IGNORECASE,
1070
+ )
1071
+ _triplet = re.compile(
1072
+ r"(\d{2}-\d{2})\s+(\*?\d+)\s+(\d{1,3}(?:,\d{3})*\.\d{2})"
1073
+ )
1074
+ out: List[Tuple[str, str, str]] = []
1075
+ seen = set()
1076
+ for t in _triplet.findall(chunk):
1077
+ if t not in seen:
1078
+ seen.add(t)
1079
+ out.append(t)
1080
+ return out
1081
+
1082
+
1083
  def _extract_jb_summary_sections(text_layer: str, needs_checks: bool = True, needs_dab: bool = True) -> str:
1084
  """
1085
  Parse Johnson Bank Checks and Daily Account Balance from pdfminer column-separated
 
1147
  break
1148
  i += 1
1149
 
1150
+ if not checks and re.search(r"\bChecks\s*\(", text_layer, re.I):
1151
+ checks = _jb_extract_checks_fallback(text_layer)
1152
+
1153
  if checks:
1154
  rows = "\n".join(
1155
  "<tr><td>" + d + "</td><td>" + n + "</td><td>" + a + "</td></tr>"
 
1157
  )
1158
  output.append("Checks")
1159
  output.append(
1160
+ '<table class="jb-summary-checks">\n<tr><th>Date</th><th>Number</th><th>Amount</th></tr>\n'
1161
  + rows + "\n</table>"
1162
  )
1163
 
 
1438
  log.warning("_patch_ocr_with_textlayer failed (page %d): %s", page_num, e)
1439
  return page_md # always safe β€” return original on any error
1440
 
 
 
1441
  # --------------------------
1442
  # Fix 5: Mid-table separator row reconstruction
1443
  # --------------------------
 
1450
  r"^-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$|^\$-?\d{1,3}(?:,\d{3})*(?:\.\d{1,4})?$"
1451
  )
1452
 
 
1453
  def _reconstruct_separator_rows(grid):
1454
  """
1455
  Detect and reconstruct mid-table separator / label rows that OCR has
 
1649
 
1650
  return new_grid
1651
 
 
1652
  def _extract_fused_desc_amount(grid):
1653
  """
1654
  Fix OCR artifact where a trailing money amount gets fused into the
 
1712
 
1713
  return new_grid
1714
 
 
1715
  def _is_junk_table(grid):
1716
  """
1717
  Detect and suppress known junk tables that are OCR artifacts from
 
1736
  return True
1737
  return False
1738
 
 
1739
  def _split_fused_multicolumn_header(grid):
1740
  """
1741
  Fix OCR artifact where multiple column headers are fused into one <th> cell.
 
1854
 
1855
  return new_grid
1856
 
 
1857
  def _normalize_daily_balance_table(grid):
1858
  """
1859
  Fix OCR artifact in DAILY BALANCE SUMMARY tables where:
 
1931
 
1932
  return [new_header] + new_rows
1933
 
 
1934
  def _normalize_checks_paid_table(grid):
1935
  """
1936
  Fix OCR artifact in CHECKS PAID SUMMARY tables where:
 
2008
 
2009
  return new_rows
2010
 
 
2011
  # ── Johnson Bank plain-text section converter ─────────────────────────────
2012
  # Johnson Bank formats Deposits, Withdrawals, Checks, and Daily Account Balance
2013
  # as plain text columns (no HTML tables). This converter detects those sections
 
2035
  _JB_TXN_ROW = re.compile(r"^(\d{2}-\d{2})\s+(.+?)\s+(-?\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
2036
  _JB_BAL_ROW = re.compile(r"^(\d{2}-\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s*$")
2037
 
 
2038
  def _jb_rows_to_html(header_cols, rows):
2039
  th = "".join("<th>" + h + "</th>" for h in header_cols)
2040
  parts = ["<table>", "<tr>" + th + "</tr>"]
 
2044
  parts.append("</table>")
2045
  return "\n".join(parts)
2046
 
 
2047
  def _jb_section_has_table(lines, start, end):
2048
  """Return True if any line in lines[start:end] contains a <table tag."""
2049
  return any("<table" in l.lower() for l in lines[start:end])
2050
 
 
2051
  def _find_first_data_row(lines, start):
2052
  """Return index of first non-blank line after start, or None."""
2053
  for i in range(start, min(start + 20, len(lines))):
 
2055
  return i
2056
  return None
2057
 
 
2058
  def convert_plaintext_bank_sections(text: str) -> str:
2059
  """
2060
  Convert Johnson Bank plain-text transaction sections to HTML tables.
 
2188
 
2189
  return "\n".join(out)
2190
 
 
2191
  def normalize_html_tables(text: str) -> str:
2192
  """
2193
  For every <table>...</table>:
 
2264
  out.append(text[last:])
2265
  return "".join(out)
2266
 
 
2267
  def stabilize_tables_and_text(page_md: str) -> str:
2268
  if not page_md:
2269
  return page_md
 
2283
  stabilized = normalize_html_tables(stabilized)
2284
  return close_unclosed_html(stabilized)
2285
 
 
2286
  # --------------------------
2287
  # PDF rendering with padding
2288
  # --------------------------
 
2322
  doc.close()
2323
  return page_images, page_heights
2324
 
 
2325
  # --------------------------
2326
  # GLM-OCR result extraction
2327
  # --------------------------
 
2342
  regions = r.get("regions") or []
2343
  return md, regions
2344
 
 
2345
  # --------------------------
2346
  # Main entry
2347
  # --------------------------
 
2405
  # Run OUTSIDE _patch_ocr_with_textlayer so exceptions there don't block it.
2406
  if is_pdf:
2407
  try:
2408
+ needs_checks = 'class="jb-summary-checks"' not in stabilized
2409
  needs_dab = "daily account balance" not in stabilized.lower()
2410
  if needs_checks or needs_dab:
2411
  # Try pdfminer first (column-aware), fall back to pymupdf
 
2486
  except Exception:
2487
  pass
2488
 
 
2489
  with gr.Blocks(title="GLM-OCR") as demo:
2490
  gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
2491
  file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])