rehan953 commited on
Commit
594145e
·
verified ·
1 Parent(s): 8a7e53d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +256 -120
app.py CHANGED
@@ -1,7 +1,15 @@
1
  """
2
- GLM-OCR Hugging Face Space app with client-side header/footer fallback for MaaS.
3
- Works for every PDF and every image: uses API bboxes when available, else minimal band.
 
 
 
 
 
 
 
4
  """
 
5
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
6
  import asyncio
7
  try:
@@ -18,29 +26,60 @@ except Exception:
18
  import logging
19
  import os
20
  import re
 
21
  import tempfile
 
 
22
  import yaml
23
  import gradio as gr
24
 
25
  import glmocr
26
 
27
  log = logging.getLogger("glmocr_app")
 
28
 
29
  GLMOCR_BASE = os.path.dirname(glmocr.__file__)
30
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
31
  FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
32
 
33
- # When MaaS is enabled, the cloud API ignores local layout config and does not return
34
- # header/footer regions. We always run client-side OCR on top/bottom bands as fallback.
35
- DEFAULT_ZONE_FRAC = float(os.environ.get("GLMOCR_DEFAULT_ZONE_FRAC", "0.12"))
36
- # MaaS rejects very small images (400). Only send crops that meet minimum dimensions.
37
- MIN_CROP_HEIGHT = int(os.environ.get("GLMOCR_MIN_CROP_HEIGHT", "112"))
38
- MIN_CROP_PIXELS = int(os.environ.get("GLMOCR_MIN_CROP_PIXELS", "12544")) # 112*112
39
- # Wider PDF bands for position-based extraction (capture account numbers etc. in top/bottom)
40
- PDF_HEADER_BAND_FRAC = float(os.environ.get("GLMOCR_PDF_HEADER_BAND", "0.08")) # top 8%
41
- PDF_FOOTER_BAND_FRAC = float(os.environ.get("GLMOCR_PDF_FOOTER_BAND", "0.85")) # bottom 15%
42
-
43
- # Single shared parser to avoid "GLM-OCR initialized" per request and asyncio cleanup issues.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  _parser = None
45
 
46
  def get_parser():
@@ -48,53 +87,26 @@ def get_parser():
48
  if _parser is None:
49
  from glmocr import GlmOcr
50
  _parser = GlmOcr(
51
- api_key=os.environ.get("GLMOCR_API_KEY", "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"),
52
  mode="maas",
53
  )
54
  return _parser
55
 
 
56
  # ---------------------------------------------------------------------------
57
- # 1. Config: set MaaS and optionally include headers/footers for non-MaaS
58
  # ---------------------------------------------------------------------------
59
  try:
60
  with open(CONFIG_PATH, "r") as f:
61
  config = yaml.safe_load(f)
62
  config["pipeline"]["maas"]["enabled"] = True
63
- config["pipeline"]["maas"]["api_key"] = os.environ.get("GLMOCR_API_KEY", "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w")
64
-
65
- to_include_as_text = {"header", "footer"}
66
- layout_section = config.get("pipeline", {}).get("layout", {})
67
- if "label_task_mapping" in layout_section:
68
- mapping = layout_section["label_task_mapping"]
69
- abandon = mapping.get("abandon")
70
- if isinstance(abandon, list):
71
- mapping["abandon"] = [x for x in abandon if x not in to_include_as_text]
72
- text_labels = mapping.get("text")
73
- if isinstance(text_labels, list):
74
- for label in to_include_as_text:
75
- if label not in text_labels:
76
- text_labels.append(label)
77
-
78
- formatter_section = config.get("pipeline", {}).get("result_formatter", {})
79
- if "abandon" in formatter_section:
80
- abandon = formatter_section["abandon"]
81
- if isinstance(abandon, list):
82
- formatter_section["abandon"] = [x for x in abandon if x not in to_include_as_text]
83
- if "label_visualization_mapping" in formatter_section:
84
- text_vis = formatter_section["label_visualization_mapping"].get("text")
85
- if isinstance(text_vis, list):
86
- for label in to_include_as_text:
87
- if label not in text_vis:
88
- text_vis.append(label)
89
-
90
  with open(CONFIG_PATH, "w") as f:
91
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
92
  except Exception:
93
- pass # e.g. read-only package on Hugging Face; MaaS ignores layout anyway
94
 
95
- # ---------------------------------------------------------------------------
96
- # 2. result_formatter: stop stripping header/footer (best-effort; may be read-only)
97
- # ---------------------------------------------------------------------------
98
  try:
99
  with open(FORMATTER_PATH, "r") as f:
100
  source = f.read()
@@ -108,7 +120,11 @@ except Exception:
108
  pass
109
 
110
 
 
 
 
111
  def get_header_footer_zones(regions, norm_height=1000):
 
112
  if not regions:
113
  return None, None
114
  y_tops, y_bottoms = [], []
@@ -123,7 +139,7 @@ def get_header_footer_zones(regions, norm_height=1000):
123
 
124
 
125
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
126
- """Extract text from a horizontal band using a clip rect."""
127
  try:
128
  import pymupdf as fitz
129
  doc = fitz.open(pdf_path)
@@ -138,8 +154,7 @@ def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
138
 
139
 
140
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
141
- """Extract all text whose word bbox falls in the given vertical band (by position).
142
- Use a wider band (e.g. top 15% / bottom 15%) to capture header/footer that clip might miss."""
143
  try:
144
  import pymupdf as fitz
145
  doc = fitz.open(pdf_path)
@@ -147,7 +162,7 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
147
  h = page.rect.height
148
  y_lo = h * y_start_frac
149
  y_hi = h * y_end_frac
150
- words = page.get_text("words") # list of (x0, y0, x1, y1, word, ...)
151
  doc.close()
152
  parts = []
153
  for w in words:
@@ -161,7 +176,7 @@ def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
161
 
162
 
163
  def ocr_zone(image_path, y_start_frac, y_end_frac):
164
- """Run OCR on a horizontal band. Pads small crops to meet API minimum size instead of skipping."""
165
  zone_name = "header" if y_end_frac < 0.5 else "footer"
166
  try:
167
  from PIL import Image
@@ -171,9 +186,10 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
171
  y1 = min(h, int(h * y_end_frac))
172
  if y1 <= y0:
173
  return ""
 
174
  crop = img.crop((0, y0, w, y1))
175
  cw, ch = crop.size
176
- # Pad to minimum size so MaaS accepts the image (avoids 400 on tiny strips)
177
  if ch < MIN_CROP_HEIGHT or (cw * ch) < MIN_CROP_PIXELS:
178
  need_h = max(ch, MIN_CROP_HEIGHT)
179
  need_w = max(cw, 1)
@@ -181,11 +197,11 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
181
  need_w = max(need_w, (MIN_CROP_PIXELS + need_h - 1) // need_h)
182
  canvas = Image.new("RGB", (need_w, need_h), (255, 255, 255))
183
  if zone_name == "header":
184
- canvas.paste(crop, (0, 0)) # crop at top
185
  else:
186
- canvas.paste(crop, (0, need_h - ch)) # crop at bottom
187
  crop = canvas
188
- cw, ch = crop.size
189
  fd, path = tempfile.mkstemp(suffix=".jpg")
190
  os.close(fd)
191
  try:
@@ -195,8 +211,7 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
195
  if not isinstance(out, list):
196
  out = [out]
197
  if out and getattr(out[0], "markdown_result", None):
198
- text = (out[0].markdown_result or "").strip()
199
- return text
200
  finally:
201
  try:
202
  os.unlink(path)
@@ -207,48 +222,156 @@ def ocr_zone(image_path, y_start_frac, y_end_frac):
207
  return ""
208
 
209
 
210
- def fix_account_number(hdr):
211
- """Fix header text:
212
- 1. If Account Number: has no value, find the number and inject it.
213
- 2. Remove any leading standalone number that is duplicated elsewhere in hdr.
214
- """
215
- import re
216
  if not hdr:
217
  return hdr
218
- # Step 1: inject account number if missing after label
219
  if "Account Number:" in hdr and "Account Number: " not in hdr:
220
- match = re.search("[0-9]{5,}", hdr)
221
- if match:
222
- acct = match.group(0)
223
- hdr = hdr.replace("Account Number:", "Account Number: " + acct)
224
- # Step 2: remove leading number that is duplicated after Account Number:
225
- # e.g. "000000731823008 JPMorgan ... Account Number: 000000731823008"
226
- # -> "JPMorgan ... Account Number: 000000731823008"
227
  acct_match = re.search(r"Account Number: ([0-9]{5,})", hdr)
228
  if acct_match:
229
  acct = acct_match.group(1)
230
- # If hdr starts with that same number, remove it from the start
231
  if hdr.startswith(acct):
232
  hdr = hdr[len(acct):].lstrip()
233
  return hdr
234
 
235
 
236
- def close_unclosed_html(md):
237
- """Close any unclosed <table>, <tbody>, <tr> tags to prevent layout bleed."""
 
 
 
238
  if not md:
239
  return md
240
- import re
241
- open_tags = re.findall(r'<(table|tbody|thead|tr|td|th)[\s>]', md)
242
- close_tags = re.findall(r'</(table|tbody|thead|tr|td|th)>', md)
243
- # Count unclosed tags and close them in reverse order
244
- for tag in reversed(['table', 'tbody', 'thead', 'tr', 'td', 'th']):
245
- opened = open_tags.count(tag)
246
- closed = close_tags.count(tag)
 
 
247
  if opened > closed:
248
- md += '</{}>'.format(tag) * (opened - closed)
249
  return md
250
 
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def get_page_md_and_regions(page_result):
253
  md = ""
254
  if hasattr(page_result, "markdown_result") and page_result.markdown_result:
@@ -267,31 +390,27 @@ def get_page_md_and_regions(page_result):
267
  return md, regions
268
 
269
 
 
 
 
270
  def run_ocr(uploaded_file):
271
  if uploaded_file is None:
272
  return "Please upload a file."
273
- try:
274
- import pymupdf as fitz
275
 
 
 
276
  path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
277
  is_pdf = path.lower().endswith(".pdf")
278
  parser = get_parser()
279
 
 
 
280
  if is_pdf:
281
- doc = fitz.open(path)
282
- page_images = []
283
- page_heights = []
284
- for i in range(len(doc)):
285
- pix = doc[i].get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False)
286
- img_path = os.path.join(tempfile.gettempdir(), f"maas_page_{i}.png")
287
- pix.save(img_path)
288
- page_images.append(img_path)
289
- page_heights.append(pix.height)
290
- doc.close()
291
  results = parser.parse(page_images)
292
  else:
293
  page_images = [path]
294
- page_heights = []
295
  results = parser.parse(path)
296
 
297
  if not isinstance(results, list):
@@ -303,51 +422,68 @@ def run_ocr(uploaded_file):
303
  img_h = page_heights[page_num] if page_num < len(page_heights) else 1000
304
  header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h)
305
 
306
- # MaaS does not return header/footer regions; always use at least default bands
307
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
308
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
309
- if he <= 0:
310
- he = DEFAULT_ZONE_FRAC # ensure we always OCR a top band
311
- if fs >= 1.0:
312
- fs = 1.0 - DEFAULT_ZONE_FRAC # ensure we always OCR a bottom band
313
 
314
  parts = []
315
 
316
- # Always run header band (top 8–12% of page)
317
- if page_num < len(page_images):
318
- img_path = page_images[page_num]
319
- hdr = ""
320
- if is_pdf:
321
- hdr = extract_zone_text_pdf(path, page_num, 0, he)
322
- if not (hdr and hdr.strip()):
323
- hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC)
324
  if not (hdr and hdr.strip()):
325
- hdr = ocr_zone(img_path, 0, he)
326
- if hdr and hdr.strip():
327
- parts.append(fix_account_number(hdr.strip()))
328
-
329
- if page_md:
330
- parts.append(close_unclosed_html(page_md))
331
-
332
- # Footer extraction removed — MaaS API already captures all footer
333
- # content correctly inside page_md. Extracting again causes duplication.
 
 
 
 
 
 
 
 
 
 
 
 
334
 
335
  if parts:
336
  all_pages.append("\n\n".join(parts))
337
 
338
  return "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
 
339
  except Exception as e:
340
  import traceback
341
  log.exception("run_ocr failed: %s", e)
342
  return f"Error: {e}\n\n{traceback.format_exc()}"
343
 
 
 
 
 
 
 
 
 
 
344
 
345
  with gr.Blocks(title="GLM-OCR") as demo:
346
- gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers and footers included.")
347
  file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
348
  run_btn = gr.Button("Run OCR", variant="primary")
349
- out = gr.Textbox(lines=40, label="Output")
350
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
351
 
352
  if __name__ == "__main__":
353
- demo.launch()
 
1
  """
2
+ GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion
3
+ 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
  """
12
+
13
  # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
14
  import asyncio
15
  try:
 
26
  import logging
27
  import os
28
  import re
29
+ import html
30
  import tempfile
31
+ from typing import List, Tuple
32
+
33
  import yaml
34
  import gradio as gr
35
 
36
  import glmocr
37
 
38
  log = logging.getLogger("glmocr_app")
39
+ logging.basicConfig(level=logging.INFO)
40
 
41
  GLMOCR_BASE = os.path.dirname(glmocr.__file__)
42
  CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
43
  FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
44
 
45
+
46
+ # ============================================================
47
+ # HARD-CODED SETTINGS (edit these numbers to tune quality/speed)
48
+ # ============================================================
49
+
50
+ # 1) GLM-OCR MaaS API key
51
+ # IMPORTANT: Do NOT hard-code secrets in a public Space.
52
+ # If your Space is public, switch to HF Secrets instead.
53
+ GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w"
54
+
55
+ # 2) Render quality (higher = better OCR for small/right-aligned digits; slower)
56
+ RENDER_SCALE = 2.2 # try 2.5 if Balance column is still missing
57
+
58
+ # 3) Add padding to protect columns near edges (Balance is usually right-most)
59
+ PAD_LEFT_FRAC = 0.02
60
+ PAD_RIGHT_FRAC = 0.06 # try 0.10 if right-most balances are missing
61
+ PAD_TOP_FRAC = 0.01
62
+ PAD_BOTTOM_FRAC = 0.01
63
+
64
+ # 4) Mild contrast boost (helps faint gray text)
65
+ ENABLE_CONTRAST = True
66
+
67
+ # 5) Header/footer band heuristics
68
+ DEFAULT_ZONE_FRAC = 0.12 # OCR band for header (top 12%) when regions not available
69
+ PDF_HEADER_BAND_FRAC = 0.10 # PDF text fallback: take top 10% words if clip returns empty
70
+
71
+ # Footer: disabled by default to avoid duplicating what GLM already returns
72
+ ENABLE_FOOTER_OCR = False
73
+ PDF_FOOTER_BAND_FRAC = 0.88 # bottom 12% (if footer enabled)
74
+
75
+ # MaaS minimum image sizes for crops; we pad if needed
76
+ MIN_CROP_HEIGHT = 112
77
+ MIN_CROP_PIXELS = 112 * 112
78
+
79
+ # ============================================================
80
+
81
+
82
+ # Single shared parser to avoid re-init per request
83
  _parser = None
84
 
85
  def get_parser():
 
87
  if _parser is None:
88
  from glmocr import GlmOcr
89
  _parser = GlmOcr(
90
+ api_key=GLMOCR_API_KEY,
91
  mode="maas",
92
  )
93
  return _parser
94
 
95
+
96
  # ---------------------------------------------------------------------------
97
+ # Best-effort config tweaks (safe to fail on read-only HF env)
98
  # ---------------------------------------------------------------------------
99
  try:
100
  with open(CONFIG_PATH, "r") as f:
101
  config = yaml.safe_load(f)
102
  config["pipeline"]["maas"]["enabled"] = True
103
+ config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  with open(CONFIG_PATH, "w") as f:
105
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
106
  except Exception:
107
+ pass
108
 
109
+ # Best-effort formatter tweak: avoid stripping header/footer labels
 
 
110
  try:
111
  with open(FORMATTER_PATH, "r") as f:
112
  source = f.read()
 
120
  pass
121
 
122
 
123
+ # --------------------------
124
+ # Header/footer helpers
125
+ # --------------------------
126
  def get_header_footer_zones(regions, norm_height=1000):
127
+ """Infer header/footer extents from bbox regions if present."""
128
  if not regions:
129
  return None, None
130
  y_tops, y_bottoms = [], []
 
139
 
140
 
141
  def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
142
+ """Extract text from a horizontal band using a clip rect (works if PDF has text layer)."""
143
  try:
144
  import pymupdf as fitz
145
  doc = fitz.open(pdf_path)
 
154
 
155
 
156
  def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
157
+ """Extract words whose bbox intersects a vertical band (robust fallback)."""
 
158
  try:
159
  import pymupdf as fitz
160
  doc = fitz.open(pdf_path)
 
162
  h = page.rect.height
163
  y_lo = h * y_start_frac
164
  y_hi = h * y_end_frac
165
+ words = page.get_text("words")
166
  doc.close()
167
  parts = []
168
  for w in words:
 
176
 
177
 
178
  def ocr_zone(image_path, y_start_frac, y_end_frac):
179
+ """Run OCR on a horizontal band. Pads small crops to meet MaaS minimum size."""
180
  zone_name = "header" if y_end_frac < 0.5 else "footer"
181
  try:
182
  from PIL import Image
 
186
  y1 = min(h, int(h * y_end_frac))
187
  if y1 <= y0:
188
  return ""
189
+
190
  crop = img.crop((0, y0, w, y1))
191
  cw, ch = crop.size
192
+
193
  if ch < MIN_CROP_HEIGHT or (cw * ch) < MIN_CROP_PIXELS:
194
  need_h = max(ch, MIN_CROP_HEIGHT)
195
  need_w = max(cw, 1)
 
197
  need_w = max(need_w, (MIN_CROP_PIXELS + need_h - 1) // need_h)
198
  canvas = Image.new("RGB", (need_w, need_h), (255, 255, 255))
199
  if zone_name == "header":
200
+ canvas.paste(crop, (0, 0))
201
  else:
202
+ canvas.paste(crop, (0, need_h - ch))
203
  crop = canvas
204
+
205
  fd, path = tempfile.mkstemp(suffix=".jpg")
206
  os.close(fd)
207
  try:
 
211
  if not isinstance(out, list):
212
  out = [out]
213
  if out and getattr(out[0], "markdown_result", None):
214
+ return (out[0].markdown_result or "").strip()
 
215
  finally:
216
  try:
217
  os.unlink(path)
 
222
  return ""
223
 
224
 
225
+ def fix_account_number(hdr: str) -> str:
226
+ """Fix common account-number formatting issues."""
 
 
 
 
227
  if not hdr:
228
  return hdr
 
229
  if "Account Number:" in hdr and "Account Number: " not in hdr:
230
+ m = re.search(r"[0-9]{5,}", hdr)
231
+ if m:
232
+ hdr = hdr.replace("Account Number:", "Account Number: " + m.group(0))
 
 
 
 
233
  acct_match = re.search(r"Account Number: ([0-9]{5,})", hdr)
234
  if acct_match:
235
  acct = acct_match.group(1)
 
236
  if hdr.startswith(acct):
237
  hdr = hdr[len(acct):].lstrip()
238
  return hdr
239
 
240
 
241
+ # --------------------------
242
+ # Table stabilization helpers
243
+ # --------------------------
244
+ def close_unclosed_html(md: str) -> str:
245
+ """Close unclosed <table>/<tr>/<td> tags to prevent bleed."""
246
  if not md:
247
  return md
248
+ open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE)
249
+ close_tags = re.findall(r"</(table|tbody|thead|tr|td|th)>", md, flags=re.IGNORECASE)
250
+
251
+ def count(tags, name):
252
+ return sum(1 for t in tags if t.lower() == name)
253
+
254
+ for tag in reversed(["td", "th", "tr", "thead", "tbody", "table"]):
255
+ opened = count(open_tags, tag)
256
+ closed = count(close_tags, tag)
257
  if opened > closed:
258
+ md += ("</%s>" % tag) * (opened - closed)
259
  return md
260
 
261
 
262
+ def looks_like_markdown_table(block: str) -> bool:
263
+ """Detect simple markdown pipe tables."""
264
+ lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()]
265
+ if len(lines) < 2:
266
+ return False
267
+ if "|" not in lines[0]:
268
+ return False
269
+ sep = lines[1].replace(" ", "")
270
+ return ("---" in sep) and ("|" in sep)
271
+
272
+
273
+ def md_table_to_html(block: str) -> str:
274
+ """Convert a simple markdown pipe table to HTML table (best-effort)."""
275
+ lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()]
276
+ if len(lines) < 2:
277
+ return block
278
+
279
+ def split_row(row: str):
280
+ row = row.strip()
281
+ if row.startswith("|"):
282
+ row = row[1:]
283
+ if row.endswith("|"):
284
+ row = row[:-1]
285
+ return [p.strip() for p in row.split("|")]
286
+
287
+ header = split_row(lines[0])
288
+ body_lines = [ln for ln in lines[2:] if "|" in ln]
289
+
290
+ html_rows = []
291
+ html_rows.append("<tr>" + "".join(f"<th>{html.escape(c)}</th>" for c in header) + "</tr>")
292
+ for ln in body_lines:
293
+ cols = split_row(ln)
294
+ if len(cols) < len(header):
295
+ cols += [""] * (len(header) - len(cols))
296
+ html_rows.append("<tr>" + "".join(f"<td>{html.escape(c)}</td>" for c in cols[:len(header)]) + "</tr>")
297
+ return "<table>\n" + "\n".join(html_rows) + "\n</table>"
298
+
299
+
300
+ def normalize_money_glyphs(text: str) -> str:
301
+ """Conservative normalization for OCR number quirks."""
302
+ if not text:
303
+ return text
304
+ t = text.replace("−", "-").replace("–", "-").replace("—", "-")
305
+ t = re.sub(r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)", r"-\1\2", t)
306
+
307
+ def o_to_zero(m):
308
+ token = m.group(0)
309
+ return token.replace("O", "0").replace("o", "0")
310
+ t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t)
311
+ return t
312
+
313
+
314
+ def stabilize_tables_and_text(page_md: str) -> str:
315
+ """Convert markdown pipe tables to HTML and close tags."""
316
+ if not page_md:
317
+ return page_md
318
+ page_md = normalize_money_glyphs(page_md)
319
+
320
+ blocks = re.split(r"\n\s*\n", page_md.strip())
321
+ out_blocks = []
322
+ for b in blocks:
323
+ if looks_like_markdown_table(b):
324
+ out_blocks.append(md_table_to_html(b))
325
+ else:
326
+ out_blocks.append(b)
327
+
328
+ stabilized = "\n\n".join(out_blocks)
329
+ return close_unclosed_html(stabilized)
330
+
331
+
332
+ # --------------------------
333
+ # PDF rendering with padding (critical for Balance column)
334
+ # --------------------------
335
+ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
336
+ import pymupdf as fitz
337
+ from PIL import Image, ImageEnhance
338
+
339
+ doc = fitz.open(pdf_path)
340
+ page_images: List[str] = []
341
+ page_heights: List[int] = []
342
+
343
+ for i in range(len(doc)):
344
+ page = doc[i]
345
+ pix = page.get_pixmap(matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False)
346
+
347
+ img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
348
+
349
+ if ENABLE_CONTRAST:
350
+ img = ImageEnhance.Contrast(img).enhance(1.12)
351
+
352
+ w, h = img.size
353
+ pad_l = int(w * PAD_LEFT_FRAC)
354
+ pad_r = int(w * PAD_RIGHT_FRAC)
355
+ pad_t = int(h * PAD_TOP_FRAC)
356
+ pad_b = int(h * PAD_BOTTOM_FRAC)
357
+
358
+ if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)):
359
+ canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255))
360
+ canvas.paste(img, (pad_l, pad_t))
361
+ img = canvas
362
+
363
+ img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png")
364
+ img.save(img_path, "PNG", compress_level=6)
365
+ page_images.append(img_path)
366
+ page_heights.append(img.height)
367
+
368
+ doc.close()
369
+ return page_images, page_heights
370
+
371
+
372
+ # --------------------------
373
+ # GLM-OCR result extraction
374
+ # --------------------------
375
  def get_page_md_and_regions(page_result):
376
  md = ""
377
  if hasattr(page_result, "markdown_result") and page_result.markdown_result:
 
390
  return md, regions
391
 
392
 
393
+ # --------------------------
394
+ # Main entry
395
+ # --------------------------
396
  def run_ocr(uploaded_file):
397
  if uploaded_file is None:
398
  return "Please upload a file."
 
 
399
 
400
+ page_images = []
401
+ try:
402
  path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
403
  is_pdf = path.lower().endswith(".pdf")
404
  parser = get_parser()
405
 
406
+ page_heights = []
407
+
408
  if is_pdf:
409
+ page_images, page_heights = render_pdf_pages_to_images(path)
 
 
 
 
 
 
 
 
 
410
  results = parser.parse(page_images)
411
  else:
412
  page_images = [path]
413
+ page_heights = [1000]
414
  results = parser.parse(path)
415
 
416
  if not isinstance(results, list):
 
422
  img_h = page_heights[page_num] if page_num < len(page_heights) else 1000
423
  header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h)
424
 
 
425
  he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
426
  fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
427
+
428
+ # clamp
429
+ he = max(0.02, min(0.25, he))
430
+ fs = max(0.75, min(0.98, fs))
431
 
432
  parts = []
433
 
434
+ # Header inclusion: PDF text -> band words -> OCR band
435
+ hdr = ""
436
+ if is_pdf:
437
+ hdr = extract_zone_text_pdf(path, page_num, 0, he)
 
 
 
 
438
  if not (hdr and hdr.strip()):
439
+ hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC)
440
+ if not (hdr and hdr.strip()) and page_num < len(page_images):
441
+ hdr = ocr_zone(page_images[page_num], 0, he)
442
+ if hdr and hdr.strip():
443
+ parts.append(fix_account_number(normalize_money_glyphs(hdr.strip())))
444
+
445
+ # Main OCR markdown, stabilized
446
+ if page_md and page_md.strip():
447
+ parts.append(stabilize_tables_and_text(page_md.strip()))
448
+
449
+ # Optional footer
450
+ if ENABLE_FOOTER_OCR and page_num < len(page_images):
451
+ ftr = ""
452
+ if is_pdf:
453
+ ftr = extract_zone_text_pdf(path, page_num, fs, 1.0)
454
+ if not (ftr and ftr.strip()):
455
+ ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0)
456
+ if not (ftr and ftr.strip()):
457
+ ftr = ocr_zone(page_images[page_num], fs, 1.0)
458
+ if ftr and ftr.strip():
459
+ parts.append(normalize_money_glyphs(ftr.strip()))
460
 
461
  if parts:
462
  all_pages.append("\n\n".join(parts))
463
 
464
  return "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
465
+
466
  except Exception as e:
467
  import traceback
468
  log.exception("run_ocr failed: %s", e)
469
  return f"Error: {e}\n\n{traceback.format_exc()}"
470
 
471
+ finally:
472
+ # cleanup rendered images
473
+ for p in page_images:
474
+ try:
475
+ if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p):
476
+ os.unlink(p)
477
+ except Exception:
478
+ pass
479
+
480
 
481
  with gr.Blocks(title="GLM-OCR") as demo:
482
+ gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.")
483
  file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
484
  run_btn = gr.Button("Run OCR", variant="primary")
485
+ out = gr.Textbox(lines=40, label="Output (markdown)")
486
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
487
 
488
  if __name__ == "__main__":
489
+ demo.launch()