#!/usr/bin/env python3 """ Simplified GLM-OCR Hugging Face / local Gradio app. Scope (intentionally small): - PDF → padded high-DPI page images → GLM-OCR body markdown - Header band: PDF text extraction first, optional header OCR fallback - Footer band: same pattern, with light dedup so we do not paste a full transaction dump twice when the body already captured it Universal image pipeline (same for every PDF, no keywords / no bank logic): - Higher rasterization scale + extra white padding so fine print, boxed section labels, and right-aligned amounts sit farther from the clip edge. - Mild contrast + unsharp mask on every raster sent to the model so thin rules and small glyphs are easier to read before recognition. Explicitly omitted vs the heavy Space build: - No HTML table rewriting, text-layer row injection, institution-specific splits (UCB / Navy / TD / First Horizon / …), or doc-wide dedupe passes Configure GLMOCR_API_KEY (environment variable). Optional: glmocr + gradio + pymupdf + pillow installed. """ # Patch asyncio first (before Gradio imports it) to reduce Python 3.13 loop 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 html import logging import os import re import tempfile from typing import List, Optional, Tuple import yaml try: import glmocr GLMOCR_BASE = os.path.dirname(glmocr.__file__) CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml") except ImportError: glmocr = None # type: ignore GLMOCR_BASE = "" CONFIG_PATH = "" log = logging.getLogger("glmocr_simple_app") logging.basicConfig(level=logging.INFO) # --------------------------------------------------------------------------- # Settings — tuned for dense financial PDFs; applies to every document # --------------------------------------------------------------------------- GLMOCR_API_KEY = os.environ.get("GLMOCR_API_KEY", "").strip() if not GLMOCR_API_KEY: log.warning("GLMOCR_API_KEY is not set; GlmOcr() will fail until you export it.") # Rasterization: higher scale = more pixels per PDF point (helps small type, # boxed headers, and narrow columns). Same constant for all uploads. RENDER_SCALE = 2.85 # White margin as a fraction of page width/height after render. Extra right # margin helps right-aligned currency columns that hug the page edge. PAD_LEFT_FRAC = 0.03 PAD_RIGHT_FRAC = 0.10 PAD_TOP_FRAC = 0.015 PAD_BOTTOM_FRAC = 0.015 ENABLE_CONTRAST = True # Slight contrast lift only; same factor for every file. CONTRAST_FACTOR = 1.12 # Subtle edge enhancement after contrast (helps hairlines and small digits). ENABLE_UNSHARP = True UNSHARP_RADIUS = 0.85 UNSHARP_PERCENT = 65 UNSHARP_THRESHOLD = 2 DEFAULT_ZONE_FRAC = 0.12 PDF_HEADER_BAND_FRAC = 0.10 ENABLE_FOOTER_OCR = True PDF_FOOTER_BAND_FRAC = 0.88 MIN_CROP_HEIGHT = 112 MIN_CROP_PIXELS = 112 * 112 # PNG compression 0–9; lower = less loss before GLM-OCR (same for all PDFs). PAGE_PNG_COMPRESS_LEVEL = 3 # JPEG quality for small header/footer crops sent to the API. ZONE_JPEG_QUALITY = 95 _parser = None def _enhance_raster_for_ocr(img): """ Improve legibility of every raster passed to GLM-OCR (full pages and header/footer crops). No document text or keywords — same pipeline for all PDFs and images. """ from PIL import ImageEnhance, ImageFilter if ENABLE_CONTRAST: img = ImageEnhance.Contrast(img).enhance(CONTRAST_FACTOR) if ENABLE_UNSHARP: img = img.filter( ImageFilter.UnsharpMask( radius=UNSHARP_RADIUS, percent=UNSHARP_PERCENT, threshold=UNSHARP_THRESHOLD, ) ) return img def get_parser(): global _parser if glmocr is None: raise RuntimeError("glmocr is not installed.") if _parser is None: from glmocr import GlmOcr _parser = GlmOcr(api_key=GLMOCR_API_KEY, mode="maas") return _parser if CONFIG_PATH: try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: config = yaml.safe_load(f) config.setdefault("pipeline", {}).setdefault("maas", {}) config["pipeline"]["maas"]["enabled"] = True config["pipeline"]["maas"]["api_key"] = GLMOCR_API_KEY with open(CONFIG_PATH, "w", encoding="utf-8") as f: yaml.dump(config, f, default_flow_style=False, sort_keys=False) except Exception: pass def get_header_footer_zones(regions, norm_height=1000): 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): 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): 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): 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=ZONE_JPEG_QUALITY) 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: 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 def close_unclosed_html(md: str) -> str: 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 looks_like_markdown_table(block: str) -> bool: 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: 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("