rehan953 commited on
Commit
7ce9acd
·
verified ·
1 Parent(s): d525cbd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +145 -139
app.py CHANGED
@@ -9,7 +9,7 @@ Primary goal for reconcile rate:
9
  - keep tables as tables (convert markdown pipe tables -> HTML table)
10
  - return ---page-separator--- between pages
11
  - normalize HTML tables so header/data columns align (expand colspan/rowspan)
12
- - clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
13
  """
14
 
15
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
@@ -51,39 +51,29 @@ FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
51
  # HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
52
  # ============================================================
53
 
54
- # 1) GLM-OCR MaaS API key
55
- # IMPORTANT: Do NOT hard-code secrets in a public Space.
56
- # If your Space is public, switch to HF Secrets instead.
57
  GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
58
 
59
- # 2) Render quality (higher = better OCR for small/right-aligned digits; slower)
60
  RENDER_SCALE = 2.2 # try 2.5 if Balance column is still missing
61
 
62
- # 3) Add padding to protect columns near edges (Balance is usually right-most)
63
  PAD_LEFT_FRAC = 0.02
64
- PAD_RIGHT_FRAC = 0.06 # try 0.10 if right-most balances are missing
65
  PAD_TOP_FRAC = 0.01
66
  PAD_BOTTOM_FRAC = 0.01
67
 
68
- # 4) Mild contrast boost (helps faint gray text)
69
  ENABLE_CONTRAST = True
70
 
71
- # 5) Header/footer band heuristics
72
- DEFAULT_ZONE_FRAC = 0.12 # OCR band for header (top 12%) when regions not available
73
- PDF_HEADER_BAND_FRAC = 0.10 # PDF text fallback: take top 10% words if clip returns empty
74
 
75
- # Footer: disabled by default to avoid duplicating what GLM already returns
76
  ENABLE_FOOTER_OCR = False
77
- PDF_FOOTER_BAND_FRAC = 0.88 # bottom 12% (if footer enabled)
78
 
79
- # MaaS minimum image sizes for crops; we pad if needed
80
  MIN_CROP_HEIGHT = 112
81
  MIN_CROP_PIXELS = 112 * 112
82
 
83
  # ============================================================
84
 
85
 
86
- # Single shared parser to avoid re-init per request
87
  _parser = None
88
 
89
  def get_parser():
@@ -110,7 +100,6 @@ try:
110
  except Exception:
111
  pass
112
 
113
- # Best-effort formatter tweak: avoid stripping header/footer labels
114
  try:
115
  with open(FORMATTER_PATH, "r") as f:
116
  source = f.read()
@@ -128,7 +117,6 @@ except Exception:
128
  # Header/footer helpers
129
  # --------------------------
130
  def get_header_footer_zones(regions, norm_height=1000):
131
- """Infer header/footer extents from bbox regions if present."""
132
  if not regions:
133
  return None, None
134
  y_tops, y_bottoms = [], []
@@ -143,7 +131,6 @@ def get_header_footer_zones(regions, norm_height=1000):
143
 
144
 
145
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
146
- """Extract text from a horizontal band using a clip rect (works if PDF has text layer)."""
147
  try:
148
  import pymupdf as fitz
149
  doc = fitz.open(pdf_path)
@@ -158,7 +145,6 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
158
 
159
 
160
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
161
- """Extract words whose bbox intersects a vertical band (robust fallback)."""
162
  try:
163
  import pymupdf as fitz
164
  doc = fitz.open(pdf_path)
@@ -180,7 +166,6 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
180
 
181
 
182
  def ocr_zone(image_path, y_start_frac, y_end_frac):
183
- """Run OCR on a horizontal band. Pads small crops to meet MaaS minimum size."""
184
  zone_name = "header" if y_end_frac < 0.5 else "footer"
185
  try:
186
  from PIL import Image
@@ -227,7 +212,6 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
227
 
228
 
229
  def fix_account_number(hdr: str) -> str:
230
- """Fix common account-number formatting issues."""
231
  if not hdr:
232
  return hdr
233
  if "Account Number:" in hdr and "Account Number: " not in hdr:
@@ -246,7 +230,6 @@ def fix_account_number(hdr: str) -> str:
246
  # Table stabilization helpers
247
  # --------------------------
248
  def close_unclosed_html(md: str) -> str:
249
- """Close unclosed <table>/<tr>/<td> tags to prevent bleed."""
250
  if not md:
251
  return md
252
  open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
@@ -263,46 +246,58 @@ def close_unclosed_html(md: str) -> str:
263
  return md
264
 
265
 
266
- def clean_table_header_artifacts(text: str) -> str:
267
- """
268
- Generic cleanup for common OCR artifacts in table headers:
269
- - 'DESCRIPTIONBeginning Balance' -> 'DESCRIPTION'
270
- - 'DESCRIPTIONEnding Balance' -> 'DESCRIPTION'
271
- - 'BALANCE$3,447.10' -> 'BALANCE'
272
- - 'BALANCE 3,447.10' -> 'BALANCE'
273
- Works for any PDF; no bank-specific logic.
274
- """
275
- if not text:
276
- return text
277
 
278
- # DESCRIPTION + (Beginning/Ending Balance) glued
279
- text = re.sub(
280
- r"(>[^<]*\bDESCRIPTION)\s*(Beginning Balance|Ending Balance)\b([^<]*<)",
281
- r"\1\3",
282
- text,
283
- flags=re.IGNORECASE,
284
- )
285
 
286
- # BALANCE with a number glued or appended (keep the word BALANCE only)
287
- text = re.sub(
288
- r"(>[^<]*\bBALANCE)\s*\$?\s*\d{1,3}(?:,\d{3})*(?:\.\d{2})?\s*([^<]*<)",
289
- r"\1\2",
290
- text,
291
- flags=re.IGNORECASE,
292
- )
293
- text = re.sub(
294
- r"(>[^<]*\bBALANCE)\$",
295
- r"\1",
296
- text,
297
- flags=re.IGNORECASE,
298
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
 
300
- return text
 
 
 
 
301
 
302
 
303
- # ---- Colspan/rowspan expansion: align header and data rows for any PDF ----
304
  class TableGridParser(HTMLParser):
305
- """Parse <table> HTML into rows of (text, colspan, rowspan)."""
306
 
307
  def __init__(self):
308
  super().__init__()
@@ -311,6 +306,7 @@ class TableGridParser(HTMLParser):
311
  self._cell_text = []
312
  self._colspan = 1
313
  self._rowspan = 1
 
314
 
315
  def handle_starttag(self, tag, attrs):
316
  if tag == "tr":
@@ -320,20 +316,22 @@ class TableGridParser(HTMLParser):
320
  self._colspan = max(1, int(attrs_d.get("colspan", 1)))
321
  self._rowspan = max(1, int(attrs_d.get("rowspan", 1)))
322
  self._cell_text = []
 
323
 
324
  def handle_endtag(self, tag):
325
  if tag in ("td", "th"):
326
  text = "".join(self._cell_text).strip().replace("\n", " ")
327
  self._current_row.append((text, self._colspan, self._rowspan))
 
328
  elif tag == "tr":
329
  self.rows.append(self._current_row)
330
 
331
  def handle_data(self, data):
332
- self._cell_text.append(data)
 
333
 
334
 
335
  def _build_grid(rows_data):
336
- """Expand colspan/rowspan into a rectangular grid."""
337
  if not rows_data:
338
  return []
339
  blocked = defaultdict(set)
@@ -360,13 +358,12 @@ def _build_grid(rows_data):
360
 
361
 
362
  def _grid_to_html(grid):
363
- """Emit a normalized table without colspan/rowspan."""
364
  if not grid:
365
  return ""
366
  lines = ["<table>"]
367
- for i, row in enumerate(grid):
368
  lines.append("<tr>")
369
- tag = "th" if i == 0 else "td"
370
  for cell in row:
371
  escaped = (cell or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
372
  lines.append(f"<{tag}>{escaped}</{tag}>")
@@ -375,90 +372,107 @@ def _grid_to_html(grid):
375
  return "\n".join(lines)
376
 
377
 
378
- def expand_colspan_rowspan_in_tables(text):
379
- """Normalize every <table>...</table> in text."""
380
- if not text or "<table" not in text.lower():
381
- return text
382
-
383
- pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
384
- result = []
385
- last_end = 0
386
 
387
- for match in pattern.finditer(text):
388
- result.append(text[last_end : match.start()])
389
- table_html = match.group(0)
390
- try:
391
- parser = TableGridParser()
392
- parser.feed(table_html)
393
- if parser.rows:
394
- grid = _build_grid(parser.rows)
395
- result.append(_grid_to_html(grid))
396
- else:
397
- result.append(table_html)
398
- except Exception:
399
- result.append(table_html)
400
- last_end = match.end()
401
 
402
- result.append(text[last_end:])
403
- return "".join(result)
 
 
 
 
 
 
 
 
 
404
 
 
 
405
 
406
- def looks_like_markdown_table(block: str) -> bool:
407
- """Detect simple markdown pipe tables."""
408
- lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
409
- if len(lines) < 2:
410
- return False
411
- if "|" not in lines[0]:
412
- return False
413
- sep = lines[1].replace(" ", "")
414
- return ("---" in sep) and ("|" in sep)
415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
- def md_table_to_html(block: str) -> str:
418
- """Convert a simple markdown pipe table to HTML table (best-effort)."""
419
- lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
420
- if len(lines) < 2:
421
- return block
 
 
 
 
 
 
 
 
 
 
422
 
423
- def split_row(row: str):
424
- row = row.strip()
425
- if row.startswith("|"):
426
- row = row[1:]
427
- if row.endswith("|"):
428
- row = row[:-1]
429
- return [p.strip() for p in row.split("|")]
430
 
431
- header = split_row(lines[0])
432
- body_lines = [ln for ln in lines[2:] if "|" in ln]
433
 
434
- html_rows = []
435
- html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
436
- for ln in body_lines:
437
- cols = split_row(ln)
438
- if len(cols) < len(header):
439
- cols += [""] * (len(header) - len(cols))
440
- html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[:len(header)]) + "</tr>")
441
- return "<table>\n" + "\n".join(html_rows) + "\n</table>"
 
 
442
 
 
 
 
443
 
444
- def normalize_money_glyphs(text: str) -> str:
445
- """Conservative normalization for OCR number quirks."""
446
- if not text:
447
- return text
448
- t = text.replace("−", "-").replace("–", "-").replace("—", "-")
449
- t = re.sub(r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)", r"-\1\2", t)
 
 
 
 
 
 
450
 
451
- def o_to_zero(m):
452
- token = m.group(0)
453
- return token.replace("O", "0").replace("o", "0")
454
- t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
455
- return t
456
 
457
 
458
  def stabilize_tables_and_text(page_md: str) -> str:
459
- """Convert markdown pipe tables to HTML, clean header artifacts, normalize tables, and close tags."""
460
  if not page_md:
461
  return page_md
 
462
  page_md = normalize_money_glyphs(page_md)
463
 
464
  blocks = re.split(r"\n\s*\n", page_md.strip())
@@ -471,13 +485,10 @@ def stabilize_tables_and_text(page_md: str) -> str:
471
 
472
  stabilized = "\n\n".join(out_blocks)
473
 
474
- # 1) Fix common header text artifacts
475
- stabilized = clean_table_header_artifacts(stabilized)
476
-
477
- # 2) Normalize tables to a rectangular grid (expand colspan/rowspan)
478
- stabilized = expand_colspan_rowspan_in_tables(stabilized)
479
 
480
- # 3) Close any unclosed tags to prevent bleed
481
  return close_unclosed_html(stabilized)
482
 
483
 
@@ -577,13 +588,11 @@ def run_ocr(uploaded_file):
577
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
578
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
579
 
580
- # clamp
581
  he = max(0.02, min(0.25, he))
582
  fs = max(0.75, min(0.98, fs))
583
 
584
  parts = []
585
 
586
- # Header inclusion: PDF text -> band words -> OCR band
587
  hdr = ""
588
  if is_pdf:
589
  hdr = extract_zone_text_pdf(path, page_num, 0, he)
@@ -594,11 +603,9 @@ def run_ocr(uploaded_file):
594
  if hdr and hdr.strip():
595
  parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
596
 
597
- # Main OCR markdown, stabilized
598
  if page_md and page_md.strip():
599
  parts.append(stabilize_tables_and_text(page_md.strip()))
600
 
601
- # Optional footer
602
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
603
  ftr = ""
604
  if is_pdf:
@@ -621,7 +628,6 @@ def run_ocr(uploaded_file):
621
  return f"Error: {e}\n\n{traceback.format_exc()}"
622
 
623
  finally:
624
- # cleanup rendered images
625
  for p in page_images:
626
  try:
627
  if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
 
9
  - keep tables as tables (convert markdown pipe tables -> HTML table)
10
  - return ---page-separator--- between pages
11
  - normalize HTML tables so header/data columns align (expand colspan/rowspan)
12
+ - generically repair "Beginning Balance" when OCR glues it into header (e.g. Chase)
13
  """
14
 
15
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
 
51
  # HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
52
  # ============================================================
53
 
 
 
 
54
  GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
55
 
 
56
  RENDER_SCALE = 2.2 # try 2.5 if Balance column is still missing
57
 
 
58
  PAD_LEFT_FRAC = 0.02
59
+ PAD_RIGHT_FRAC = 0.06
60
  PAD_TOP_FRAC = 0.01
61
  PAD_BOTTOM_FRAC = 0.01
62
 
 
63
  ENABLE_CONTRAST = True
64
 
65
+ DEFAULT_ZONE_FRAC = 0.12
66
+ PDF_HEADER_BAND_FRAC = 0.10
 
67
 
 
68
  ENABLE_FOOTER_OCR = False
69
+ PDF_FOOTER_BAND_FRAC = 0.88
70
 
 
71
  MIN_CROP_HEIGHT = 112
72
  MIN_CROP_PIXELS = 112 * 112
73
 
74
  # ============================================================
75
 
76
 
 
77
  _parser = None
78
 
79
  def get_parser():
 
100
  except Exception:
101
  pass
102
 
 
103
  try:
104
  with open(FORMATTER_PATH, "r") as f:
105
  source = f.read()
 
117
  # Header/footer helpers
118
  # --------------------------
119
  def get_header_footer_zones(regions, norm_height=1000):
 
120
  if not regions:
121
  return None, None
122
  y_tops, y_bottoms = [], []
 
131
 
132
 
133
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
 
134
  try:
135
  import pymupdf as fitz
136
  doc = fitz.open(pdf_path)
 
145
 
146
 
147
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
 
148
  try:
149
  import pymupdf as fitz
150
  doc = fitz.open(pdf_path)
 
166
 
167
 
168
  def ocr_zone(image_path, y_start_frac, y_end_frac):
 
169
  zone_name = "header" if y_end_frac < 0.5 else "footer"
170
  try:
171
  from PIL import Image
 
212
 
213
 
214
  def fix_account_number(hdr: str) -> str:
 
215
  if not hdr:
216
  return hdr
217
  if "Account Number:" in hdr and "Account Number: " not in hdr:
 
230
  # Table stabilization helpers
231
  # --------------------------
232
  def close_unclosed_html(md: str) -> str:
 
233
  if not md:
234
  return md
235
  open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
 
246
  return md
247
 
248
 
249
+ def looks_like_markdown_table(block: str) -> bool:
250
+ lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
251
+ if len(lines) < 2:
252
+ return False
253
+ if "|" not in lines[0]:
254
+ return False
255
+ sep = lines[1].replace(" ", "")
256
+ return ("---" in sep) and ("|" in sep)
 
 
 
257
 
 
 
 
 
 
 
 
258
 
259
+ def md_table_to_html(block: str) -> str:
260
+ lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
261
+ if len(lines) < 2:
262
+ return block
263
+
264
+ def split_row(row: str):
265
+ row = row.strip()
266
+ if row.startswith("|"):
267
+ row = row[1:]
268
+ if row.endswith("|"):
269
+ row = row[:-1]
270
+ return [p.strip() for p in row.split("|")]
271
+
272
+ header = split_row(lines[0])
273
+ body_lines = [ln for ln in lines[2:] if "|" in ln]
274
+
275
+ html_rows = []
276
+ html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
277
+ for ln in body_lines:
278
+ cols = split_row(ln)
279
+ if len(cols) < len(header):
280
+ cols += [""] * (len(header) - len(cols))
281
+ html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[:len(header)]) + "</tr>")
282
+ return "<table>\n" + "\n".join(html_rows) + "\n</table>"
283
+
284
+
285
+ def normalize_money_glyphs(text: str) -> str:
286
+ if not text:
287
+ return text
288
+ t = text.replace("−", "-").replace("–", "-").replace("—", "-")
289
+ t = re.sub(r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)", r"-\1\2", t)
290
 
291
+ def o_to_zero(m):
292
+ token = m.group(0)
293
+ return token.replace("O", "0").replace("o", "0")
294
+ t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
295
+ return t
296
 
297
 
298
+ # ---- Generic HTML table normalizer (colspan/rowspan + beginning-balance repair) ----
299
  class TableGridParser(HTMLParser):
300
+ """Parse a <table> into rows of (text, colspan, rowspan) per cell."""
301
 
302
  def __init__(self):
303
  super().__init__()
 
306
  self._cell_text = []
307
  self._colspan = 1
308
  self._rowspan = 1
309
+ self._in_cell = False
310
 
311
  def handle_starttag(self, tag, attrs):
312
  if tag == "tr":
 
316
  self._colspan = max(1, int(attrs_d.get("colspan", 1)))
317
  self._rowspan = max(1, int(attrs_d.get("rowspan", 1)))
318
  self._cell_text = []
319
+ self._in_cell = True
320
 
321
  def handle_endtag(self, tag):
322
  if tag in ("td", "th"):
323
  text = "".join(self._cell_text).strip().replace("\n", " ")
324
  self._current_row.append((text, self._colspan, self._rowspan))
325
+ self._in_cell = False
326
  elif tag == "tr":
327
  self.rows.append(self._current_row)
328
 
329
  def handle_data(self, data):
330
+ if self._in_cell:
331
+ self._cell_text.append(data)
332
 
333
 
334
  def _build_grid(rows_data):
 
335
  if not rows_data:
336
  return []
337
  blocked = defaultdict(set)
 
358
 
359
 
360
  def _grid_to_html(grid):
 
361
  if not grid:
362
  return ""
363
  lines = ["<table>"]
364
+ for r, row in enumerate(grid):
365
  lines.append("<tr>")
366
+ tag = "th" if r == 0 else "td"
367
  for cell in row:
368
  escaped = (cell or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
369
  lines.append(f"<{tag}>{escaped}</{tag}>")
 
372
  return "\n".join(lines)
373
 
374
 
375
+ def _looks_like_amount(s: str) -> bool:
376
+ if not s:
377
+ return False
378
+ s2 = s.strip()
379
+ # $2,006.07 or 2006.07 or -515.00 etc.
380
+ return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s2) is not None
 
 
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
+ def _normalize_transaction_header_and_beginning_balance(grid):
384
+ """
385
+ Generic fix:
386
+ If the header row contains glued 'Beginning Balance' and/or glued starting balance value,
387
+ extract that starting balance and insert a synthetic first data row:
388
+ ['', 'Beginning Balance', '', '', '<balance>']
389
+ Then clean the header cells to be just DESCRIPTION and BALANCE.
390
+ Works across PDFs because it only triggers when these exact generic artifacts are present.
391
+ """
392
+ if not grid or not grid[0]:
393
+ return grid
394
 
395
+ header = [c.strip() for c in grid[0]]
396
+ header_join = " | ".join(h.upper() for h in header)
397
 
398
+ is_txn_like = ("DATE" in header_join and "AMOUNT" in header_join and "BALANCE" in header_join)
399
+ if not is_txn_like:
400
+ return grid
 
 
 
 
 
 
401
 
402
+ # Find starting balance embedded in header (common OCR artifact: "BALANCE$3,447.10")
403
+ start_balance = None
404
+ for i, cell in enumerate(header):
405
+ m = re.search(r"\bBALANCE\b\s*\$?\s*(-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?)", cell, flags=re.IGNORECASE)
406
+ if m:
407
+ start_balance = m.group(1)
408
+ header[i] = re.sub(r"\bBALANCE\b.*", "BALANCE", cell, flags=re.IGNORECASE).strip() or "BALANCE"
409
+ # Clean DESCRIPTIONBeginning Balance / DESCRIPTIONEnding Balance
410
+ if re.search(r"\bDESCRIPTION\b", cell, flags=re.IGNORECASE) and re.search(r"\bBeginning Balance\b|\bEnding Balance\b", cell, flags=re.IGNORECASE):
411
+ header[i] = "DESCRIPTION"
412
+
413
+ # If header has "Beginning Balance" glued elsewhere, but balance value not found, don't invent a value.
414
+ # Also: if the table already contains a beginning-balance style row, don't add one.
415
+ body = grid[1:] if len(grid) > 1 else []
416
+ has_begin_row = any(
417
+ any(isinstance(c, str) and "beginning balance" in c.lower() for c in row)
418
+ for row in body
419
+ )
420
 
421
+ # Replace header row in grid
422
+ grid[0] = header + ([""] * (len(grid[0]) - len(header))) if len(header) < len(grid[0]) else header[: len(grid[0])]
423
+
424
+ if start_balance and not has_begin_row:
425
+ # Insert synthetic beginning balance row aligned to typical 5-col txn tables
426
+ cols = len(grid[0])
427
+ new_row = [""] * cols
428
+ if cols >= 2:
429
+ new_row[1] = "Beginning Balance"
430
+ if cols >= 1:
431
+ new_row[0] = "" # date empty
432
+ # Put the starting balance in the last column if it exists (balance column)
433
+ new_row[-1] = start_balance
434
+ # Insert after header
435
+ grid.insert(1, new_row)
436
 
437
+ return grid
 
 
 
 
 
 
438
 
 
 
439
 
440
+ def normalize_html_tables(text: str) -> str:
441
+ """
442
+ For every <table>...</table>:
443
+ - parse to grid (expanding colspan/rowspan)
444
+ - normalize transaction headers (remove glued beginning balance artifacts)
445
+ - insert missing 'Beginning Balance' row when it was embedded in header
446
+ - emit normalized <table> HTML without colspan/rowspan
447
+ """
448
+ if not text or "<table" not in text.lower():
449
+ return text
450
 
451
+ pattern = re.compile(r"<table[^>]*>.*?</table>", re.DOTALL | re.IGNORECASE)
452
+ out = []
453
+ last = 0
454
 
455
+ for m in pattern.finditer(text):
456
+ out.append(text[last : m.start()])
457
+ table_html = m.group(0)
458
+ try:
459
+ p = TableGridParser()
460
+ p.feed(table_html)
461
+ grid = _build_grid(p.rows)
462
+ grid = _normalize_transaction_header_and_beginning_balance(grid)
463
+ out.append(_grid_to_html(grid) if grid else table_html)
464
+ except Exception:
465
+ out.append(table_html)
466
+ last = m.end()
467
 
468
+ out.append(text[last:])
469
+ return "".join(out)
 
 
 
470
 
471
 
472
  def stabilize_tables_and_text(page_md: str) -> str:
 
473
  if not page_md:
474
  return page_md
475
+
476
  page_md = normalize_money_glyphs(page_md)
477
 
478
  blocks = re.split(r"\n\s*\n", page_md.strip())
 
485
 
486
  stabilized = "\n\n".join(out_blocks)
487
 
488
+ # Normalize all HTML tables (generic): expand colspan/rowspan + fix header artifacts + re-add beginning balance row when needed
489
+ stabilized = normalize_html_tables(stabilized)
 
 
 
490
 
491
+ # Close tags to prevent bleed
492
  return close_unclosed_html(stabilized)
493
 
494
 
 
588
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
589
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
590
 
 
591
  he = max(0.02, min(0.25, he))
592
  fs = max(0.75, min(0.98, fs))
593
 
594
  parts = []
595
 
 
596
  hdr = ""
597
  if is_pdf:
598
  hdr = extract_zone_text_pdf(path, page_num, 0, he)
 
603
  if hdr and hdr.strip():
604
  parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
605
 
 
606
  if page_md and page_md.strip():
607
  parts.append(stabilize_tables_and_text(page_md.strip()))
608
 
 
609
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
610
  ftr = ""
611
  if is_pdf:
 
628
  return f"Error: {e}\n\n{traceback.format_exc()}"
629
 
630
  finally:
 
631
  for p in page_images:
632
  try:
633
  if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):