""" 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"(table|tbody|thead|tr|td|th)>", 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 += ("%s>" % 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.escape(c)} | " for c in header) + "{html.escape(c)} | " for c in cols[:len(header)]) + " |