File size: 12,574 Bytes
5625297
55cb0af
5625297
 
5534d19
14a7432
aef7ee0
14a7432
 
b1c7862
14a7432
 
 
177ec96
14a7432
 
 
2d37358
 
14a7432
2d37358
 
 
 
14a7432
 
 
 
 
aef7ee0
 
 
 
 
 
 
 
 
 
6415913
 
aef7ee0
 
 
 
 
 
 
 
14a7432
 
 
 
 
 
6415913
 
 
 
 
 
14a7432
 
 
 
 
 
 
 
 
 
 
 
55cb0af
14a7432
 
 
 
 
 
 
 
55cb0af
 
14a7432
 
594145e
14a7432
 
 
37508d7
14a7432
 
 
 
 
 
 
 
 
 
2d37358
 
14a7432
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47c1654
 
721c9d9
 
 
14a7432
 
721c9d9
6a1f80a
721c9d9
37508d7
5625297
14a7432
721c9d9
 
5534d19
 
 
 
 
 
 
 
 
721c9d9
14a7432
721c9d9
 
 
5534d19
14a7432
721c9d9
5534d19
 
 
6415913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5534d19
 
 
 
6415913
 
 
 
5534d19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6415913
5534d19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6415913
5534d19
6415913
 
 
 
 
5534d19
194f537
 
14a7432
 
aef7ee0
14a7432
aef7ee0
7fbf37f
721c9d9
aef7ee0
 
 
 
 
 
5534d19
aef7ee0
7fbf37f
 
14a7432
aef7ee0
 
 
 
6415913
 
 
 
 
aef7ee0
 
 
31f684f
5625297
14a7432
 
 
 
 
 
 
5625297
 
 
 
11bf437
14a7432
aef7ee0
11bf437
 
5cc96b8
bcc62dd
6415913
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
#!/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"<table\b", chunk, flags=re.IGNORECASE))
        closes = len(re.findall(r"</table>", chunk, flags=re.IGNORECASE))
        missing = max(0, opens - closes)
        if missing:
            chunk = chunk.rstrip() + ("\n</table>" * 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 <br> 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"<tr[^>]*>(.*?)</tr>", re.IGNORECASE | re.DOTALL)
    cell_re = re.compile(r"<t[dh][^>]*>(.*?)</t[dh]>", re.IGNORECASE | re.DOTALL)
    tag_re = re.compile(r"<[^>]+>")

    def clean_cell(c: str) -> str:
        c = re.sub(r"<br\s*/?>", " ", c, flags=re.IGNORECASE)
        c = tag_re.sub("", c)
        c = c.replace("&nbsp;", " ").replace("&amp;", "&").replace("&gt;", ">").replace("&lt;", "<")
        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"<table[\s\S]*?</table>", 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()