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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +151 -70
app.py CHANGED
@@ -4,23 +4,28 @@ and table-structure stabilization for downstream bank-statement pipelines.
4
 
5
  Hard-coded knobs (no environment variables required).
6
 
7
- Primary goal for reconcile rate:
8
  - preserve right-most columns (often "Balance") by higher DPI render + right padding
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
16
  import asyncio
17
  try:
18
  _orig_close = asyncio.BaseEventLoop.close
 
19
  def _safe_close(self):
20
  try:
21
  _orig_close(self)
22
  except (ValueError, OSError):
23
  pass
 
24
  asyncio.BaseEventLoop.close = _safe_close
25
  except Exception:
26
  pass
@@ -36,7 +41,6 @@ from html.parser import HTMLParser
36
 
37
  import yaml
38
  import gradio as gr
39
-
40
  import glmocr
41
 
42
  log = logging.getLogger("glmocr_app")
@@ -46,17 +50,19 @@ GLMOCR_BASE = os.path.dirname(glmocr.__file__)
46
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
47
  FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
48
 
49
-
50
  # ============================================================
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
 
@@ -73,13 +79,14 @@ MIN_CROP_PIXELS = 112 * 112
73
 
74
  # ============================================================
75
 
76
-
77
  _parser = None
78
 
 
79
  def get_parser():
80
  global _parser
81
  if _parser is None:
82
  from glmocr import GlmOcr
 
83
  _parser = GlmOcr(
84
  api_key=GLMOCR_API_KEY,
85
  mode="maas",
@@ -100,10 +107,20 @@ try:
100
  except Exception:
101
  pass
102
 
 
103
  try:
104
  with open(FORMATTER_PATH, "r") as f:
105
  source = f.read()
106
- for label in ('"header"', "'header'", '"footer"', "'footer'", '"doc_header"', "'doc_header'", '"doc_footer"', "'doc_footer'"):
 
 
 
 
 
 
 
 
 
107
  source = re.sub(r",\s*" + re.escape(label), "", source)
108
  source = re.sub(re.escape(label) + r"\s*,", "", source)
109
  source = re.sub(re.escape(label), "", source)
@@ -133,6 +150,7 @@ def get_header_footer_zones(regions, norm_height=1000):
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)
137
  page = doc[page_num]
138
  h, w = page.rect.height, page.rect.width
@@ -147,6 +165,7 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
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)
151
  page = doc[page_num]
152
  h = page.rect.height
@@ -169,6 +188,7 @@ 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
 
172
  img = Image.open(image_path).convert("RGB")
173
  w, h = img.size
174
  y0 = max(0, int(h * y_start_frac))
@@ -222,7 +242,7 @@ def fix_account_number(hdr: str) -> str:
222
  if acct_match:
223
  acct = acct_match.group(1)
224
  if hdr.startswith(acct):
225
- hdr = hdr[len(acct):].lstrip()
226
  return hdr
227
 
228
 
@@ -278,7 +298,7 @@ def md_table_to_html(block: str) -> str:
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
 
@@ -286,18 +306,23 @@ 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__()
@@ -365,74 +390,125 @@ def _grid_to_html(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}>")
370
  lines.append("</tr>")
371
  lines.append("</table>")
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
 
@@ -441,8 +517,8 @@ 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():
@@ -459,7 +535,8 @@ def normalize_html_tables(text: str) -> str:
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)
@@ -484,16 +561,12 @@ def stabilize_tables_and_text(page_md: str) -> str:
484
  out_blocks.append(b)
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
 
495
  # --------------------------
496
- # PDF rendering with padding (critical for Balance column)
497
  # --------------------------
498
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
499
  import pymupdf as fitz
@@ -588,11 +661,13 @@ def run_ocr(uploaded_file):
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,9 +678,11 @@ def run_ocr(uploaded_file):
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:
@@ -624,6 +701,7 @@ def run_ocr(uploaded_file):
624
 
625
  except Exception as e:
626
  import traceback
 
627
  log.exception("run_ocr failed: %s", e)
628
  return f"Error: {e}\n\n{traceback.format_exc()}"
629
 
@@ -638,7 +716,10 @@ def run_ocr(uploaded_file):
638
 
639
  with gr.Blocks(title="GLM-OCR") as demo:
640
  gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
641
- file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
 
 
 
642
  run_btn = gr.Button("Run OCR", variant="primary")
643
  out = gr.Textbox(lines=40, label="Output (markdown)")
644
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
 
4
 
5
  Hard-coded knobs (no environment variables required).
6
 
7
+ Primary goals for reconcile rate:
8
  - preserve right-most columns (often "Balance") by higher DPI render + right padding
9
  - keep tables as tables (convert markdown pipe tables -> HTML table)
10
  - return ---page-separator--- between pages
11
+ - normalize HTML tables:
12
+ - expand colspan/rowspan to a rectangular grid
13
+ - merge “blank header” columns into DESCRIPTION when they contain text (common in many statements)
14
+ - clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10")
15
+ - DO NOT hardcode for any single bank/PDF
16
  """
17
 
18
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
19
  import asyncio
20
  try:
21
  _orig_close = asyncio.BaseEventLoop.close
22
+
23
  def _safe_close(self):
24
  try:
25
  _orig_close(self)
26
  except (ValueError, OSError):
27
  pass
28
+
29
  asyncio.BaseEventLoop.close = _safe_close
30
  except Exception:
31
  pass
 
41
 
42
  import yaml
43
  import gradio as gr
 
44
  import glmocr
45
 
46
  log = logging.getLogger("glmocr_app")
 
50
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
51
  FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
52
 
 
53
  # ============================================================
54
  # HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
55
  # ============================================================
56
 
57
+ # IMPORTANT: Do NOT hard-code secrets in a public Space.
58
  GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
59
 
60
+ # Higher = better OCR for small/right-aligned digits; slower
61
+ RENDER_SCALE = 2.2 # try 2.5 if right-side numbers are missed
62
 
63
+ # Padding to protect columns near edges (Balance is often right-most)
64
  PAD_LEFT_FRAC = 0.02
65
+ PAD_RIGHT_FRAC = 0.06 # try 0.10 if right-most balances are missing
66
  PAD_TOP_FRAC = 0.01
67
  PAD_BOTTOM_FRAC = 0.01
68
 
 
79
 
80
  # ============================================================
81
 
 
82
  _parser = None
83
 
84
+
85
  def get_parser():
86
  global _parser
87
  if _parser is None:
88
  from glmocr import GlmOcr
89
+
90
  _parser = GlmOcr(
91
  api_key=GLMOCR_API_KEY,
92
  mode="maas",
 
107
  except Exception:
108
  pass
109
 
110
+ # Best-effort formatter tweak: avoid stripping header/footer labels
111
  try:
112
  with open(FORMATTER_PATH, "r") as f:
113
  source = f.read()
114
+ for label in (
115
+ '"header"',
116
+ "'header'",
117
+ '"footer"',
118
+ "'footer'",
119
+ '"doc_header"',
120
+ "'doc_header'",
121
+ '"doc_footer"',
122
+ "'doc_footer'",
123
+ ):
124
  source = re.sub(r",\s*" + re.escape(label), "", source)
125
  source = re.sub(re.escape(label) + r"\s*,", "", source)
126
  source = re.sub(re.escape(label), "", source)
 
150
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
151
  try:
152
  import pymupdf as fitz
153
+
154
  doc = fitz.open(pdf_path)
155
  page = doc[page_num]
156
  h, w = page.rect.height, page.rect.width
 
165
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
166
  try:
167
  import pymupdf as fitz
168
+
169
  doc = fitz.open(pdf_path)
170
  page = doc[page_num]
171
  h = page.rect.height
 
188
  zone_name = "header" if y_end_frac < 0.5 else "footer"
189
  try:
190
  from PIL import Image
191
+
192
  img = Image.open(image_path).convert("RGB")
193
  w, h = img.size
194
  y0 = max(0, int(h * y_start_frac))
 
242
  if acct_match:
243
  acct = acct_match.group(1)
244
  if hdr.startswith(acct):
245
+ hdr = hdr[len(acct) :].lstrip()
246
  return hdr
247
 
248
 
 
298
  cols = split_row(ln)
299
  if len(cols) < len(header):
300
  cols += [""] * (len(header) - len(cols))
301
+ html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>")
302
  return "<table>\n" + "\n".join(html_rows) + "\n</table>"
303
 
304
 
 
306
  if not text:
307
  return text
308
  t = text.replace("−", "-").replace("–", "-").replace("—", "-")
309
+ t = re.sub(
310
+ r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)",
311
+ r"-\1\2",
312
+ t,
313
+ )
314
 
315
  def o_to_zero(m):
316
  token = m.group(0)
317
  return token.replace("O", "0").replace("o", "0")
318
+
319
  t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
320
  return t
321
 
322
 
323
+ # ---- Generic HTML table normalizer ----
324
  class TableGridParser(HTMLParser):
325
+ """Parse a <table> into rows of (text, colspan, rowspan)."""
326
 
327
  def __init__(self):
328
  super().__init__()
 
390
  lines.append("<tr>")
391
  tag = "th" if r == 0 else "td"
392
  for cell in row:
393
+ escaped = (
394
+ (cell or "")
395
+ .replace("&", "&amp;")
396
+ .replace("<", "&lt;")
397
+ .replace(">", "&gt;")
398
+ .replace('"', "&quot;")
399
+ )
400
  lines.append(f"<{tag}>{escaped}</{tag}>")
401
  lines.append("</tr>")
402
  lines.append("</table>")
403
  return "\n".join(lines)
404
 
405
 
406
+ def _is_amount_like(s: str) -> bool:
407
  if not s:
408
  return False
409
  s2 = s.strip()
 
410
  return re.fullmatch(r"\$?-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", s2) is not None
411
 
412
 
413
+ def _clean_header_artifacts(grid):
414
  """
415
+ Generic header text cleanup:
416
+ - DESCRIPTIONBeginning Balance -> DESCRIPTION
417
+ - DESCRIPTIONEnding Balance -> DESCRIPTION
418
+ - BALANCE$3,447.10 -> BALANCE
 
 
419
  """
420
  if not grid or not grid[0]:
421
  return grid
422
+ hdr = [str(c or "") for c in grid[0]]
423
+
424
+ for idx, cell in enumerate(hdr):
425
+ c = cell.strip()
426
+
427
+ if re.search(r"\bDESCRIPTION\b", c, flags=re.IGNORECASE) and re.search(
428
+ r"\b(Beginning|Ending)\s+Balance\b", c, flags=re.IGNORECASE
429
+ ):
430
+ hdr[idx] = "DESCRIPTION"
431
+ continue
432
 
433
+ if re.search(r"\bBALANCE\b", c, flags=re.IGNORECASE) and re.search(
434
+ r"\$?\s*-?\d{1,3}(?:,\d{3})*(?:\.\d{2})?", c
435
+ ):
436
+ # If it contains a number glued to BALANCE, keep label only
437
+ hdr[idx] = "BALANCE"
438
 
439
+ grid[0] = hdr
440
+ return grid
441
+
442
+
443
+ def _collapse_blank_header_columns(grid):
444
+ """
445
+ Generic fix:
446
+ If the header row has blank columns, and many body rows have non-empty *text* in that blank column,
447
+ merge that column into the nearest non-empty header to the left (usually DESCRIPTION),
448
+ then remove the blank column.
449
+
450
+ This fixes:
451
+ - transaction tables where DESCRIPTION is split into two columns (one header blank)
452
+ - summary tables that have an extra blank trailing column
453
+ """
454
+ if not grid or len(grid) < 2:
455
  return grid
456
 
457
+ header = [str(c or "").strip() for c in grid[0]]
458
+ ncols = len(header)
459
+ body = grid[1:]
460
+
461
+ blank_cols = [i for i, h in enumerate(header) if h == ""]
462
+ if not blank_cols:
463
+ return grid
 
 
 
 
 
 
 
 
 
 
 
464
 
465
+ keep = [True] * ncols
466
+
467
+ for i in blank_cols:
468
+ # merge target: nearest non-empty header to the left
469
+ j = i - 1
470
+ while j >= 0 and header[j] == "":
471
+ j -= 1
472
+ if j < 0:
473
+ continue
474
+
475
+ total = 0
476
+ non_empty = 0
477
+ texty = 0
478
+
479
+ for row in body:
480
+ if i >= len(row) or j >= len(row):
481
+ continue
482
+ v = str(row[i] or "").strip()
483
+ total += 1
484
+ if v:
485
+ non_empty += 1
486
+ if not _is_amount_like(v):
487
+ texty += 1
488
+
489
+ if total == 0:
490
+ continue
491
+
492
+ non_empty_ratio = non_empty / total
493
+ texty_ratio = (texty / non_empty) if non_empty > 0 else 0.0
494
+
495
+ # Merge only when this column is mostly text continuation (not numbers)
496
+ if non_empty_ratio >= 0.55 and texty_ratio >= 0.70:
497
+ for r in range(1, len(grid)): # body only
498
+ row = grid[r]
499
+ if i >= len(row) or j >= len(row):
500
+ continue
501
+ left = str(row[j] or "").strip()
502
+ right = str(row[i] or "").strip()
503
+ if right:
504
+ row[j] = (left + " " + right).strip() if left else right
505
+ keep[i] = False
506
+
507
+ if not all(keep):
508
+ new_grid = []
509
+ for row in grid:
510
+ new_grid.append([cell for idx, cell in enumerate(row) if idx < len(keep) and keep[idx]])
511
+ return new_grid
512
 
513
  return grid
514
 
 
517
  """
518
  For every <table>...</table>:
519
  - parse to grid (expanding colspan/rowspan)
520
+ - merge blank header columns when they’re actually description continuation
521
+ - clean common header artifacts
522
  - emit normalized <table> HTML without colspan/rowspan
523
  """
524
  if not text or "<table" not in text.lower():
 
535
  p = TableGridParser()
536
  p.feed(table_html)
537
  grid = _build_grid(p.rows)
538
+ grid = _collapse_blank_header_columns(grid)
539
+ grid = _clean_header_artifacts(grid)
540
  out.append(_grid_to_html(grid) if grid else table_html)
541
  except Exception:
542
  out.append(table_html)
 
561
  out_blocks.append(b)
562
 
563
  stabilized = "\n\n".join(out_blocks)
 
 
564
  stabilized = normalize_html_tables(stabilized)
 
 
565
  return close_unclosed_html(stabilized)
566
 
567
 
568
  # --------------------------
569
+ # PDF rendering with padding
570
  # --------------------------
571
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
572
  import pymupdf as fitz
 
661
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
662
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
663
 
664
+ # clamp
665
  he = max(0.02, min(0.25, he))
666
  fs = max(0.75, min(0.98, fs))
667
 
668
  parts = []
669
 
670
+ # Header inclusion: PDF text -> band words -> OCR band
671
  hdr = ""
672
  if is_pdf:
673
  hdr = extract_zone_text_pdf(path, page_num, 0, he)
 
678
  if hdr and hdr.strip():
679
  parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
680
 
681
+ # Main OCR markdown, stabilized
682
  if page_md and page_md.strip():
683
  parts.append(stabilize_tables_and_text(page_md.strip()))
684
 
685
+ # Optional footer
686
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
687
  ftr = ""
688
  if is_pdf:
 
701
 
702
  except Exception as e:
703
  import traceback
704
+
705
  log.exception("run_ocr failed: %s", e)
706
  return f"Error: {e}\n\n{traceback.format_exc()}"
707
 
 
716
 
717
  with gr.Blocks(title="GLM-OCR") as demo:
718
  gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
719
+ file_in = gr.File(
720
+ label="Upload PDF or image",
721
+ file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
722
+ )
723
  run_btn = gr.Button("Run OCR", variant="primary")
724
  out = gr.Textbox(lines=40, label="Output (markdown)")
725
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)