#!/usr/bin/env python3 import logging import os import re import subprocess import tempfile import time from typing import Any, List import gradio as gr import httpx import yaml log = logging.getLogger("glmocr_selfhosted_space") logging.basicConfig(level=logging.INFO) MODEL_ID = "zai-org/GLM-OCR" OCR_API_PORT = 8080 OCR_API_BASE = f"http://127.0.0.1:{OCR_API_PORT}" VLLM_MAX_MODEL_LEN = "8192" VLLM_GPU_MEMORY_UTIL = "0.75" VLLM_EXTRA_ARGS = "" GLMOCR_MAX_OUTPUT_TOKENS = 2048 _parser = None _vllm_proc = None def _render_pdf_pages_to_images(pdf_path: str) -> List[str]: import pymupdf as fitz doc = fitz.open(pdf_path) page_images: List[str] = [] try: for i in range(len(doc)): page = doc[i] pix = page.get_pixmap(matrix=fitz.Matrix(2.5, 2.5), alpha=False) img_path = os.path.join( tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{i}_{int(time.time()*1000)}.png", ) pix.save(img_path) page_images.append(img_path) finally: doc.close() return page_images def _start_vllm_if_needed() -> None: global _vllm_proc if _vllm_proc is not None and _vllm_proc.poll() is None: return cmd: List[str] = [ "python", "-m", "vllm.entrypoints.openai.api_server", "--model", MODEL_ID, "--served-model-name", "glm-ocr", "--port", str(OCR_API_PORT), "--max-model-len", VLLM_MAX_MODEL_LEN, "--gpu-memory-utilization", VLLM_GPU_MEMORY_UTIL, ] if VLLM_EXTRA_ARGS: cmd.extend(VLLM_EXTRA_ARGS.split()) log.info("Starting vLLM server: %s", " ".join(cmd)) _vllm_proc = subprocess.Popen(cmd) deadline = time.time() + 420 last_err: str = "" while time.time() < deadline: if _vllm_proc.poll() is not None: raise RuntimeError("vLLM server exited early. Check Space logs for details.") try: resp = httpx.get(f"{OCR_API_BASE}/v1/models", timeout=8.0) if resp.status_code == 200: log.info("vLLM is ready.") return except Exception as e: last_err = str(e) time.sleep(3) raise RuntimeError(f"Timed out waiting for vLLM startup. Last error: {last_err}") def _configure_glmocr_to_keep_header_footer() -> None: import glmocr config_path = os.path.join(os.path.dirname(glmocr.__file__), "config.yaml") with open(config_path, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} pipeline = cfg.setdefault("pipeline", {}) maas = pipeline.setdefault("maas", {}) maas["enabled"] = False ocr_api = pipeline.setdefault("ocr_api", {}) ocr_api["api_host"] = "127.0.0.1" ocr_api["api_port"] = OCR_API_PORT ocr_api["model"] = "glm-ocr" ocr_api["api_path"] = "/v1/chat/completions" ocr_api["api_mode"] = "openai" ocr_api["verify_ssl"] = False page_loader = pipeline.setdefault("page_loader", {}) page_loader["max_tokens"] = GLMOCR_MAX_OUTPUT_TOKENS layout = pipeline.setdefault("layout", {}) label_task_mapping = layout.setdefault("label_task_mapping", {}) text_labels = set(label_task_mapping.get("text", []) or []) abandon_labels = set(label_task_mapping.get("abandon", []) or []) skip_labels = set(label_task_mapping.get("skip", []) or []) text_labels.update({"header", "footer"}) abandon_labels.discard("header") abandon_labels.discard("footer") skip_labels.update({"header_image", "footer_image"}) abandon_labels.discard("header_image") abandon_labels.discard("footer_image") label_task_mapping["text"] = sorted(text_labels) label_task_mapping["abandon"] = sorted(abandon_labels) label_task_mapping["skip"] = sorted(skip_labels) with open(config_path, "w", encoding="utf-8") as f: yaml.safe_dump(cfg, f, sort_keys=False) def get_parser(): global _parser if _parser is None: _start_vllm_if_needed() _configure_glmocr_to_keep_header_footer() from glmocr import GlmOcr _parser = GlmOcr(mode="selfhosted") return _parser def _extract_md(result: Any) -> str: if result is None: return "" def _close_unbalanced_tables(chunk: str) -> str: opens = len(re.findall(r"", chunk, flags=re.IGNORECASE)) missing = max(0, opens - closes) if missing: chunk = chunk.rstrip() + ("\n" * missing) return chunk if isinstance(result, list): chunks = [] for item in result: md = getattr(item, "markdown_result", "") if md: chunks.append(_close_unbalanced_tables(str(md).strip())) return "\n\n---page-separator---\n\n".join([c for c in chunks if c]).strip() md = getattr(result, "markdown_result", "") return _close_unbalanced_tables(str(md).strip()) if md else "" def _get_cell_text(cell) -> str: """Extract clean text from a BeautifulSoup table cell.""" # Replace
tags with space before getting text for br in cell.find_all("br"): br.replace_with(" ") text = cell.get_text(separator=" ", strip=True) # Collapse multiple spaces text = re.sub(r"\s+", " ", text).strip() return text def _html_table_to_markdown(table_html: str) -> str: """ Convert a single HTML table to a clean markdown table. - Removes empty columns that only contain whitespace - Handles colspan/rowspan by ignoring them (takes first value) - Works for both regular tables and bordered tables """ try: from bs4 import BeautifulSoup soup = BeautifulSoup(table_html, "html.parser") table = soup.find("table") if not table: return table_html except ImportError: # Fallback: regex-based if bs4 not available return _html_table_to_markdown_regex(table_html) # Collect all rows from thead + tbody + direct tr all_rows = [] for tr in table.find_all("tr"): cells = [] for cell in tr.find_all(["td", "th"]): cells.append(_get_cell_text(cell)) if cells: all_rows.append(cells) if not all_rows: return table_html # Normalize all rows to same width max_cols = max(len(r) for r in all_rows) all_rows = [r + [""] * (max_cols - len(r)) for r in all_rows] # Remove columns that are empty in ALL rows non_empty_cols = [] for col_idx in range(max_cols): if any(all_rows[row_idx][col_idx] for row_idx in range(len(all_rows))): non_empty_cols.append(col_idx) all_rows = [[row[i] for i in non_empty_cols] for row in all_rows] if not all_rows: return table_html num_cols = len(all_rows[0]) md_lines = [] # First row as header md_lines.append("| " + " | ".join(all_rows[0]) + " |") md_lines.append("| " + " | ".join(["---"] * num_cols) + " |") # Remaining rows for row in all_rows[1:]: # Pad or trim to match header width padded = (row + [""] * num_cols)[:num_cols] md_lines.append("| " + " | ".join(padded) + " |") return "\n".join(md_lines) def _html_table_to_markdown_regex(table_html: str) -> str: """Regex-based fallback for HTML table conversion when bs4 is unavailable.""" row_re = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) cell_re = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) tag_re = re.compile(r"<[^>]+>") def clean_cell(c: str) -> str: c = re.sub(r"", " ", c, flags=re.IGNORECASE) c = tag_re.sub("", c) c = c.replace(" ", " ").replace("&", "&").replace(">", ">").replace("<", "<") return re.sub(r"\s+", " ", c).strip() rows = [] for tr in row_re.findall(table_html): cells = [clean_cell(c) for c in cell_re.findall(tr)] if cells: rows.append(cells) if not rows: return table_html max_cols = max(len(r) for r in rows) rows = [r + [""] * (max_cols - len(r)) for r in rows] # Remove empty columns non_empty_cols = [i for i in range(max_cols) if any(rows[j][i] for j in range(len(rows)))] rows = [[r[i] for i in non_empty_cols] for r in rows] if not rows: return table_html num_cols = len(rows[0]) md = ["| " + " | ".join(rows[0]) + " |", "| " + " | ".join(["---"] * num_cols) + " |"] for row in rows[1:]: padded = (row + [""] * num_cols)[:num_cols] md.append("| " + " | ".join(padded) + " |") return "\n".join(md) def _convert_all_html_tables(md: str) -> str: """Find and replace all HTML tables in the markdown with clean markdown tables.""" table_re = re.compile(r"", re.IGNORECASE) def replace_table(match: re.Match) -> str: return _html_table_to_markdown(match.group(0)) return table_re.sub(replace_table, md) def _clean_markdown(md: str) -> str: if not md: return md # Convert all HTML tables to clean markdown tables first md = _convert_all_html_tables(md) # Drop frequently repeated bank footer/header lines drop_line_patterns = [ r"Call 1-800-937-2000 for 24-hour Bank-by-Phone services.*", r"Bank Deposits FDIC Insured \| TD Bank, N\.A\. \| Equal Housing Lender.*", r"Go paperless\.", r"Scan the QR code to opt in to paperless statements\.", ] drop_line_regexes = [re.compile(p, re.IGNORECASE) for p in drop_line_patterns] cleaned_lines: List[str] = [] for line in md.splitlines(): s = line.strip() if any(rx.fullmatch(s) for rx in drop_line_regexes): continue cleaned_lines.append(line) md = "\n".join(cleaned_lines) # Remove long legal notice sections legal_start_markers = [ "## FOR CONSUMER ACCOUNTS ONLY", "## FOR CONSUMER LOAN ACCOUNTS ONLY", "## In case of Errors or Questions About Your Bill:", ] lines = md.splitlines() kept: List[str] = [] skipping = False for line in lines: s = line.strip() if any(s.startswith(m) for m in legal_start_markers): skipping = True continue if skipping and s == "---page-separator---": skipping = False kept.append(line) continue if not skipping: kept.append(line) md = "\n".join(kept) # Remove duplicate separators and excessive blank lines md = re.sub(r"(?:\n\s*){3,}", "\n\n", md) md = re.sub( r"(?:\n\s*---page-separator---\s*\n){2,}", "\n\n---page-separator---\n\n", md, ) return md.strip() def run_ocr(file_obj): if file_obj is None: return "Please upload a file." path = file_obj.name if hasattr(file_obj, "name") else str(file_obj) page_images: List[str] = [] try: parser = get_parser() is_pdf = path.lower().endswith(".pdf") if is_pdf: page_images = _render_pdf_pages_to_images(path) result = parser.parse(page_images) else: result = parser.parse(path) md = _clean_markdown(_extract_md(result)) or "(No content)" return md except Exception as e: import traceback log.exception("run_ocr failed: %s", e) return f"Error: {e}\n\n{traceback.format_exc()}" finally: 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 def build_demo(): with gr.Blocks(title="GLM-OCR Self-hosted (Header/Footer enabled)") as demo: gr.Markdown("# GLM-OCR Self-hosted (Header/Footer enabled)") gr.Markdown( "Runs a local vLLM server inside the Space and configures GLM-OCR " "to include `header` and `footer` regions in OCR output." ) 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_md = gr.Textbox(label="Markdown", lines=30) run_btn.click(fn=run_ocr, inputs=file_in, outputs=out_md) return demo if __name__ == "__main__": build_demo().launch()