""" GLM-OCR Hugging Face Space app for PDF/image OCR with header inclusion and table-structure stabilization for downstream bank-statement pipelines. Hard-coded knobs (no environment variables required). Primary goal for reconcile rate: - preserve right-most columns (often "Balance") by higher DPI render + right padding - keep tables as tables (convert markdown pipe tables -> HTML table) - return ---page-separator--- between pages - normalize HTML tables so header/data columns align (expand colspan/rowspan) - clean common header artifacts (e.g. "DESCRIPTIONBeginning Balance", "BALANCE$3,447.10") """ # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise import asyncio try: _orig_close = asyncio.BaseEventLoop.close def _safe_close(self): try: _orig_close(self) except (ValueError, OSError): pass asyncio.BaseEventLoop.close = _safe_close except Exception: pass import logging import os import re import html import tempfile from typing import List, Tuple from collections import defaultdict from html.parser import HTMLParser import yaml import gradio as gr import glmocr log = logging.getLogger("glmocr_app") logging.basicConfig(level=logging.INFO) GLMOCR_BASE = os.path.dirname(glmocr.__file__) CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml") FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py") # ============================================================ # HARD-CODED SETTINGS (edit these numbers to tune quality/speed) # ============================================================ # 1) GLM-OCR MaaS API key # IMPORTANT: Do NOT hard-code secrets in a public Space. # If your Space is public, switch to HF Secrets instead. GLMOCR_API_KEY = "e2b138b2005a41cb9d87dd18805838aa.lyd51L23rcDbsw0w" # 2) Render quality (higher = better OCR for small/right-aligned digits; slower) RENDER_SCALE = 2.2 # try 2.5 if Balance column is still missing # 3) Add padding to protect columns near edges (Balance is usually right-most) PAD_LEFT_FRAC = 0.02 PAD_RIGHT_FRAC = 0.06 # try 0.10 if right-most balances are missing PAD_TOP_FRAC = 0.01 PAD_BOTTOM_FRAC = 0.01 # 4) Mild contrast boost (helps faint gray text) ENABLE_CONTRAST = True # 5) Header/footer band heuristics DEFAULT_ZONE_FRAC = 0.12 # OCR band for header (top 12%) when regions not available PDF_HEADER_BAND_FRAC = 0.10 # PDF text fallback: take top 10% words if clip returns empty # Footer: disabled by default to avoid duplicating what GLM already returns ENABLE_FOOTER_OCR = False PDF_FOOTER_BAND_FRAC = 0.88 # bottom 12% (if footer enabled) # MaaS minimum image sizes for crops; we pad if needed MIN_CROP_HEIGHT = 112 MIN_CROP_PIXELS = 112 * 112 # ============================================================ # Single shared parser to avoid re-init per request _parser = None def get_parser(): global _parser if _parser is None: from glmocr import GlmOcr _parser = GlmOcr( api_key=GLMOCR_API_KEY, mode="maas", ) return _parser # --------------------------------------------------------------------------- # Best-effort config tweaks (safe to fail on read-only HF env) # --------------------------------------------------------------------------- try: with open(CONFIG_PATH, "r") as f: config = yaml.safe_load(f) config["pipeline"]["maas"]["enabled"] = True config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY with open(CONFIG_PATH, "w") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) except Exception: pass # Best-effort formatter tweak: avoid stripping header/footer labels try: with open(FORMATTER_PATH, "r") as f: source = f.read() for label in ('"header"', "'header'", '"footer"', "'footer'", '"doc_header"', "'doc_header'", '"doc_footer"', "'doc_footer'"): source = re.sub(r",\s*" + re.escape(label), "", source) source = re.sub(re.escape(label) + r"\s*,", "", source) source = re.sub(re.escape(label), "", source) with open(FORMATTER_PATH, "w") as f: f.write(source) except Exception: pass # -------------------------- # Header/footer helpers # -------------------------- def get_header_footer_zones(regions, norm_height=1000): """Infer header/footer extents from bbox regions if present.""" if not regions: return None, None y_tops, y_bottoms = [], [] for r in regions: bbox = r.get("bbox_2d") if isinstance(r, dict) else getattr(r, "bbox_2d", None) if bbox and len(bbox) >= 4: y_tops.append(bbox[1]) y_bottoms.append(bbox[3]) if not y_tops: return None, None return min(y_tops) / norm_height, max(y_bottoms) / norm_height def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac): """Extract text from a horizontal band using a clip rect (works if PDF has text layer).""" try: import pymupdf as fitz doc = fitz.open(pdf_path) page = doc[page_num] h, w = page.rect.height, page.rect.width rect = fitz.Rect(0, h * y_start_frac, w, h * y_end_frac) text = page.get_text(clip=rect).strip() doc.close() return text except Exception: return "" def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac): """Extract words whose bbox intersects a vertical band (robust fallback).""" try: import pymupdf as fitz doc = fitz.open(pdf_path) page = doc[page_num] h = page.rect.height y_lo = h * y_start_frac y_hi = h * y_end_frac words = page.get_text("words") doc.close() parts = [] for w in words: if len(w) >= 5: y0, y1 = float(w[1]), float(w[3]) if y0 < y_hi and y1 > y_lo: parts.append(w[4]) return " ".join(parts).strip() except Exception: return "" def ocr_zone(image_path, y_start_frac, y_end_frac): """Run OCR on a horizontal band. Pads small crops to meet MaaS minimum size.""" zone_name = "header" if y_end_frac < 0.5 else "footer" try: from PIL import Image img = Image.open(image_path).convert("RGB") w, h = img.size y0 = max(0, int(h * y_start_frac)) y1 = min(h, int(h * y_end_frac)) if y1 <= y0: return "" crop = img.crop((0, y0, w, y1)) cw, ch = crop.size if ch < MIN_CROP_HEIGHT or (cw * ch) < MIN_CROP_PIXELS: need_h = max(ch, MIN_CROP_HEIGHT) need_w = max(cw, 1) if (need_w * need_h) < MIN_CROP_PIXELS: need_w = max(need_w, (MIN_CROP_PIXELS + need_h - 1) // need_h) canvas = Image.new("RGB", (need_w, need_h), (255, 255, 255)) if zone_name == "header": canvas.paste(crop, (0, 0)) else: canvas.paste(crop, (0, need_h - ch)) crop = canvas fd, path = tempfile.mkstemp(suffix=".jpg") os.close(fd) try: crop.save(path, "JPEG", quality=92) parser = get_parser() out = parser.parse(path) if not isinstance(out, list): out = [out] if out and getattr(out[0], "markdown_result", None): return (out[0].markdown_result or "").strip() finally: try: os.unlink(path) except Exception: pass except Exception as e: log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True) return "" def fix_account_number(hdr: str) -> str: """Fix common account-number formatting issues.""" if not hdr: return hdr if "Account Number:" in hdr and "Account Number: " not in hdr: m = re.search(r"[0-9]{5,}", hdr) if m: hdr = hdr.replace("Account Number:", "Account Number: " + m.group(0)) acct_match = re.search(r"Account Number: ([0-9]{5,})", hdr) if acct_match: acct = acct_match.group(1) if hdr.startswith(acct): hdr = hdr[len(acct):].lstrip() return hdr # -------------------------- # Table stabilization helpers # -------------------------- def close_unclosed_html(md: str) -> str: """Close unclosed //
tags to prevent bleed.""" if not md: return md open_tags = re.findall(r"<(table|tbody|thead|tr|td|th)\b", md, flags=re.IGNORECASE) close_tags = re.findall(r"", md, flags=re.IGNORECASE) def count(tags, name): return sum(1 for t in tags if t.lower() == name) for tag in reversed(["td", "th", "tr", "thead", "tbody", "table"]): opened = count(open_tags, tag) closed = count(close_tags, tag) if opened > closed: md += ("" % tag) * (opened - closed) return md def clean_table_header_artifacts(text: str) -> str: """ Generic cleanup for common OCR artifacts in table headers: - 'DESCRIPTIONBeginning Balance' -> 'DESCRIPTION' - 'DESCRIPTIONEnding Balance' -> 'DESCRIPTION' - 'BALANCE$3,447.10' -> 'BALANCE' - 'BALANCE 3,447.10' -> 'BALANCE' Works for any PDF; no bank-specific logic. """ if not text: return text # DESCRIPTION + (Beginning/Ending Balance) glued text = re.sub( r"(>[^<]*\bDESCRIPTION)\s*(Beginning Balance|Ending Balance)\b([^<]*<)", r"\1\3", text, flags=re.IGNORECASE, ) # BALANCE with a number glued or appended (keep the word BALANCE only) text = re.sub( r"(>[^<]*\bBALANCE)\s*\$?\s*\d{1,3}(?:,\d{3})*(?:\.\d{2})?\s*([^<]*<)", r"\1\2", text, flags=re.IGNORECASE, ) text = re.sub( r"(>[^<]*\bBALANCE)\$", r"\1", text, flags=re.IGNORECASE, ) return text # ---- Colspan/rowspan expansion: align header and data rows for any PDF ---- class TableGridParser(HTMLParser): """Parse HTML into rows of (text, colspan, rowspan).""" def __init__(self): super().__init__() self.rows = [] self._current_row = [] self._cell_text = [] self._colspan = 1 self._rowspan = 1 def handle_starttag(self, tag, attrs): if tag == "tr": self._current_row = [] elif tag in ("td", "th"): attrs_d = dict(attrs) self._colspan = max(1, int(attrs_d.get("colspan", 1))) self._rowspan = max(1, int(attrs_d.get("rowspan", 1))) self._cell_text = [] def handle_endtag(self, tag): if tag in ("td", "th"): text = "".join(self._cell_text).strip().replace("\n", " ") self._current_row.append((text, self._colspan, self._rowspan)) elif tag == "tr": self.rows.append(self._current_row) def handle_data(self, data): self._cell_text.append(data) def _build_grid(rows_data): """Expand colspan/rowspan into a rectangular grid.""" if not rows_data: return [] blocked = defaultdict(set) grid = [] for r, row_cells in enumerate(rows_data): grid.append([]) col = 0 for content, C, R in row_cells: while col in blocked[r]: grid[r].append("") col += 1 for k in range(C): grid[r].append(content if k == 0 else "") for k in range(1, R): blocked[r + k].add(col) col += C max_cols = max(len(row) for row in grid) if grid else 0 for row in grid: while len(row) < max_cols: row.append("") return grid def _grid_to_html(grid): """Emit a normalized table without colspan/rowspan.""" if not grid: return "" lines = ["
"] for i, row in enumerate(grid): lines.append("") tag = "th" if i == 0 else "td" for cell in row: escaped = (cell or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) lines.append(f"<{tag}>{escaped}") lines.append("") lines.append("
") return "\n".join(lines) def expand_colspan_rowspan_in_tables(text): """Normalize every ...
in text.""" if not text or "]*>.*?
", re.DOTALL | re.IGNORECASE) result = [] last_end = 0 for match in pattern.finditer(text): result.append(text[last_end : match.start()]) table_html = match.group(0) try: parser = TableGridParser() parser.feed(table_html) if parser.rows: grid = _build_grid(parser.rows) result.append(_grid_to_html(grid)) else: result.append(table_html) except Exception: result.append(table_html) last_end = match.end() result.append(text[last_end:]) return "".join(result) def looks_like_markdown_table(block: str) -> bool: """Detect simple markdown pipe tables.""" lines = [ln.rstrip() for ln in block.strip().splitlines() if ln.strip()] if len(lines) < 2: return False if "|" not in lines[0]: return False sep = lines[1].replace(" ", "") return ("---" in sep) and ("|" in sep) def md_table_to_html(block: str) -> str: """Convert a simple markdown pipe table to HTML table (best-effort).""" lines = [ln.strip() for ln in block.strip().splitlines() if ln.strip()] if len(lines) < 2: return block def split_row(row: str): row = row.strip() if row.startswith("|"): row = row[1:] if row.endswith("|"): row = row[:-1] return [p.strip() for p in row.split("|")] header = split_row(lines[0]) body_lines = [ln for ln in lines[2:] if "|" in ln] html_rows = [] html_rows.append("" + "".join(f"{html.escape(c)}" for c in header) + "") for ln in body_lines: cols = split_row(ln) if len(cols) < len(header): cols += [""] * (len(header) - len(cols)) html_rows.append("" + "".join(f"{html.escape(c)}" for c in cols[:len(header)]) + "") return "\n" + "\n".join(html_rows) + "\n
" def normalize_money_glyphs(text: str) -> str: """Conservative normalization for OCR number quirks.""" if not text: return text t = text.replace("−", "-").replace("–", "-").replace("—", "-") t = re.sub(r"\(\s*\$?\s*([0-9]{1,3}(?:,[0-9]{3})*|[0-9]+)(\.[0-9]{2})\s*\)", r"-\1\2", t) def o_to_zero(m): token = m.group(0) return token.replace("O", "0").replace("o", "0") t = re.sub(r"\b[0-9Oo\$,.\-]{4,}\b", o_to_zero, t) return t def stabilize_tables_and_text(page_md: str) -> str: """Convert markdown pipe tables to HTML, clean header artifacts, normalize tables, and close tags.""" if not page_md: return page_md page_md = normalize_money_glyphs(page_md) blocks = re.split(r"\n\s*\n", page_md.strip()) out_blocks = [] for b in blocks: if looks_like_markdown_table(b): out_blocks.append(md_table_to_html(b)) else: out_blocks.append(b) stabilized = "\n\n".join(out_blocks) # 1) Fix common header text artifacts stabilized = clean_table_header_artifacts(stabilized) # 2) Normalize tables to a rectangular grid (expand colspan/rowspan) stabilized = expand_colspan_rowspan_in_tables(stabilized) # 3) Close any unclosed tags to prevent bleed return close_unclosed_html(stabilized) # -------------------------- # PDF rendering with padding (critical for Balance column) # -------------------------- def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]: import pymupdf as fitz from PIL import Image, ImageEnhance doc = fitz.open(pdf_path) page_images: List[str] = [] page_heights: List[int] = [] for i in range(len(doc)): page = doc[i] pix = page.get_pixmap(matrix=fitz.Matrix(RENDER_SCALE, RENDER_SCALE), alpha=False) img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) if ENABLE_CONTRAST: img = ImageEnhance.Contrast(img).enhance(1.12) w, h = img.size pad_l = int(w * PAD_LEFT_FRAC) pad_r = int(w * PAD_RIGHT_FRAC) pad_t = int(h * PAD_TOP_FRAC) pad_b = int(h * PAD_BOTTOM_FRAC) if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)): canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255)) canvas.paste(img, (pad_l, pad_t)) img = canvas img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}.png") img.save(img_path, "PNG", compress_level=6) page_images.append(img_path) page_heights.append(img.height) doc.close() return page_images, page_heights # -------------------------- # GLM-OCR result extraction # -------------------------- def get_page_md_and_regions(page_result): md = "" if hasattr(page_result, "markdown_result") and page_result.markdown_result: md = (page_result.markdown_result or "").strip() regions = [] if hasattr(page_result, "json_result"): jr = page_result.json_result if isinstance(jr, dict) and "regions" in jr: regions = jr.get("regions") or [] elif isinstance(jr, list) and len(jr) > 0: r = jr[0] if isinstance(jr[0], list) else jr if isinstance(r, list): regions = r elif isinstance(r, dict) and "regions" in r: regions = r.get("regions") or [] return md, regions # -------------------------- # Main entry # -------------------------- def run_ocr(uploaded_file): if uploaded_file is None: return "Please upload a file." page_images = [] try: path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file) is_pdf = path.lower().endswith(".pdf") parser = get_parser() page_heights = [] if is_pdf: page_images, page_heights = render_pdf_pages_to_images(path) results = parser.parse(page_images) else: page_images = [path] page_heights = [1000] results = parser.parse(path) if not isinstance(results, list): results = [results] all_pages = [] for page_num, page_result in enumerate(results): page_md, regions = get_page_md_and_regions(page_result) img_h = page_heights[page_num] if page_num < len(page_heights) else 1000 header_end_frac, footer_start_frac = get_header_footer_zones(regions, img_h) he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC) # clamp he = max(0.02, min(0.25, he)) fs = max(0.75, min(0.98, fs)) parts = [] # Header inclusion: PDF text -> band words -> OCR band hdr = "" if is_pdf: hdr = extract_zone_text_pdf(path, page_num, 0, he) if not (hdr and hdr.strip()): hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC) if not (hdr and hdr.strip()) and page_num < len(page_images): hdr = ocr_zone(page_images[page_num], 0, he) if hdr and hdr.strip(): parts.append(fix_account_number(normalize_money_glyphs(hdr.strip()))) # Main OCR markdown, stabilized if page_md and page_md.strip(): parts.append(stabilize_tables_and_text(page_md.strip())) # Optional footer if ENABLE_FOOTER_OCR and page_num < len(page_images): ftr = "" if is_pdf: ftr = extract_zone_text_pdf(path, page_num, fs, 1.0) if not (ftr and ftr.strip()): ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0) if not (ftr and ftr.strip()): ftr = ocr_zone(page_images[page_num], fs, 1.0) if ftr and ftr.strip(): parts.append(normalize_money_glyphs(ftr.strip())) if parts: all_pages.append("\n\n".join(parts)) return "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)" except Exception as e: import traceback log.exception("run_ocr failed: %s", e) return f"Error: {e}\n\n{traceback.format_exc()}" finally: # cleanup rendered images for p in page_images: try: if isinstance(p, str) and p.endswith(".png") and "glmocr_page_" in os.path.basename(p): os.unlink(p) except Exception: pass with gr.Blocks(title="GLM-OCR") as demo: gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers included; tables stabilized.") file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"]) run_btn = gr.Button("Run OCR", variant="primary") out = gr.Textbox(lines=40, label="Output (markdown)") run_btn.click(fn=run_ocr, inputs=file_in, outputs=out) if __name__ == "__main__": demo.launch()