rehan953 commited on
Commit
c606d27
·
verified ·
1 Parent(s): d501982

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +287 -81
app.py CHANGED
@@ -1,30 +1,3 @@
1
- #!/usr/bin/env python3
2
- """
3
- Simplified GLM-OCR Hugging Face / local Gradio app.
4
-
5
- Scope (intentionally small):
6
- - PDF → padded high-DPI page images → GLM-OCR body markdown
7
- - Header band: PDF text extraction first, optional header OCR fallback
8
- - Footer band: same pattern, with light dedup so we do not paste a full
9
- transaction dump twice when the body already captured it
10
-
11
- Universal image pipeline (same for every PDF, no keywords / no bank logic):
12
- - Higher rasterization scale + extra white padding so fine print, boxed
13
- section labels, and right-aligned amounts sit farther from the clip edge.
14
- - Mild contrast + unsharp mask on every raster sent to the model so
15
- thin rules and small glyphs are easier to read before recognition.
16
-
17
- Explicitly omitted vs the heavy Space build:
18
- - No text-layer row injection, institution-specific splits (UCB / Navy /
19
- TD / First Horizon / …), doc-wide dedupe passes, or HTML table rewriting
20
- keyed off column names / dates / amounts (layout fidelity comes from input
21
- image quality, not post-hoc string rules).
22
-
23
- Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
24
- pymupdf + pillow installed.
25
- """
26
-
27
- # Patch asyncio first (before Gradio imports it) to reduce Python 3.13 loop noise
28
  import asyncio
29
 
30
  try:
@@ -55,7 +28,7 @@ try:
55
  GLMOCR_BASE = os.path.dirname(glmocr.__file__)
56
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
57
  except ImportError:
58
- glmocr = None # type: ignore
59
  GLMOCR_BASE = ""
60
  CONFIG_PATH = ""
61
 
@@ -63,57 +36,220 @@ log = logging.getLogger("glmocr_simple_app")
63
  logging.basicConfig(level=logging.INFO)
64
 
65
  # ---------------------------------------------------------------------------
66
- # Settings — tuned for dense financial PDFs; applies to every document
67
  # ---------------------------------------------------------------------------
68
 
69
  GLMOCR_API_KEY = "cee1d52dd91a4ab591b3f6e105f8ad89.LgbQTECuzX0zrito"
70
  if not GLMOCR_API_KEY:
71
- log.warning("GLMOCR_API_KEY is not set; GlmOcr() will fail until you export it.")
72
 
73
- # Rasterization: higher scale = more pixels per PDF point (helps small type,
74
- # boxed headers, and narrow columns). Same constant for all uploads.
75
  RENDER_SCALE = 3.05
76
 
77
- # White margin as a fraction of page width/height after render. Extra right
78
- # margin helps right-aligned currency columns that hug the page edge.
79
- PAD_LEFT_FRAC = 0.035
80
- PAD_RIGHT_FRAC = 0.11
81
- PAD_TOP_FRAC = 0.018
82
  PAD_BOTTOM_FRAC = 0.018
83
 
84
- ENABLE_CONTRAST = True
85
- # Slight contrast lift only; same factor for every file.
86
- CONTRAST_FACTOR = 1.16
87
 
88
- # Subtle edge enhancement after contrast (helps hairlines and small digits).
89
- ENABLE_UNSHARP = True
90
- UNSHARP_RADIUS = 0.78
91
- UNSHARP_PERCENT = 72
92
  UNSHARP_THRESHOLD = 1
93
 
94
- DEFAULT_ZONE_FRAC = 0.12
95
  PDF_HEADER_BAND_FRAC = 0.10
96
 
97
- ENABLE_FOOTER_OCR = True
98
  PDF_FOOTER_BAND_FRAC = 0.88
99
 
100
  MIN_CROP_HEIGHT = 112
101
  MIN_CROP_PIXELS = 112 * 112
102
 
103
- # PNG compression 0–9; lower = less loss before GLM-OCR (same for all PDFs).
104
  PAGE_PNG_COMPRESS_LEVEL = 3
105
- # JPEG quality for small header/footer crops sent to the API.
106
- ZONE_JPEG_QUALITY = 95
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  _parser = None
109
 
110
 
111
- def _enhance_raster_for_ocr(img):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  """
113
- Improve legibility of every raster passed to GLM-OCR (full pages and
114
- header/footer crops). No document text or keywords same pipeline for
115
- all PDFs and images.
116
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  from PIL import ImageEnhance, ImageFilter
118
 
119
  if ENABLE_CONTRAST:
@@ -135,7 +271,6 @@ def get_parser():
135
  raise RuntimeError("glmocr is not installed.")
136
  if _parser is None:
137
  from glmocr import GlmOcr
138
-
139
  _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
140
  return _parser
141
 
@@ -262,14 +397,14 @@ def fix_account_number(hdr: str) -> str:
262
  if acct_match:
263
  acct = acct_match.group(1)
264
  if hdr.startswith(acct):
265
- hdr = hdr[len(acct) :].lstrip()
266
  return hdr
267
 
268
 
269
  def close_unclosed_html(md: str) -> str:
270
  if not md:
271
  return md
272
- open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
273
  close_tags = re.findall(r"</(table|tbody|thead|tr|td|th)>", md, flags=re.IGNORECASE)
274
 
275
  def count(tags, name):
@@ -306,17 +441,21 @@ def md_table_to_html(block: str) -> str:
306
  row = row[:-1]
307
  return [p.strip() for p in row.split("|")]
308
 
309
- header = split_row(lines[0])
310
  body_lines = [ln for ln in lines[2:] if "|" in ln]
311
 
312
  html_rows = []
313
- html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
 
 
314
  for ln in body_lines:
315
  cols = split_row(ln)
316
  if len(cols) < len(header):
317
  cols += [""] * (len(header) - len(cols))
318
  html_rows.append(
319
- "<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>"
 
 
320
  )
321
  return "<table>\n" + "\n".join(html_rows) + "\n</table>"
322
 
@@ -340,18 +479,36 @@ def normalize_money_glyphs(text: str) -> str:
340
 
341
 
342
  def light_stabilize_markdown(page_md: str) -> str:
343
- """Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs; repair tags."""
 
 
 
 
 
 
344
  if not page_md:
345
  return page_md
 
346
  page_md = normalize_money_glyphs(page_md)
347
- blocks = re.split(r"\n\s*\n", page_md.strip())
 
 
348
  out_blocks = []
349
  for b in blocks:
350
  if looks_like_markdown_table(b):
351
  out_blocks.append(md_table_to_html(b))
352
  else:
353
  out_blocks.append(b)
354
- return close_unclosed_html("\n\n".join(out_blocks))
 
 
 
 
 
 
 
 
 
355
 
356
 
357
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
@@ -364,7 +521,9 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
364
 
365
  for i in range(len(doc)):
366
  page = doc[i]
367
- pix = page.get_pixmap(matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False)
 
 
368
 
369
  img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
370
  img = _enhance_raster_for_ocr(img)
@@ -376,11 +535,15 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
376
  pad_b = int(h * PAD_BOTTOM_FRAC)
377
 
378
  if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)):
379
- canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255))
 
 
380
  canvas.paste(img, (pad_l, pad_t))
381
  img = canvas
382
 
383
- img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png")
 
 
384
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
385
  page_images.append(img_path)
386
  page_heights.append(img.height)
@@ -413,9 +576,9 @@ def run_ocr(uploaded_file):
413
 
414
  page_images: List[str] = []
415
  try:
416
- path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
417
- is_pdf = path.lower().endswith(".pdf")
418
- parser = get_parser()
419
 
420
  page_heights: List[int] = []
421
 
@@ -423,13 +586,19 @@ def run_ocr(uploaded_file):
423
  page_images, page_heights = render_pdf_pages_to_images(path)
424
  results = parser.parse(page_images)
425
  else:
426
- page_images = [path]
427
  page_heights = [1000]
428
- results = parser.parse(path)
429
 
430
  if not isinstance(results, list):
431
  results = [results]
432
 
 
 
 
 
 
 
433
  all_pages = []
434
  for page_num, page_result in enumerate(results):
435
  page_md, regions = get_page_md_and_regions(page_result)
@@ -437,39 +606,68 @@ def run_ocr(uploaded_file):
437
  header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h)
438
 
439
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
440
- fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
 
 
 
 
441
 
442
  he = max(0.02, min(0.25, he))
443
  fs = max(0.75, min(0.98, fs))
444
 
445
  parts = []
446
 
 
 
 
 
447
  hdr = ""
448
  if is_pdf:
449
  hdr = extract_zone_text_pdf(path, page_num, 0, he)
450
  if not (hdr and hdr.strip()):
451
- hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC)
 
 
452
  if not (hdr and hdr.strip()) and page_num < len(page_images):
453
  hdr = ocr_zone(page_images[page_num], 0, he)
 
454
  if hdr and hdr.strip():
455
- parts.append(light_stabilize_markdown(fix_account_number(normalize_money_glyphs(hdr.strip()))))
 
 
 
 
 
 
 
 
 
456
 
 
457
  if page_md and page_md.strip():
458
  parts.append(light_stabilize_markdown(page_md.strip()))
459
 
 
460
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
461
  ftr = ""
462
  if is_pdf:
463
  ftr = extract_zone_text_pdf(path, page_num, fs, 1.0)
464
  if not (ftr and ftr.strip()):
465
- ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0)
 
 
466
  if not (ftr and ftr.strip()):
467
  ftr = ocr_zone(page_images[page_num], fs, 1.0)
 
468
  if ftr and ftr.strip():
469
  ftr_clean = normalize_money_glyphs(ftr.strip())
470
 
471
  ftr_first_line = next(
472
- (ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()),
 
 
 
 
473
  "",
474
  )
475
  already_present = ftr_first_line and any(
@@ -477,9 +675,9 @@ def run_ocr(uploaded_file):
477
  )
478
 
479
  _footer_date_re = re.compile(r"\b\d{1,2}[-/]\d{2}\b")
480
- _footer_amt_re = re.compile(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b")
481
  _date_hits = len(_footer_date_re.findall(ftr_clean))
482
- _amt_hits = len(_footer_amt_re.findall(ftr_clean))
483
  is_txn_dump = _date_hits >= 3 and _amt_hits >= 3
484
 
485
  if not already_present and not is_txn_dump:
@@ -488,7 +686,11 @@ def run_ocr(uploaded_file):
488
  if parts:
489
  all_pages.append("\n\n".join(parts))
490
 
491
- merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
 
 
 
 
492
  return merged
493
 
494
  except Exception as e:
@@ -500,7 +702,11 @@ def run_ocr(uploaded_file):
500
  finally:
501
  for p in page_images:
502
  try:
503
- if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
 
 
 
 
504
  os.unlink(p)
505
  except Exception:
506
  pass
@@ -520,10 +726,10 @@ def _create_gradio_demo():
520
  file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
521
  )
522
  run_btn = gr.Button("Run OCR", variant="primary")
523
- out = gr.Textbox(lines=40, label="Output (markdown / light HTML)")
524
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
525
  return demo
526
 
527
 
528
  if __name__ == "__main__":
529
- _create_gradio_demo().launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
 
3
  try:
 
28
  GLMOCR_BASE = os.path.dirname(glmocr.__file__)
29
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
30
  except ImportError:
31
+ glmocr = None
32
  GLMOCR_BASE = ""
33
  CONFIG_PATH = ""
34
 
 
36
  logging.basicConfig(level=logging.INFO)
37
 
38
  # ---------------------------------------------------------------------------
39
+ # Settings
40
  # ---------------------------------------------------------------------------
41
 
42
  GLMOCR_API_KEY = "cee1d52dd91a4ab591b3f6e105f8ad89.LgbQTECuzX0zrito"
43
  if not GLMOCR_API_KEY:
44
+ log.warning("GLMOCR_API_KEY is not set.")
45
 
 
 
46
  RENDER_SCALE = 3.05
47
 
48
+ PAD_LEFT_FRAC = 0.035
49
+ PAD_RIGHT_FRAC = 0.11
50
+ PAD_TOP_FRAC = 0.018
 
 
51
  PAD_BOTTOM_FRAC = 0.018
52
 
53
+ ENABLE_CONTRAST = True
54
+ CONTRAST_FACTOR = 1.16
 
55
 
56
+ ENABLE_UNSHARP = True
57
+ UNSHARP_RADIUS = 0.78
58
+ UNSHARP_PERCENT = 72
 
59
  UNSHARP_THRESHOLD = 1
60
 
61
+ DEFAULT_ZONE_FRAC = 0.12
62
  PDF_HEADER_BAND_FRAC = 0.10
63
 
64
+ ENABLE_FOOTER_OCR = True
65
  PDF_FOOTER_BAND_FRAC = 0.88
66
 
67
  MIN_CROP_HEIGHT = 112
68
  MIN_CROP_PIXELS = 112 * 112
69
 
 
70
  PAGE_PNG_COMPRESS_LEVEL = 3
71
+ ZONE_JPEG_QUALITY = 95
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Similarity threshold for header deduplication (0-1). Texts with a
75
+ # normalised token-overlap above this value are considered repeating headers
76
+ # and suppressed on pages > 0. No document keywords are used — works purely
77
+ # on character/token similarity so it generalises to any PDF layout.
78
+ # ---------------------------------------------------------------------------
79
+ HEADER_SIMILARITY_THRESHOLD = 0.60
80
+
81
+ # Minimum ratio of shared tokens (vs shorter string) to treat two table
82
+ # schemas as "the same" and attempt column-count normalisation.
83
+ TABLE_SCHEMA_SIMILARITY = 0.70
84
 
85
  _parser = None
86
 
87
 
88
+ # ---------------------------------------------------------------------------
89
+ # Generic text-similarity helper (no hardcoding, no document keywords)
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def _token_similarity(a: str, b: str) -> float:
93
+ """
94
+ Return a 0-1 Jaccard-style similarity between two strings based on
95
+ whitespace-split token sets. Used to detect repeated headers and
96
+ identical table schema rows across pages without any hardcoded patterns.
97
+ """
98
+ if not a or not b:
99
+ return 0.0
100
+ ta = set(a.lower().split())
101
+ tb = set(b.lower().split())
102
+ if not ta or not tb:
103
+ return 0.0
104
+ return len(ta & tb) / len(ta | tb)
105
+
106
+
107
+ def _normalise_text(t: str) -> str:
108
+ """Collapse whitespace, strip punctuation edges — for similarity checks."""
109
+ return re.sub(r"\s+", " ", t).strip()
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Generic HTML table normalisation
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _parse_html_table_rows(table_html: str):
117
+ """
118
+ Parse any HTML table into a list of rows, where each row is a list of
119
+ (text, colspan) tuples. Works regardless of whether the source uses
120
+ <thead>/<tbody>, inline border attributes, or Bootstrap classes.
121
+ No document-specific assumptions.
122
+ """
123
+ rows = []
124
+ for row_m in re.finditer(r"<tr\b[^>]*>(.*?)</tr>", table_html, re.IGNORECASE | re.DOTALL):
125
+ cells = []
126
+ for cell_m in re.finditer(
127
+ r"<t[dh]\b([^>]*)>(.*?)</t[dh]>", row_m.group(1), re.IGNORECASE | re.DOTALL
128
+ ):
129
+ attrs, content = cell_m.group(1), cell_m.group(2)
130
+ cs_m = re.search(r'colspan\s*=\s*["\']?(\d+)', attrs, re.IGNORECASE)
131
+ colspan = int(cs_m.group(1)) if cs_m else 1
132
+ text = re.sub(r"<[^>]+>", " ", content)
133
+ text = re.sub(r"\s+", " ", text).strip()
134
+ cells.append((text, colspan))
135
+ if cells:
136
+ rows.append(cells)
137
+ return rows
138
+
139
+
140
+ def _effective_col_count(rows) -> int:
141
+ """Return the most common effective column count across all rows."""
142
+ from collections import Counter
143
+ counts = Counter(sum(cs for _, cs in r) for r in rows)
144
+ return counts.most_common(1)[0][0] if counts else 0
145
+
146
+
147
+ def _rows_to_uniform_html(rows, col_count: int) -> str:
148
+ """
149
+ Re-serialise parsed rows back to a clean, uniform HTML table where every
150
+ data row has exactly col_count <td> cells. Header rows (those that had
151
+ all-<th> content in the original) are written as <th>.
152
+ Collapsed cells are split back to individual cells with empty padding so
153
+ downstream parsers always see the same schema.
154
+ """
155
+ out = ["<table>"]
156
+ for i, row in enumerate(rows):
157
+ effective = sum(cs for _, cs in row)
158
+ tag = "th" if i == 0 else "td"
159
+ out.append("<tr>")
160
+ filled = 0
161
+ for text, cs in row:
162
+ out.append(f"<{tag}>{html.escape(text)}</{tag}>")
163
+ filled += cs
164
+ # Pad missing columns
165
+ while filled < col_count:
166
+ out.append(f"<{tag}></{tag}>")
167
+ filled += 1
168
+ out.append("</tr>")
169
+ out.append("</table>")
170
+ return "\n".join(out)
171
+
172
+
173
+ def normalise_tables(md: str) -> str:
174
+ """
175
+ Find all HTML tables in a markdown string and rewrite them so that:
176
+ 1. Every table uses a uniform schema (same col count in every row).
177
+ 2. Mixed <thead>/<tbody>/border="1"/class="..." attributes are stripped
178
+ to a clean, consistent <table> with plain <tr><th>/<td> cells.
179
+ 3. colspan cells that inflate or deflate the logical column count are
180
+ expanded back to individual cells.
181
+
182
+ This is fully generic — no document keywords, no hardcoded column counts.
183
+ """
184
+ def replace_table(m):
185
+ raw = m.group(0)
186
+ rows = _parse_html_table_rows(raw)
187
+ if not rows:
188
+ return raw
189
+ col_count = _effective_col_count(rows)
190
+ if col_count == 0:
191
+ return raw
192
+ return _rows_to_uniform_html(rows, col_count)
193
+
194
+ # Match any HTML table, including those with class/border attrs and
195
+ # nested thead/tbody, non-greedily.
196
+ return re.sub(
197
+ r"<table\b[^>]*>.*?</table>",
198
+ replace_table,
199
+ md,
200
+ flags=re.IGNORECASE | re.DOTALL,
201
+ )
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Image placeholder removal
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def _strip_image_placeholders(md: str) -> str:
209
  """
210
+ Remove OCR pipeline artefact image tags like ![Image N-N](imgs/...).
211
+ These reference local temp paths that never exist in the output context.
212
+ Generic regex works for any img reference with a local relative path.
213
  """
214
+ return re.sub(r"!\[[^\]]*\]\(imgs/[^)]+\)", "", md)
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Generic repeated-header suppression
219
+ # ---------------------------------------------------------------------------
220
+
221
+ class _HeaderTracker:
222
+ """
223
+ Tracks header texts seen across pages using token-similarity comparison.
224
+ On the first occurrence a header is stored and emitted. On subsequent
225
+ pages, if the candidate header is sufficiently similar to ANY previously
226
+ seen header it is suppressed entirely.
227
+
228
+ No document keywords. Works for any PDF where the same block of text
229
+ repeats at the top of every page (date ranges, account numbers, titles,
230
+ report headers, etc.).
231
+ """
232
+
233
+ def __init__(self, threshold: float = HEADER_SIMILARITY_THRESHOLD):
234
+ self._seen: List[str] = []
235
+ self._threshold = threshold
236
+
237
+ def should_include(self, hdr: str) -> bool:
238
+ norm = _normalise_text(hdr)
239
+ if not norm:
240
+ return False
241
+ for seen in self._seen:
242
+ if _token_similarity(norm, seen) >= self._threshold:
243
+ return False
244
+ self._seen.append(norm)
245
+ return True
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Image enhancement
250
+ # ---------------------------------------------------------------------------
251
+
252
+ def _enhance_raster_for_ocr(img):
253
  from PIL import ImageEnhance, ImageFilter
254
 
255
  if ENABLE_CONTRAST:
 
271
  raise RuntimeError("glmocr is not installed.")
272
  if _parser is None:
273
  from glmocr import GlmOcr
 
274
  _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
275
  return _parser
276
 
 
397
  if acct_match:
398
  acct = acct_match.group(1)
399
  if hdr.startswith(acct):
400
+ hdr = hdr[len(acct):].lstrip()
401
  return hdr
402
 
403
 
404
  def close_unclosed_html(md: str) -> str:
405
  if not md:
406
  return md
407
+ open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
408
  close_tags = re.findall(r"</(table|tbody|thead|tr|td|th)>", md, flags=re.IGNORECASE)
409
 
410
  def count(tags, name):
 
441
  row = row[:-1]
442
  return [p.strip() for p in row.split("|")]
443
 
444
+ header = split_row(lines[0])
445
  body_lines = [ln for ln in lines[2:] if "|" in ln]
446
 
447
  html_rows = []
448
+ html_rows.append(
449
+ "<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>"
450
+ )
451
  for ln in body_lines:
452
  cols = split_row(ln)
453
  if len(cols) < len(header):
454
  cols += [""] * (len(header) - len(cols))
455
  html_rows.append(
456
+ "<tr>"
457
+ + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)])
458
+ + "</tr>"
459
  )
460
  return "<table>\n" + "\n".join(html_rows) + "\n</table>"
461
 
 
479
 
480
 
481
  def light_stabilize_markdown(page_md: str) -> str:
482
+ """
483
+ 1. Convert pipe tables to HTML.
484
+ 2. Normalise money glyphs.
485
+ 3. Normalise all HTML table schemas to uniform column counts.
486
+ 4. Strip broken image placeholder tags.
487
+ 5. Repair unclosed HTML tags.
488
+ """
489
  if not page_md:
490
  return page_md
491
+
492
  page_md = normalize_money_glyphs(page_md)
493
+
494
+ # Convert pipe-style markdown tables to HTML first
495
+ blocks = re.split(r"\n\s*\n", page_md.strip())
496
  out_blocks = []
497
  for b in blocks:
498
  if looks_like_markdown_table(b):
499
  out_blocks.append(md_table_to_html(b))
500
  else:
501
  out_blocks.append(b)
502
+ page_md = "\n\n".join(out_blocks)
503
+
504
+ # Normalise all HTML table schemas (fixes mixed colspan, thead/tbody,
505
+ # border="1" vs class="table", 3-col vs 4-col inconsistencies)
506
+ page_md = normalise_tables(page_md)
507
+
508
+ # Remove broken local image references left by the OCR region detector
509
+ page_md = _strip_image_placeholders(page_md)
510
+
511
+ return close_unclosed_html(page_md)
512
 
513
 
514
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
 
521
 
522
  for i in range(len(doc)):
523
  page = doc[i]
524
+ pix = page.get_pixmap(
525
+ matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False
526
+ )
527
 
528
  img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
529
  img = _enhance_raster_for_ocr(img)
 
535
  pad_b = int(h * PAD_BOTTOM_FRAC)
536
 
537
  if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)):
538
+ canvas = Image.new(
539
+ "RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255)
540
+ )
541
  canvas.paste(img, (pad_l, pad_t))
542
  img = canvas
543
 
544
+ img_path = os.path.join(
545
+ tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png"
546
+ )
547
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
548
  page_images.append(img_path)
549
  page_heights.append(img.height)
 
576
 
577
  page_images: List[str] = []
578
  try:
579
+ path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
580
+ is_pdf = path.lower().endswith(".pdf")
581
+ parser = get_parser()
582
 
583
  page_heights: List[int] = []
584
 
 
586
  page_images, page_heights = render_pdf_pages_to_images(path)
587
  results = parser.parse(page_images)
588
  else:
589
+ page_images = [path]
590
  page_heights = [1000]
591
+ results = parser.parse(path)
592
 
593
  if not isinstance(results, list):
594
  results = [results]
595
 
596
+ # ------------------------------------------------------------------
597
+ # One tracker per document run — suppresses repeated headers across
598
+ # ALL pages without any hardcoded pattern matching.
599
+ # ------------------------------------------------------------------
600
+ header_tracker = _HeaderTracker(threshold=HEADER_SIMILARITY_THRESHOLD)
601
+
602
  all_pages = []
603
  for page_num, page_result in enumerate(results):
604
  page_md, regions = get_page_md_and_regions(page_result)
 
606
  header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h)
607
 
608
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
609
+ fs = (
610
+ footer_start_frac
611
+ if footer_start_frac is not None
612
+ else (1.0 - DEFAULT_ZONE_FRAC)
613
+ )
614
 
615
  he = max(0.02, min(0.25, he))
616
  fs = max(0.75, min(0.98, fs))
617
 
618
  parts = []
619
 
620
+ # --------------------------------------------------------------
621
+ # Header extraction with cross-page deduplication.
622
+ # The tracker uses token-similarity — no document-specific logic.
623
+ # --------------------------------------------------------------
624
  hdr = ""
625
  if is_pdf:
626
  hdr = extract_zone_text_pdf(path, page_num, 0, he)
627
  if not (hdr and hdr.strip()):
628
+ hdr = extract_pdf_text_in_band(
629
+ path, page_num, 0, PDF_HEADER_BAND_FRAC
630
+ )
631
  if not (hdr and hdr.strip()) and page_num < len(page_images):
632
  hdr = ocr_zone(page_images[page_num], 0, he)
633
+
634
  if hdr and hdr.strip():
635
+ # Only include this header if it is meaningfully different
636
+ # from any header already seen in this document.
637
+ if header_tracker.should_include(hdr.strip()):
638
+ cleaned_hdr = light_stabilize_markdown(
639
+ fix_account_number(
640
+ normalize_money_glyphs(hdr.strip())
641
+ )
642
+ )
643
+ if cleaned_hdr:
644
+ parts.append(cleaned_hdr)
645
 
646
+ # Body
647
  if page_md and page_md.strip():
648
  parts.append(light_stabilize_markdown(page_md.strip()))
649
 
650
+ # Footer
651
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
652
  ftr = ""
653
  if is_pdf:
654
  ftr = extract_zone_text_pdf(path, page_num, fs, 1.0)
655
  if not (ftr and ftr.strip()):
656
+ ftr = extract_pdf_text_in_band(
657
+ path, page_num, PDF_FOOTER_BAND_FRAC, 1.0
658
+ )
659
  if not (ftr and ftr.strip()):
660
  ftr = ocr_zone(page_images[page_num], fs, 1.0)
661
+
662
  if ftr and ftr.strip():
663
  ftr_clean = normalize_money_glyphs(ftr.strip())
664
 
665
  ftr_first_line = next(
666
+ (
667
+ ln.strip().lower()
668
+ for ln in ftr_clean.splitlines()
669
+ if ln.strip()
670
+ ),
671
  "",
672
  )
673
  already_present = ftr_first_line and any(
 
675
  )
676
 
677
  _footer_date_re = re.compile(r"\b\d{1,2}[-/]\d{2}\b")
678
+ _footer_amt_re = re.compile(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b")
679
  _date_hits = len(_footer_date_re.findall(ftr_clean))
680
+ _amt_hits = len(_footer_amt_re.findall(ftr_clean))
681
  is_txn_dump = _date_hits >= 3 and _amt_hits >= 3
682
 
683
  if not already_present and not is_txn_dump:
 
686
  if parts:
687
  all_pages.append("\n\n".join(parts))
688
 
689
+ merged = (
690
+ "\n\n---page-separator---\n\n".join(all_pages)
691
+ if all_pages
692
+ else "(No content)"
693
+ )
694
  return merged
695
 
696
  except Exception as e:
 
702
  finally:
703
  for p in page_images:
704
  try:
705
+ if (
706
+ isinstance(p, str)
707
+ and p.endswith(".png")
708
+ and "glmocr_page_" in os.path.basename(p)
709
+ ):
710
  os.unlink(p)
711
  except Exception:
712
  pass
 
726
  file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
727
  )
728
  run_btn = gr.Button("Run OCR", variant="primary")
729
+ out = gr.Textbox(lines=40, label="Output (markdown / light HTML)")
730
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
731
  return demo
732
 
733
 
734
  if __name__ == "__main__":
735
+ _create_gradio_demo().launch()