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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +233 -287
app.py CHANGED
@@ -1,3 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
 
3
  try:
@@ -18,6 +49,7 @@ import logging
18
  import os
19
  import re
20
  import tempfile
 
21
  from typing import List, Optional, Tuple
22
 
23
  import yaml
@@ -28,7 +60,7 @@ 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,220 +68,57 @@ log = logging.getLogger("glmocr_simple_app")
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,6 +140,7 @@ def get_parser():
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,14 +267,14 @@ def fix_account_number(hdr: str) -> str:
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):
@@ -418,6 +288,152 @@ def close_unclosed_html(md: str) -> str:
418
  return md
419
 
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  def looks_like_markdown_table(block: str) -> bool:
422
  lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
423
  if len(lines) < 2:
@@ -441,21 +457,17 @@ def md_table_to_html(block: str) -> str:
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,36 +491,19 @@ def normalize_money_glyphs(text: str) -> str:
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,9 +516,7 @@ 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,15 +528,11 @@ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
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,9 +565,9 @@ def run_ocr(uploaded_file):
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,19 +575,13 @@ def run_ocr(uploaded_file):
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,68 +589,39 @@ def run_ocr(uploaded_file):
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,9 +629,9 @@ def run_ocr(uploaded_file):
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,11 +640,7 @@ def run_ocr(uploaded_file):
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,11 +652,7 @@ def run_ocr(uploaded_file):
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,10 +672,10 @@ def _create_gradio_demo():
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()
 
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 / …), or doc-wide dedupe passes.
20
+
21
+ Included (data-driven, header-agnostic):
22
+ - HTML tables: infer modal logical column width from each table's own rows
23
+ (colspan-aware), pad short rows, trim trailing empty cells on over-wide
24
+ rows. No fixed N, no header keywords, no date/money heuristics. Does not
25
+ fix same-width rows with content in the wrong cell (needs bbox / PDF text).
26
+
27
+ Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio +
28
+ pymupdf + pillow installed.
29
+ """
30
+
31
+ # Patch asyncio first (before Gradio imports it) to reduce Python 3.13 loop noise
32
  import asyncio
33
 
34
  try:
 
49
  import os
50
  import re
51
  import tempfile
52
+ from collections import Counter
53
  from typing import List, Optional, Tuple
54
 
55
  import yaml
 
60
  GLMOCR_BASE = os.path.dirname(glmocr.__file__)
61
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
62
  except ImportError:
63
+ glmocr = None # type: ignore
64
  GLMOCR_BASE = ""
65
  CONFIG_PATH = ""
66
 
 
68
  logging.basicConfig(level=logging.INFO)
69
 
70
  # ---------------------------------------------------------------------------
71
+ # Settings — tuned for dense financial PDFs; applies to every document
72
  # ---------------------------------------------------------------------------
73
 
74
  GLMOCR_API_KEY = "cee1d52dd91a4ab591b3f6e105f8ad89.LgbQTECuzX0zrito"
75
  if not GLMOCR_API_KEY:
76
+ log.warning("GLMOCR_API_KEY is not set; GlmOcr() will fail until you export it.")
77
 
78
+ # Rasterization: higher scale = more pixels per PDF point (helps small type,
79
+ # boxed headers, and narrow columns). Same constant for all uploads.
80
  RENDER_SCALE = 3.05
81
 
82
+ # White margin as a fraction of page width/height after render. Extra right
83
+ # margin helps right-aligned currency columns that hug the page edge.
84
+ PAD_LEFT_FRAC = 0.035
85
+ PAD_RIGHT_FRAC = 0.11
86
+ PAD_TOP_FRAC = 0.018
87
  PAD_BOTTOM_FRAC = 0.018
88
 
89
+ ENABLE_CONTRAST = True
90
+ # Slight contrast lift only; same factor for every file.
91
+ CONTRAST_FACTOR = 1.16
92
 
93
+ # Subtle edge enhancement after contrast (helps hairlines and small digits).
94
+ ENABLE_UNSHARP = True
95
+ UNSHARP_RADIUS = 0.78
96
+ UNSHARP_PERCENT = 72
97
  UNSHARP_THRESHOLD = 1
98
 
99
+ DEFAULT_ZONE_FRAC = 0.12
100
  PDF_HEADER_BAND_FRAC = 0.10
101
 
102
+ ENABLE_FOOTER_OCR = True
103
  PDF_FOOTER_BAND_FRAC = 0.88
104
 
105
  MIN_CROP_HEIGHT = 112
106
  MIN_CROP_PIXELS = 112 * 112
107
 
108
+ # PNG compression 0–9; lower = less loss before GLM-OCR (same for all PDFs).
109
  PAGE_PNG_COMPRESS_LEVEL = 3
110
+ # JPEG quality for small header/footer crops sent to the API.
111
+ ZONE_JPEG_QUALITY = 95
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  _parser = None
114
 
115
 
116
+ def _enhance_raster_for_ocr(img):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  """
118
+ Improve legibility of every raster passed to GLM-OCR (full pages and
119
+ header/footer crops). No document text or keywords same pipeline for
120
+ all PDFs and images.
 
 
 
 
 
121
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  from PIL import ImageEnhance, ImageFilter
123
 
124
  if ENABLE_CONTRAST:
 
140
  raise RuntimeError("glmocr is not installed.")
141
  if _parser is None:
142
  from glmocr import GlmOcr
143
+
144
  _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas")
145
  return _parser
146
 
 
267
  if acct_match:
268
  acct = acct_match.group(1)
269
  if hdr.startswith(acct):
270
+ hdr = hdr[len(acct) :].lstrip()
271
  return hdr
272
 
273
 
274
  def close_unclosed_html(md: str) -> str:
275
  if not md:
276
  return md
277
+ open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
278
  close_tags = re.findall(r"</(table|tbody|thead|tr|td|th)>", md, flags=re.IGNORECASE)
279
 
280
  def count(tags, name):
 
288
  return md
289
 
290
 
291
+ _TR_OPEN = re.compile(r"<tr\b([^>]*)>", re.IGNORECASE)
292
+ _TR_CLOSE = re.compile(r"</tr>", re.IGNORECASE)
293
+ _CELL = re.compile(
294
+ r"<(td|th)(\b[^>]*?)>((?:(?!</?(?:td|th)\b).)*?)</(td|th)\s*>",
295
+ re.IGNORECASE | re.DOTALL,
296
+ )
297
+
298
+
299
+ def _cell_entries(tr_inner: str) -> List[Tuple[str, int]]:
300
+ """(full_cell_html, logical_width) for each td/th; 0 cells if unparseable."""
301
+ out: List[Tuple[str, int]] = []
302
+ for m in _CELL.finditer(tr_inner):
303
+ open_name, attrs, _body, close_name = m.group(1), m.group(2), m.group(3), m.group(4)
304
+ if open_name.lower() != close_name.lower():
305
+ continue
306
+ cm = re.search(r"colspan\s*=\s*[\"']?(\d+)", attrs, flags=re.IGNORECASE)
307
+ span = int(cm.group(1)) if cm else 1
308
+ span = max(1, span)
309
+ out.append((m.group(0), span))
310
+ return out
311
+
312
+
313
+ def _logical_row_width(entries: List[Tuple[str, int]]) -> int:
314
+ return sum(s for _f, s in entries)
315
+
316
+
317
+ def _cell_text_empty(full_cell: str) -> bool:
318
+ m = _CELL.fullmatch(full_cell.strip(), flags=re.IGNORECASE | re.DOTALL)
319
+ if not m:
320
+ inner = re.sub(r"<[^>]+>", " ", full_cell)
321
+ else:
322
+ inner = m.group(3)
323
+ inner = re.sub(r"\s+", " ", inner).strip()
324
+ inner = html.unescape(inner)
325
+ return inner == ""
326
+
327
+
328
+ def _infer_modal_logical_width(tr_inners: List[str]) -> int:
329
+ """
330
+ Modal logical column count across rows (colspan sums). On frequency ties,
331
+ prefer the larger width so a rare short row is padded to the majority grid.
332
+ Returns -1 if the table uses rowspan (skip) or has no measurable rows.
333
+ """
334
+ widths: List[int] = []
335
+ for inner in tr_inners:
336
+ if re.search(r"rowspan\s*=", inner, flags=re.IGNORECASE):
337
+ return -1
338
+ w = _logical_row_width(_cell_entries(inner))
339
+ if w > 0:
340
+ widths.append(w)
341
+ if not widths:
342
+ return -1
343
+ c = Counter(widths)
344
+ best = max(c.values())
345
+ candidates = [w for w, n in c.items() if n == best]
346
+ return max(candidates)
347
+
348
+
349
+ def _normalize_one_tr_inner(tr_inner: str, target: int) -> str:
350
+ entries = _cell_entries(tr_inner)
351
+ if not entries:
352
+ return tr_inner
353
+ cells = [e[0] for e in entries]
354
+ spans = [e[1] for e in entries]
355
+ w = sum(spans)
356
+ if w < target:
357
+ cells.extend(["<td></td>"] * (target - w))
358
+ return "".join(cells)
359
+ while w > target and cells:
360
+ if spans[-1] != 1 or not _cell_text_empty(cells[-1]):
361
+ break
362
+ w -= spans[-1]
363
+ cells.pop()
364
+ spans.pop()
365
+ return "".join(cells)
366
+
367
+
368
+ def normalize_html_table_row_widths(md: str) -> str:
369
+ """
370
+ For each <table>, infer the dominant logical column count from rowspan-free
371
+ rows (colspan-aware), then pad rows that are too narrow or strip trailing
372
+ empty single-colspan cells from rows that are too wide.
373
+
374
+ No column names, dates, currency patterns, or fixed N — only per-table
375
+ statistics. Tables containing rowspan are left unchanged (unsafe to infer).
376
+ """
377
+ if not md or "<table" not in md.lower():
378
+ return md
379
+
380
+ def repl_table(m: re.Match) -> str:
381
+ full = m.group(0)
382
+ low = full.lower()
383
+ inner_start = low.find(">") + 1
384
+ inner_end = low.rfind("</table>")
385
+ if inner_start <= 0 or inner_end < inner_start:
386
+ return full
387
+ prefix = full[:inner_start]
388
+ body = full[inner_start:inner_end]
389
+ suffix = full[inner_end:]
390
+
391
+ tr_blocks = list(re.finditer(r"<tr\b[^>]*>.*?</tr>", body, flags=re.IGNORECASE | re.DOTALL))
392
+ if not tr_blocks:
393
+ return full
394
+
395
+ tr_inners: List[str] = []
396
+ for tm in tr_blocks:
397
+ seg = tm.group(0)
398
+ op = re.search(r"<tr\b[^>]*>", seg, flags=re.IGNORECASE)
399
+ cl = seg.lower().rfind("</tr>")
400
+ if not op or cl < 0:
401
+ continue
402
+ tr_inners.append(seg[op.end() : cl])
403
+
404
+ target = _infer_modal_logical_width(tr_inners)
405
+ if target < 1:
406
+ return full
407
+
408
+ new_parts: List[str] = []
409
+ last_end = 0
410
+ for tm in tr_blocks:
411
+ new_parts.append(body[last_end : tm.start()])
412
+ seg = tm.group(0)
413
+ op = re.search(r"<tr\b[^>]*>", seg, flags=re.IGNORECASE)
414
+ cl = seg.lower().rfind("</tr>")
415
+ if not op or cl < 0:
416
+ new_parts.append(seg)
417
+ else:
418
+ open_tr = seg[: op.end()]
419
+ inner = seg[op.end() : cl]
420
+ close_tr = seg[cl:]
421
+ if re.search(r"rowspan\s*=", inner, flags=re.IGNORECASE):
422
+ new_parts.append(seg)
423
+ else:
424
+ new_parts.append(open_tr + _normalize_one_tr_inner(inner, target) + close_tr)
425
+ last_end = tm.end()
426
+ new_parts.append(body[last_end:])
427
+ return prefix + "".join(new_parts) + suffix
428
+
429
+ return re.sub(
430
+ r"<table\b[^>]*>.*?</table>",
431
+ repl_table,
432
+ md,
433
+ flags=re.IGNORECASE | re.DOTALL,
434
+ )
435
+
436
+
437
  def looks_like_markdown_table(block: str) -> bool:
438
  lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
439
  if len(lines) < 2:
 
457
  row = row[:-1]
458
  return [p.strip() for p in row.split("|")]
459
 
460
+ header = split_row(lines[0])
461
  body_lines = [ln for ln in lines[2:] if "|" in ln]
462
 
463
  html_rows = []
464
+ html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
 
 
465
  for ln in body_lines:
466
  cols = split_row(ln)
467
  if len(cols) < len(header):
468
  cols += [""] * (len(header) - len(cols))
469
  html_rows.append(
470
+ "<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[: len(header)]) + "</tr>"
 
 
471
  )
472
  return "<table>\n" + "\n".join(html_rows) + "\n</table>"
473
 
 
491
 
492
 
493
  def light_stabilize_markdown(page_md: str) -> str:
494
+ """Convert obvious GitHub-style pipe tables to HTML; normalize money glyphs; repair tags."""
 
 
 
 
 
 
495
  if not page_md:
496
  return page_md
 
497
  page_md = normalize_money_glyphs(page_md)
498
+ blocks = re.split(r"\n\s*\n", page_md.strip())
 
 
499
  out_blocks = []
500
  for b in blocks:
501
  if looks_like_markdown_table(b):
502
  out_blocks.append(md_table_to_html(b))
503
  else:
504
  out_blocks.append(b)
505
+ merged = close_unclosed_html("\n\n".join(out_blocks))
506
+ return normalize_html_table_row_widths(merged)
 
 
 
 
 
 
 
 
507
 
508
 
509
  def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
 
516
 
517
  for i in range(len(doc)):
518
  page = doc[i]
519
+ pix = page.get_pixmap(matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False)
 
 
520
 
521
  img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
522
  img = _enhance_raster_for_ocr(img)
 
528
  pad_b = int(h * PAD_BOTTOM_FRAC)
529
 
530
  if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)):
531
+ canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255))
 
 
532
  canvas.paste(img, (pad_l, pad_t))
533
  img = canvas
534
 
535
+ img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png")
 
 
536
  img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
537
  page_images.append(img_path)
538
  page_heights.append(img.height)
 
565
 
566
  page_images: List[str] = []
567
  try:
568
+ path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
569
+ is_pdf = path.lower().endswith(".pdf")
570
+ parser = get_parser()
571
 
572
  page_heights: List[int] = []
573
 
 
575
  page_images, page_heights = render_pdf_pages_to_images(path)
576
  results = parser.parse(page_images)
577
  else:
578
+ page_images = [path]
579
  page_heights = [1000]
580
+ results = parser.parse(path)
581
 
582
  if not isinstance(results, list):
583
  results = [results]
584
 
 
 
 
 
 
 
585
  all_pages = []
586
  for page_num, page_result in enumerate(results):
587
  page_md, regions = get_page_md_and_regions(page_result)
 
589
  header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h)
590
 
591
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
592
+ fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
 
 
 
 
593
 
594
  he = max(0.02, min(0.25, he))
595
  fs = max(0.75, min(0.98, fs))
596
 
597
  parts = []
598
 
 
 
 
 
599
  hdr = ""
600
  if is_pdf:
601
  hdr = extract_zone_text_pdf(path, page_num, 0, he)
602
  if not (hdr and hdr.strip()):
603
+ hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC)
 
 
604
  if not (hdr and hdr.strip()) and page_num < len(page_images):
605
  hdr = ocr_zone(page_images[page_num], 0, he)
 
606
  if hdr and hdr.strip():
607
+ parts.append(light_stabilize_markdown(fix_account_number(normalize_money_glyphs(hdr.strip()))))
 
 
 
 
 
 
 
 
 
608
 
 
609
  if page_md and page_md.strip():
610
  parts.append(light_stabilize_markdown(page_md.strip()))
611
 
 
612
  if ENABLE_FOOTER_OCR and page_num < len(page_images):
613
  ftr = ""
614
  if is_pdf:
615
  ftr = extract_zone_text_pdf(path, page_num, fs, 1.0)
616
  if not (ftr and ftr.strip()):
617
+ ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0)
 
 
618
  if not (ftr and ftr.strip()):
619
  ftr = ocr_zone(page_images[page_num], fs, 1.0)
 
620
  if ftr and ftr.strip():
621
  ftr_clean = normalize_money_glyphs(ftr.strip())
622
 
623
  ftr_first_line = next(
624
+ (ln.strip().lower() for ln in ftr_clean.splitlines() if ln.strip()),
 
 
 
 
625
  "",
626
  )
627
  already_present = ftr_first_line and any(
 
629
  )
630
 
631
  _footer_date_re = re.compile(r"\b\d{1,2}[-/]\d{2}\b")
632
+ _footer_amt_re = re.compile(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b")
633
  _date_hits = len(_footer_date_re.findall(ftr_clean))
634
+ _amt_hits = len(_footer_amt_re.findall(ftr_clean))
635
  is_txn_dump = _date_hits >= 3 and _amt_hits >= 3
636
 
637
  if not already_present and not is_txn_dump:
 
640
  if parts:
641
  all_pages.append("\n\n".join(parts))
642
 
643
+ merged = "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
 
 
 
 
644
  return merged
645
 
646
  except Exception as e:
 
652
  finally:
653
  for p in page_images:
654
  try:
655
+ if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
 
 
 
 
656
  os.unlink(p)
657
  except Exception:
658
  pass
 
672
  file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
673
  )
674
  run_btn = gr.Button("Run OCR", variant="primary")
675
+ out = gr.Textbox(lines=40, label="Output (markdown / light HTML)")
676
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
677
  return demo
678
 
679
 
680
  if __name__ == "__main__":
681
+ _create_gradio_demo().launch()