#!/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()