Suya OCR API — vLLM-backed, OpenAI-compatible OCR service

FastAPI service wrapping the Surya-OCR-2 model (datalab-to) served through vLLM:
legacy /v1/api/ai/* endpoints, an OpenAI-compatible /v1/chat/completions endpoint,
a coalescing request batcher, a local OCR CLI, Docker packaging, multilingual
example outputs, and quantization/concurrency benchmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fu Dai
2026-06-17 10:20:02 +04:00
co-authored by Claude Opus 4.8
commit 1a585693be
147 changed files with 13827 additions and 0 deletions
View File
+311
View File
@@ -0,0 +1,311 @@
"""Build a GGUF artifact from a surya VLM checkpoint, suitable for stock llama.cpp.
Three patches are applied to llama.cpp's convert_hf_to_gguf.py before
running the HF→GGUF conversion. All fixes are baked into the output
artifact, so the resulting GGUF runs on unpatched llama.cpp.
Two checkpoint-side patches are also applied to a working copy:
- config.json: architectures → ["Qwen3_5ForConditionalGeneration"]
- tokenizer_config.json: tokenizer_class → "PreTrainedTokenizerFast",
strip backend / extra_special_tokens / is_local
The original checkpoint is never modified — patches land in a sibling
working dir under --out-dir.
Usage:
python -m surya.scripts.build_gguf \\
--checkpoint datalab-to/surya-2.1.6 \\
--out-dir ./gguf-build
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
LLAMA_CPP_REPO = "https://github.com/ggerganov/llama.cpp.git"
# Pinned commit the converter patches were authored against. Bump when
# the upstream converter drifts; re-validate the anchor strings below.
LLAMA_CPP_REV = "bbeb89d76c41bc250f16e4a6fefcc9b530d6e3f3"
# ---- llama.cpp converter patches -----------------------------------------
# Each patch is (sentinel, anchor, replacement). Idempotent: if `sentinel`
# is already in the file, the patch is skipped.
_PATCH_REGISTER_HASH_ANCHOR = """ if chkhsh == "862f827721df956049dff5ca81a57f29e575280bc622e290d3bf4e35eca29015":
# ref: https://huggingface.co/codefuse-ai/F2LLM-v2-4B
res = "f2llmv2"
"""
_PATCH_REGISTER_HASH_REPLACEMENT = (
_PATCH_REGISTER_HASH_ANCHOR
+ """ if chkhsh == "11865354be60ff9206694aed04242190f1807029bbd750bfbced09b4d26f1ad2":
# surya-2.1.0 char-level WordLevel tokenizer (Split regex=".")
res = "default"
"""
)
_PATCH_VOCAB_ANCHOR = """ def _set_vocab_gpt2(self) -> None:
tokens, toktypes, tokpre = self.get_vocab_base()
self.gguf_writer.add_tokenizer_model("gpt2")
self.gguf_writer.add_tokenizer_pre(tokpre)
self.gguf_writer.add_token_list(tokens)
self.gguf_writer.add_token_types(toktypes)
special_vocab = gguf.SpecialVocab(self.dir_model, load_merges=True)
special_vocab.add_to_gguf(self.gguf_writer)
"""
_PATCH_VOCAB_REPLACEMENT = ''' def _set_vocab_gpt2(self) -> None:
tokens, toktypes, tokpre = self.get_vocab_base()
# surya: char-level / WordLevel vocabs contain raw bytes (e.g. " ",
# "\\n"). llama.cpp's gpt2 vocab decoder applies bytes_to_unicode
# when emitting NORMAL tokens, so encode bytes here for round-trip.
# Idempotent on already-encoded vocabs.
tokens = self._maybe_encode_gpt2_bytes(tokens, toktypes)
self.gguf_writer.add_tokenizer_model("gpt2")
self.gguf_writer.add_tokenizer_pre(tokpre)
self.gguf_writer.add_token_list(tokens)
self.gguf_writer.add_token_types(toktypes)
special_vocab = gguf.SpecialVocab(self.dir_model, load_merges=True)
special_vocab.add_to_gguf(self.gguf_writer)
# surya: char-level / WordLevel vocabs have no merges, but the gpt2
# vocab loader requires the field. Write a single dummy entry.
if not special_vocab.merges:
self.gguf_writer.add_token_merges(["a a"])
@staticmethod
def _maybe_encode_gpt2_bytes(tokens: list[str], toktypes: list[int]) -> list[str]:
"""Apply GPT-2 bytes_to_unicode to NORMAL tokens iff any contain raw
whitespace/control bytes. Idempotent on already-encoded vocabs."""
bs = (list(range(ord("!"), ord("~") + 1))
+ list(range(ord("¡"), ord("¬") + 1))
+ list(range(ord("®"), ord("ÿ") + 1)))
cs = bs[:]
n = 0
for b in range(256):
if b not in bs:
bs.append(b)
cs.append(2 ** 8 + n)
n += 1
byte_to_unicode = {b: chr(c) for b, c in zip(bs, cs)}
printable = set(range(ord("!"), ord("~") + 1))
needs = False
for tok, ttype in zip(tokens, toktypes):
if ttype != gguf.TokenType.NORMAL:
continue
for ch in tok:
cb = ord(ch)
if cb < 0x80 and cb not in printable:
needs = True
break
if needs:
break
if not needs:
return tokens
out: list[str] = []
for tok, ttype in zip(tokens, toktypes):
if ttype == gguf.TokenType.NORMAL:
out.append("".join(byte_to_unicode[b] for b in tok.encode("utf-8")))
else:
out.append(tok)
return out
'''
CONVERTER_PATCHES = [
{
"name": "register surya-2.1.0 pre-tokenizer hash",
"sentinel": '"11865354be60ff9206694aed04242190f1807029bbd750bfbced09b4d26f1ad2"',
"anchor": _PATCH_REGISTER_HASH_ANCHOR,
"replacement": _PATCH_REGISTER_HASH_REPLACEMENT,
},
{
"name": "byte-encode NORMAL tokens + dummy merges in _set_vocab_gpt2",
"sentinel": "_maybe_encode_gpt2_bytes",
"anchor": _PATCH_VOCAB_ANCHOR,
"replacement": _PATCH_VOCAB_REPLACEMENT,
},
]
def patch_converter(convert_py: Path) -> None:
text = convert_py.read_text()
changed = False
for p in CONVERTER_PATCHES:
if p["sentinel"] in text:
print(f" [skip] {p['name']} (already applied)")
continue
if p["anchor"] not in text:
raise RuntimeError(
f"converter patch {p['name']!r} could not find its anchor. "
f"llama.cpp upstream may have drifted; re-pin LLAMA_CPP_REV "
f"and re-validate anchors."
)
text = text.replace(p["anchor"], p["replacement"], 1)
print(f" [apply] {p['name']}")
changed = True
if changed:
convert_py.write_text(text)
# ---- checkpoint-side patches ----------------------------------------------
def patch_checkpoint(src: Path, dst: Path) -> None:
"""Symlink-clone src into dst, with config.json + tokenizer_config.json
rewritten for stock transformers/llama.cpp compatibility."""
if dst.exists():
shutil.rmtree(dst)
dst.mkdir(parents=True)
overrides = {"config.json", "tokenizer_config.json"}
for entry in src.iterdir():
if entry.name in overrides:
continue
os.symlink(entry.resolve(), dst / entry.name)
cfg = json.loads((src / "config.json").read_text())
cfg["architectures"] = ["Qwen3_5ForConditionalGeneration"]
(dst / "config.json").write_text(json.dumps(cfg, indent=2))
tk = json.loads((src / "tokenizer_config.json").read_text())
tk["tokenizer_class"] = "PreTrainedTokenizerFast"
for k in ("backend", "extra_special_tokens", "is_local"):
tk.pop(k, None)
(dst / "tokenizer_config.json").write_text(json.dumps(tk, indent=2))
# ---- llama.cpp resolution -------------------------------------------------
def ensure_llama_cpp(repo_dir: Path, rev: str) -> Path:
if not repo_dir.exists():
repo_dir.parent.mkdir(parents=True, exist_ok=True)
print(f"[clone] {LLAMA_CPP_REPO}{repo_dir}")
subprocess.check_call(["git", "clone", LLAMA_CPP_REPO, str(repo_dir)])
head = subprocess.check_output(
["git", "-C", str(repo_dir), "rev-parse", "HEAD"], text=True
).strip()
if head != rev:
# Discard any prior patches so the checkout is clean.
subprocess.check_call(
["git", "-C", str(repo_dir), "reset", "--hard", "--quiet", "HEAD"]
)
subprocess.check_call(
["git", "-C", str(repo_dir), "fetch", "--quiet", "origin"]
)
print(f"[checkout] llama.cpp @ {rev}")
subprocess.check_call(["git", "-C", str(repo_dir), "checkout", "--quiet", rev])
return repo_dir
# ---- checkpoint resolution ------------------------------------------------
def resolve_checkpoint(checkpoint: str) -> Path:
p = Path(checkpoint)
if p.exists():
return p.resolve()
from huggingface_hub import snapshot_download
print(f"[download] {checkpoint}")
return Path(snapshot_download(checkpoint))
# ---- main -----------------------------------------------------------------
def main() -> int:
from surya.settings import settings
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument(
"--checkpoint",
default=settings.SURYA_MODEL_CHECKPOINT,
help="HF repo id or local checkpoint dir",
)
ap.add_argument("--out-dir", type=Path, default=Path("./gguf-build"))
ap.add_argument(
"--name",
default="surya-2",
help="Output basename. Produces <name>.gguf and <name>-mmproj.gguf",
)
ap.add_argument(
"--llama-cpp-dir",
type=Path,
default=Path.home() / ".cache" / "datalab" / "llama.cpp",
)
ap.add_argument("--llama-cpp-rev", default=LLAMA_CPP_REV)
ap.add_argument(
"--outtype",
default="f16",
help="convert_hf_to_gguf --outtype (f16, bf16, q8_0, ...)",
)
ap.add_argument(
"--keep-work",
action="store_true",
help="Keep the patched-checkpoint working dir on success",
)
args = ap.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
work = args.out_dir / "_patched_ckpt"
src = resolve_checkpoint(args.checkpoint)
print(f"[checkpoint] {src}")
print("[patch] checkpoint config + tokenizer_config")
patch_checkpoint(src, work)
repo = ensure_llama_cpp(args.llama_cpp_dir, args.llama_cpp_rev)
convert_py = repo / "convert_hf_to_gguf.py"
print("[patch] llama.cpp convert_hf_to_gguf.py")
patch_converter(convert_py)
out_llm = (args.out_dir / f"{args.name}.gguf").resolve()
out_mmproj = (args.out_dir / f"{args.name}-mmproj.gguf").resolve()
print(f"[convert] LLM → {out_llm}")
subprocess.check_call(
[
sys.executable,
str(convert_py),
str(work),
"--outfile",
str(out_llm),
"--outtype",
args.outtype,
]
)
print(f"[convert] mmproj → {out_mmproj}")
subprocess.check_call(
[
sys.executable,
str(convert_py),
str(work),
"--mmproj",
"--outfile",
str(out_mmproj),
"--outtype",
args.outtype,
]
)
if not args.keep_work:
shutil.rmtree(work)
print()
print(f" LLM: {out_llm}")
print(f" mmproj: {out_mmproj}")
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
from typing import List
import click
import os
from surya.input.load import load_from_folder, load_from_file
from surya.settings import settings
class CLILoader:
def __init__(self, filepath: str, cli_options: dict, highres: bool = False):
self.page_range = cli_options.get("page_range")
if self.page_range:
self.page_range = self.parse_range_str(self.page_range)
self.filepath = filepath
self.config = cli_options
self.save_images = cli_options.get("images", False)
self.debug = cli_options.get("debug", False)
self.output_dir = cli_options.get("output_dir")
# Opt in to leaving the inference server up so later commands reuse it.
if cli_options.get("keep_server"):
settings.SURYA_INFERENCE_KEEP_ALIVE = True
self.load(highres)
@staticmethod
def common_options(fn):
fn = click.argument("input_path", type=click.Path(exists=True), required=True)(
fn
)
fn = click.option(
"--output_dir",
type=click.Path(exists=False),
required=False,
default=os.path.join(settings.RESULT_DIR, "surya"),
help="Directory to save output.",
)(fn)
fn = click.option(
"--page_range",
type=str,
default=None,
help="Page range to convert, specify comma separated page numbers or ranges. Example: 0,5-10,20",
)(fn)
fn = click.option(
"--images",
is_flag=True,
help="Save images of detected bboxes.",
default=False,
)(fn)
fn = click.option(
"--debug", "-d", is_flag=True, help="Enable debug mode.", default=False
)(fn)
fn = click.option(
"--keep_server",
is_flag=True,
default=False,
help="Keep the inference server (vllm/llama.cpp) running after this command exits so later commands reuse it instead of re-spawning.",
)(fn)
return fn
def load(self, highres: bool = False):
highres_images = None
if os.path.isdir(self.filepath):
images, names = load_from_folder(self.filepath, self.page_range)
folder_name = os.path.basename(self.filepath)
if highres:
highres_images, _ = load_from_folder(
self.filepath, self.page_range, settings.IMAGE_DPI_HIGHRES
)
else:
images, names = load_from_file(self.filepath, self.page_range)
folder_name = os.path.basename(self.filepath).split(".")[0]
if highres:
highres_images, _ = load_from_file(
self.filepath, self.page_range, settings.IMAGE_DPI_HIGHRES
)
self.images = images
self.highres_images = highres_images
self.names = names
self.result_path = os.path.abspath(os.path.join(self.output_dir, folder_name))
os.makedirs(self.result_path, exist_ok=True)
@staticmethod
def parse_range_str(range_str: str) -> List[int]:
range_lst = range_str.split(",")
page_lst = []
for i in range_lst:
if "-" in i:
start, end = i.split("-")
page_lst += list(range(int(start), int(end) + 1))
else:
page_lst.append(int(i))
page_lst = sorted(
list(set(page_lst))
) # Deduplicate page numbers and sort in order
return page_lst
+62
View File
@@ -0,0 +1,62 @@
import time
import click
import copy
import json
from collections import defaultdict
from surya.inference import SuryaInferenceManager
from surya.layout import LayoutPredictor
from surya.debug.draw import draw_polys_on_image
from surya.logging import configure_logging, get_logger
from surya.scripts.config import CLILoader
import os
configure_logging()
logger = get_logger()
@click.command(help="Detect layout of an input file or folder (PDFs or image).")
@CLILoader.common_options
def detect_layout_cli(input_path: str, **kwargs):
loader = CLILoader(input_path, kwargs)
manager = SuryaInferenceManager()
layout_predictor = LayoutPredictor(manager)
start = time.time()
layout_predictions = layout_predictor(loader.images)
if loader.debug:
logger.debug(f"Layout took {time.time() - start} seconds")
if loader.save_images:
for idx, (image, layout_pred, name) in enumerate(
zip(loader.images, layout_predictions, loader.names)
):
polygons = [p.polygon for p in layout_pred.bboxes]
labels = [f"{p.label}-{p.position}" for p in layout_pred.bboxes]
bbox_image = draw_polys_on_image(
polygons, copy.deepcopy(image), labels=labels
)
bbox_image.save(
os.path.join(loader.result_path, f"{name}_{idx}_layout.png")
)
predictions_by_page = defaultdict(list)
for idx, (pred, name, image) in enumerate(
zip(layout_predictions, loader.names, loader.images)
):
out_pred = pred.model_dump()
out_pred["page"] = len(predictions_by_page[name]) + 1
predictions_by_page[name].append(out_pred)
with open(
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
) as f:
json.dump(predictions_by_page, f, ensure_ascii=False)
logger.info(f"Wrote results to {loader.result_path}")
if __name__ == "__main__":
detect_layout_cli()
+59
View File
@@ -0,0 +1,59 @@
import click
import copy
import json
import time
from collections import defaultdict
from surya.detection import DetectionPredictor
from surya.debug.draw import draw_polys_on_image
from surya.logging import configure_logging, get_logger
from surya.scripts.config import CLILoader
import os
configure_logging()
logger = get_logger()
@click.command(help="Detect bboxes in an input file or folder (PDFs or image).")
@CLILoader.common_options
def detect_text_cli(input_path: str, **kwargs):
loader = CLILoader(input_path, kwargs)
det_predictor = DetectionPredictor()
start = time.time()
predictions = det_predictor(loader.images, include_maps=loader.debug)
end = time.time()
if loader.debug:
logger.debug(f"Detection took {end - start} seconds")
if loader.save_images:
for idx, (image, pred, name) in enumerate(
zip(loader.images, predictions, loader.names)
):
polygons = [p.polygon for p in pred.bboxes]
bbox_image = draw_polys_on_image(polygons, copy.deepcopy(image))
bbox_image.save(os.path.join(loader.result_path, f"{name}_{idx}_bbox.png"))
if loader.debug:
heatmap = pred.heatmap
heatmap.save(os.path.join(loader.result_path, f"{name}_{idx}_heat.png"))
predictions_by_page = defaultdict(list)
for idx, (pred, name, image) in enumerate(
zip(predictions, loader.names, loader.images)
):
out_pred = pred.model_dump(exclude=["heatmap", "affinity_map"])
out_pred["page"] = len(predictions_by_page[name]) + 1
predictions_by_page[name].append(out_pred)
with open(
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
) as f:
json.dump(predictions_by_page, f, ensure_ascii=False)
logger.info(f"Wrote results to {loader.result_path}")
if __name__ == "__main__":
detect_text_cli()
+48
View File
@@ -0,0 +1,48 @@
import os
import click
import json
import time
from collections import defaultdict
from surya.inference import SuryaInferenceManager
from surya.logging import configure_logging, get_logger
from surya.recognition import RecognitionPredictor
from surya.scripts.config import CLILoader
configure_logging()
logger = get_logger()
@click.command(help="OCR text — full-page OCR (one VLM call per page).")
@CLILoader.common_options
def ocr_text_cli(input_path: str, **kwargs):
# Full-page OCR is the default path: one VLM call per page returns layout
# + content together. Pages whose full-page output fails to parse fall
# back to layout + per-block OCR automatically (see RecognitionPredictor).
loader = CLILoader(input_path, kwargs, highres=True)
manager = SuryaInferenceManager()
rec_predictor = RecognitionPredictor(manager)
start = time.time()
page_results = rec_predictor(loader.highres_images, full_page=True)
if loader.debug:
logger.debug(f"OCR took {time.time() - start:.2f} seconds")
out_preds = defaultdict(list)
for name, page in zip(loader.names, page_results):
out_pred = page.model_dump()
out_pred["page"] = len(out_preds[name]) + 1
out_preds[name].append(out_pred)
with open(
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
) as f:
json.dump(out_preds, f, ensure_ascii=False)
logger.info(f"Wrote results to {loader.result_path}")
if __name__ == "__main__":
ocr_text_cli()
+9
View File
@@ -0,0 +1,9 @@
import subprocess
import os
def streamlit_app_cli():
cur_dir = os.path.dirname(os.path.abspath(__file__))
ocr_app_path = os.path.join(cur_dir, "streamlit_app.py")
cmd = ["streamlit", "run", ocr_app_path, "--server.fileWatcherType", "none", "--server.headless", "true"]
subprocess.run(cmd, env={**os.environ, "IN_STREAMLIT": "true"})
+226
View File
@@ -0,0 +1,226 @@
"""Screenshot-friendly Surya viewer.
Shows a PDF/image page on the left and full-page OCR output on the right, side
by side, for clean screenshots. You can scroll through pages and preview them
before running OCR, then export the side-by-side view as a PNG.
Run with `surya_screenshot`, then open http://localhost:8504.
"""
from __future__ import annotations
import base64
import io
import os
import tempfile
import uuid
from typing import List, Optional
import pypdfium2
from flask import Flask, jsonify, render_template, request
from PIL import Image
from werkzeug.utils import secure_filename
from surya.inference import SuryaInferenceManager
from surya.logging import configure_logging, get_logger
from surya.recognition import RecognitionPredictor
from surya.recognition.schema import PageOCRResult
from surya.settings import settings
configure_logging()
logger = get_logger()
app = Flask(__name__)
ALLOWED_EXT = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "surya_screenshot")
os.makedirs(UPLOAD_DIR, exist_ok=True)
_rec: Optional[RecognitionPredictor] = None
def get_rec() -> RecognitionPredictor:
"""Lazily build the recognition predictor (shared inference manager)."""
global _rec
if _rec is None:
_rec = RecognitionPredictor(SuryaInferenceManager())
return _rec
# Datalab-flavored palette for layout block overlays, keyed by canonical label.
LABEL_COLORS = {
"Text": "#2563eb",
"SectionHeader": "#0ea5e9",
"PageHeader": "#7c3aed",
"PageFooter": "#7c3aed",
"Caption": "#c026d3",
"Footnote": "#64748b",
"Equation": "#9333ea",
"Table": "#f59e0b",
"TableOfContents": "#f59e0b",
"Form": "#ea580c",
"ListGroup": "#10b981",
"Picture": "#db2777",
"Figure": "#db2777",
"Diagram": "#db2777",
"Code": "#0d9488",
"default": "#ef4444",
}
def _logo_data_url() -> str:
path = os.path.join(settings.BASE_DIR, "static", "datalab-logo.png")
try:
with open(path, "rb") as f:
return "data:image/png;base64," + base64.b64encode(f.read()).decode()
except Exception:
return ""
def _pil_to_data_url(img: Image.Image, fmt: str = "PNG") -> str:
buf = io.BytesIO()
img.save(buf, format=fmt)
return (
f"data:image/{fmt.lower()};base64," + base64.b64encode(buf.getvalue()).decode()
)
def _is_pdf(path: str) -> bool:
return path.lower().endswith(".pdf")
def _page_count(path: str) -> int:
if _is_pdf(path):
doc = pypdfium2.PdfDocument(path)
n = len(doc)
doc.close()
return n
return 1
def _render_page(path: str, page: int, dpi: int) -> Image.Image:
"""Render a 0-indexed page of a PDF (or load an image file) as RGB."""
if _is_pdf(path):
doc = pypdfium2.PdfDocument(path)
try:
pil = doc[page].render(scale=dpi / 72).to_pil().convert("RGB")
finally:
doc.close()
return pil
return Image.open(path).convert("RGB")
def _assemble_page_html(page: PageOCRResult) -> str:
"""Whole-page HTML from a PageOCRResult (math stays in <math> tags)."""
parts: List[str] = []
for blk in page.blocks:
if blk.skipped:
continue
x0, y0, x1, y1 = (int(c) for c in blk.bbox)
parts.append(
f'<div data-bbox="{x0} {y0} {x1} {y1}" '
f'data-label="{blk.label}">{blk.html or ""}</div>'
)
return "\n".join(parts)
@app.route("/")
def index():
return render_template("surya_screenshot.html", logo=_logo_data_url())
@app.route("/info", methods=["POST"])
def info():
path = (request.json or {}).get("file_path", "").strip()
if not path:
return jsonify({"error": "file_path is required"}), 400
if not os.path.exists(path):
return jsonify({"error": f"File not found: {path}"}), 400
try:
return jsonify({"page_count": _page_count(path)})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/upload", methods=["POST"])
def upload():
"""Accept a drag/drop (or browsed) file, save to a temp path, return it."""
f = request.files.get("file")
if f is None or not f.filename:
return jsonify({"error": "no file uploaded"}), 400
ext = os.path.splitext(f.filename)[1].lower()
if ext not in ALLOWED_EXT:
return jsonify({"error": f"unsupported file type: {ext or '(none)'}"}), 400
safe = secure_filename(f.filename) or f"upload{ext}"
dest = os.path.join(UPLOAD_DIR, f"{uuid.uuid4().hex}_{safe}")
f.save(dest)
try:
return jsonify(
{"file_path": dest, "page_count": _page_count(dest), "name": f.filename}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/page", methods=["POST"])
def page():
"""Render a single page for preview (no OCR)."""
data = request.json or {}
path = data.get("file_path", "").strip()
page_num = int(data.get("page", 0))
if not path or not os.path.exists(path):
return jsonify({"error": "valid file_path is required"}), 400
try:
img = _render_page(path, page_num, settings.IMAGE_DPI_HIGHRES)
return jsonify(
{
"image_base64": _pil_to_data_url(img),
"width": img.size[0],
"height": img.size[1],
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/process", methods=["POST"])
def process():
"""Run full-page OCR on one page; return the page image + OCR HTML + blocks."""
data = request.json or {}
path = data.get("file_path", "").strip()
page_num = int(data.get("page", 0))
if not path or not os.path.exists(path):
return jsonify({"error": "valid file_path is required"}), 400
try:
img = _render_page(path, page_num, settings.IMAGE_DPI_HIGHRES)
page_result = get_rec()([img], full_page=True)[0]
blocks = [
{
"bbox": [int(c) for c in blk.bbox],
"label": blk.label,
"color": LABEL_COLORS.get(blk.label, LABEL_COLORS["default"]),
}
for blk in page_result.blocks
if not blk.skipped
]
return jsonify(
{
"image_base64": _pil_to_data_url(img),
"width": img.size[0],
"height": img.size[1],
"html": _assemble_page_html(page_result),
"blocks": blocks,
"n_blocks": len(page_result.blocks),
}
)
except Exception as e:
logger.exception("Full-page OCR failed")
return jsonify({"error": str(e)}), 500
def main():
app.run(host="0.0.0.0", port=8504)
if __name__ == "__main__":
main()
+490
View File
@@ -0,0 +1,490 @@
"""Surya2 streamlit app — exercise layout, recognition, table_rec via the
inference manager. Detection + OCR-error stay in their own torch paths."""
from __future__ import annotations
import io
import re
import tempfile
import time
from typing import List
import pypdfium2
import streamlit as st
import streamlit.components.v1 as components
from PIL import Image, ImageDraw
from surya.debug.draw import draw_polys_on_image, draw_bboxes_on_image
from surya.detection import TextDetectionResult
from surya.inference import SuryaInferenceManager
from surya.layout import LayoutPredictor
from surya.layout.schema import LayoutResult
from surya.recognition import RecognitionPredictor
from surya.recognition.schema import PageOCRResult
from surya.settings import settings
from surya.table_rec import TableRecPredictor
from surya.table_rec.schema import TableResult
# KaTeX-enabled HTML wrapper. The OCR HTML wraps math in <math>...</math>
# (KaTeX-compatible LaTeX inside), which a browser would otherwise show as
# raw text. We convert those tags to \( \) / \[ \] delimiters and let KaTeX
# auto-render typeset them inside an iframe component.
_KATEX_HEAD = r"""<!doctype html><html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
<style>
/* White "paper" card so the text stays readable in both light and dark
Streamlit themes (the iframe is otherwise transparent and our text is dark). */
html,body{background:#ffffff;}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px;line-height:1.55;color:#111111;margin:0;padding:14px;}
table{border-collapse:collapse;margin:6px 0;} td,th{border:1px solid #bbb;padding:3px 6px;color:#111111;}
[data-label="SectionHeader"],[data-label="PageHeader"]{font-weight:600;}
</style></head><body>
"""
_KATEX_TAIL = r"""
<script>
renderMathInElement(document.body, {
delimiters: [
{left: "\\[", right: "\\]", display: true},
{left: "\\(", right: "\\)", display: false}
],
throwOnError: false
});
</script></body></html>
"""
_MATH_RE = re.compile(r"<math\b([^>]*)>(.*?)</math>", re.DOTALL | re.IGNORECASE)
def _math_to_katex(html_str: str) -> str:
"""Rewrite <math>...</math> tags into KaTeX \\( \\) / \\[ \\] delimiters."""
def repl(m: "re.Match") -> str:
attrs, inner = m.group(1), m.group(2)
if re.search(r"""display\s*=\s*["']block["']""", attrs):
return "\\[" + inner + "\\]"
return "\\(" + inner + "\\)"
return _MATH_RE.sub(repl, html_str or "")
def render_ocr_html(html_str: str, height: int = 400) -> None:
"""Render OCR HTML with math typeset by KaTeX (iframe component)."""
components.html(
_KATEX_HEAD + _math_to_katex(html_str) + _KATEX_TAIL,
height=height,
scrolling=True,
)
def _assemble_page_html(page: PageOCRResult) -> str:
"""Reconstruct a div-block whole-page HTML from a PageOCRResult."""
parts: List[str] = []
for blk in page.blocks:
if blk.skipped:
continue
x0, y0, x1, y1 = (int(c) for c in blk.bbox)
body = blk.html or ""
parts.append(
f'<div data-bbox="{x0} {y0} {x1} {y1}" data-label="{blk.label}">{body}</div>'
)
return "\n".join(parts)
def _show_timing(label: str, elapsed_s: float, extra: str = "") -> None:
"""Render a small caption with wall-clock + optional extra detail."""
detail = f"{extra}" if extra else ""
st.caption(f"{label}: {elapsed_s * 1000:.0f} ms ({elapsed_s:.2f}s){detail}")
@st.cache_resource()
def load_predictors_cached():
manager = SuryaInferenceManager()
layout_predictor = LayoutPredictor(manager)
rec_predictor = RecognitionPredictor(manager)
table_rec_predictor = TableRecPredictor(manager)
# Lazy-import detection / ocr_error to keep startup snappy when the user
# only wants VLM modes
from surya.detection import DetectionPredictor
from surya.ocr_error import OCRErrorPredictor
return {
"manager": manager,
"layout": layout_predictor,
"recognition": rec_predictor,
"table_rec": table_rec_predictor,
"detection": DetectionPredictor(),
"ocr_error": OCRErrorPredictor(),
}
def text_detection(img) -> tuple[Image.Image, TextDetectionResult, float]:
t = time.perf_counter()
text_pred = predictors["detection"]([img])[0]
elapsed = time.perf_counter() - t
text_polygons = [p.polygon for p in text_pred.bboxes]
det_img = draw_polys_on_image(text_polygons, img.copy())
return det_img, text_pred, elapsed
def layout_detection(img) -> tuple[Image.Image, LayoutResult, float]:
t = time.perf_counter()
pred = predictors["layout"]([img])[0]
elapsed = time.perf_counter() - t
polygons = [p.polygon for p in pred.bboxes]
labels = [
f"{p.label}-{p.position}-c{p.count}-{round(p.confidence or 0, 2)}"
for p in pred.bboxes
]
annotated = draw_polys_on_image(
polygons, img.copy(), labels=labels, label_font_size=14
)
return annotated, pred, elapsed
def block_ocr(img) -> tuple[Image.Image, PageOCRResult, LayoutResult, float, float]:
"""Layout → block crops → BLOCK_PROMPT. Returns layout + block-OCR timings."""
t_layout = time.perf_counter()
layout = predictors["layout"]([img])[0]
layout_elapsed = time.perf_counter() - t_layout
t_blocks = time.perf_counter()
page_results = predictors["recognition"]([img], [layout])
blocks_elapsed = time.perf_counter() - t_blocks
page = page_results[0]
annotated = img.copy()
draw = ImageDraw.Draw(annotated)
for blk in page.blocks:
x0, y0, x1, y1 = blk.bbox
color = "red" if blk.error else ("orange" if blk.skipped else "green")
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
draw.text((x0 + 4, y0 + 4), f"{blk.reading_order} {blk.label}", fill=color)
return annotated, page, layout, layout_elapsed, blocks_elapsed
def full_page_ocr(img) -> tuple[Image.Image, PageOCRResult, float]:
"""Single HIGH_ACCURACY_BBOX_PROMPT call on the whole page."""
t = time.perf_counter()
page_results = predictors["recognition"]([img], full_page=True)
elapsed = time.perf_counter() - t
page = page_results[0]
annotated = img.copy()
draw = ImageDraw.Draw(annotated)
for blk in page.blocks:
x0, y0, x1, y1 = blk.bbox
color = "red" if blk.error else ("orange" if blk.skipped else "green")
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
draw.text((x0 + 4, y0 + 4), f"{blk.reading_order} {blk.label}", fill=color)
return annotated, page, elapsed
def table_recognition(
img: Image.Image,
mode: str,
skip_table_detection: bool,
) -> tuple[Image.Image, List[TableResult], float, float]:
"""Returns (annotated_img, table_preds, layout_elapsed, table_rec_elapsed)."""
layout_elapsed = 0.0
if skip_table_detection:
table_imgs = [img]
table_counts = [0]
table_bboxes = [(0, 0, img.size[0], img.size[1])]
else:
t = time.perf_counter()
layout = predictors["layout"]([img])[0]
layout_elapsed = time.perf_counter() - t
tables = [b for b in layout.bboxes if b.label in ("Table", "TableOfContents")]
if not tables:
return img.copy(), [], layout_elapsed, 0.0
table_bboxes = [tuple(int(c) for c in b.bbox) for b in tables]
table_imgs = [img.crop(b) for b in table_bboxes]
table_counts = [b.count for b in tables]
t = time.perf_counter()
if mode == "full":
table_preds = predictors["table_rec"].predict_full(
table_imgs, counts=table_counts
)
else:
table_preds = predictors["table_rec"].predict_simple(table_imgs)
table_rec_elapsed = time.perf_counter() - t
out_img = img.copy()
for pred, table_img, tbbox in zip(table_preds, table_imgs, table_bboxes):
if pred.error or pred.mode != "simple" or not pred.rows:
continue
row_bboxes = [r.bbox for r in pred.rows]
col_bboxes = [c.bbox for c in pred.cols]
row_labels = [r.label for r in pred.rows]
col_labels = [c.label for c in pred.cols]
annot = table_img.copy()
annot = draw_bboxes_on_image(
row_bboxes, annot, labels=row_labels, label_font_size=14, color="blue"
)
annot = draw_bboxes_on_image(
col_bboxes, annot, labels=col_labels, label_font_size=14, color="red"
)
# Paste annotated crop back at the table's position in the page.
out_img.paste(annot, (tbbox[0], tbbox[1]))
return out_img, table_preds, layout_elapsed, table_rec_elapsed
def ocr_errors(pdf_file, page_count, sample_len=512, max_samples=10, max_pages=15):
from pdftext.extraction import plain_text_output
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(pdf_file.getvalue())
f.seek(0)
page_middle = page_count // 2
page_range = range(
max(page_middle - max_pages, 0), min(page_middle + max_pages, page_count)
)
text = plain_text_output(f.name, page_range=page_range)
sample_gap = len(text) // max_samples
if len(text) == 0 or sample_gap == 0:
return "This PDF has no text or very little text", ["no text"]
if sample_gap < sample_len:
sample_gap = sample_len
samples = []
for i in range(0, len(text), sample_gap):
samples.append(text[i : i + sample_len])
results = predictors["ocr_error"](samples)
label = "This PDF has good text."
if results.labels.count("bad") / len(results.labels) > 0.2:
label = "This PDF may have garbled or bad OCR text."
return label, results.labels
def open_pdf(pdf_file):
stream = io.BytesIO(pdf_file.getvalue())
return pypdfium2.PdfDocument(stream)
@st.cache_data()
def get_page_image(pdf_file, page_num, dpi=settings.IMAGE_DPI):
doc = open_pdf(pdf_file)
renderer = doc.render(
pypdfium2.PdfBitmap.to_pil,
page_indices=[page_num - 1],
scale=dpi / 72,
)
png = list(renderer)[0]
png_image = png.convert("RGB")
doc.close()
return png_image
@st.cache_data()
def page_counter(pdf_file):
doc = open_pdf(pdf_file)
doc_len = len(doc)
doc.close()
return doc_len
st.set_page_config(layout="wide")
col1, col2 = st.columns([0.55, 0.45])
predictors = load_predictors_cached()
st.markdown(
"""
# Surya 2 Demo
VLM-backed layout, OCR, and table recognition. The model runs in a local
`llama-server` (or vllm) process, started on first use.
Modes:
- **Layout**: page → list of blocks with label + bbox + token count
- **Block OCR**: layout + per-block HTML
- **Table Rec (simple)**: row + column bboxes only
- **Table Rec (full)**: full HTML for each detected table
"""
)
in_file = st.sidebar.file_uploader(
"PDF file or image:", type=["pdf", "png", "jpg", "jpeg", "gif", "webp"]
)
if in_file is None:
st.stop()
filetype = in_file.type
page_count = None
if "pdf" in filetype:
page_count = page_counter(in_file)
page_number = st.sidebar.number_input(
f"Page number out of {page_count}:", min_value=1, value=1, max_value=page_count
)
# Render at high DPI so the OCR / table-rec demos see fine glyphs.
# Layout + detection internally downsample (or accept the small perf hit
# at demo scale); we always render and display the high-DPI page here.
pil_image = get_page_image(in_file, page_number, settings.IMAGE_DPI_HIGHRES)
else:
pil_image = Image.open(in_file).convert("RGB")
page_number = None
run_full_page_ocr = st.sidebar.button("Run Full-Page OCR")
run_text_det = st.sidebar.button("Run Text Detection")
run_layout = st.sidebar.button("Run Layout Analysis")
run_table_rec = st.sidebar.button("Run Table Rec")
run_block_ocr = st.sidebar.button("Run Block OCR")
run_ocr_errors = st.sidebar.button("Run bad-PDF-text detection")
table_mode = st.sidebar.radio(
"Table mode",
options=["simple", "full"],
index=0,
help="simple: rows+cols only. full: full HTML.",
)
skip_table_detection = st.sidebar.checkbox(
"Skip table detection",
value=False,
help="Treat the entire page/image as a single table.",
)
if pil_image is None:
st.stop()
if run_text_det:
det_img, text_pred, elapsed = text_detection(pil_image)
with col1:
_show_timing("Text detection", elapsed, f"{len(text_pred.bboxes)} polys")
st.image(det_img, caption="Detected Text", use_container_width=True)
st.json(
text_pred.model_dump(exclude=["heatmap", "affinity_map"]), expanded=False
)
if run_layout:
annotated, pred, elapsed = layout_detection(pil_image)
with col1:
_show_timing("Layout", elapsed, f"{len(pred.bboxes)} blocks")
st.image(annotated, caption="Detected Layout", use_container_width=True)
st.json(pred.model_dump(), expanded=False)
if run_block_ocr:
annotated, page, layout, t_layout, t_blocks = block_ocr(pil_image)
with col1:
n_blocks = len(page.blocks)
n_ok = sum(1 for b in page.blocks if not b.skipped and not b.error)
_show_timing("Block OCR — layout", t_layout, f"{n_blocks} blocks")
_show_timing("Block OCR — per-block OCR", t_blocks, f"{n_ok} OCR'd")
_show_timing("Block OCR — total", t_layout + t_blocks)
st.image(
annotated,
caption="Block OCR (green=ok, orange=skipped, red=error)",
use_container_width=True,
)
full_html = _assemble_page_html(page)
with st.expander("Full page HTML (rendered)", expanded=False):
render_ocr_html(full_html, height=600)
with st.expander("Full page HTML (source)", expanded=False):
st.code(full_html, language="html")
for blk in page.blocks:
with st.expander(
f"#{blk.reading_order} {blk.label} (conf {blk.confidence:.2f})"
):
# Diagnostics: show numeric bbox + polygon + a thumbnail with the
# drawn rectangle highlighted, then the actual crop fed to OCR.
xs = [p[0] for p in blk.polygon]
ys = [p[1] for p in blk.polygon]
bbox_drawn = [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))]
cx0 = max(0, int(min(xs)) - 4)
cy0 = max(0, int(min(ys)) - 4)
cx1 = min(pil_image.size[0], int(max(xs)) + 4)
cy1 = min(pil_image.size[1], int(max(ys)) + 4)
st.text(
f"bbox(drawn) = {bbox_drawn}\n"
f"crop(ocr) = {(cx0, cy0, cx1, cy1)} (= bbox ± 4px pad)"
)
# Thumbnail with this block's rectangle highlighted in red.
thumb = pil_image.copy()
ImageDraw.Draw(thumb).rectangle(bbox_drawn, outline="red", width=4)
st.image(thumb, caption="this block's drawn rect (red)", width=300)
# The actual crop fed to OCR
if cx1 > cx0 and cy1 > cy0:
st.image(pil_image.crop((cx0, cy0, cx1, cy1)), caption="OCR crop")
if blk.skipped:
st.info("Block skipped (visual label)")
elif blk.error:
st.error("Block OCR errored")
else:
render_ocr_html(blk.html, height=160)
st.code(blk.html, language="html")
if run_full_page_ocr:
annotated, page, elapsed = full_page_ocr(pil_image)
with col1:
n_blocks = len(page.blocks)
n_ok = sum(1 for b in page.blocks if not b.skipped and not b.error)
_show_timing("Full-Page OCR", elapsed, f"{n_blocks} blocks parsed, {n_ok} OK")
st.image(
annotated,
caption="Full-Page OCR (green=ok, orange=skipped, red=error)",
use_container_width=True,
)
full_html = _assemble_page_html(page)
with st.expander("Full page HTML (rendered)", expanded=False):
render_ocr_html(full_html, height=600)
with st.expander("Full page HTML (source)", expanded=False):
st.code(full_html, language="html")
for blk in page.blocks:
with st.expander(
f"#{blk.reading_order} {blk.label} (conf {blk.confidence:.2f})"
):
if blk.skipped:
st.info("Block skipped (visual label)")
elif blk.error:
st.error("Block OCR errored")
else:
render_ocr_html(blk.html, height=160)
st.code(blk.html, language="html")
if run_table_rec:
table_img, preds, t_layout, t_table = table_recognition(
pil_image, table_mode, skip_table_detection
)
with col1:
if not skip_table_detection:
_show_timing("Table Rec — layout", t_layout, f"{len(preds)} tables found")
_show_timing(f"Table Rec — {table_mode}", t_table)
if not skip_table_detection:
_show_timing("Table Rec — total", t_layout + t_table)
st.image(table_img, caption="Table Recognition", use_container_width=True)
for pred in preds:
if pred.mode == "full" and pred.html:
with st.expander("Table HTML"):
render_ocr_html(pred.html, height=400)
st.code(pred.html, language="html")
else:
st.json(pred.model_dump(), expanded=False)
if run_ocr_errors:
if "pdf" not in filetype:
st.error("This feature only works with PDFs.")
else:
label, results = ocr_errors(in_file, page_count)
with col1:
st.write(label)
st.json(results)
with col2:
st.image(pil_image, caption="Uploaded Image", use_container_width=True)
+139
View File
@@ -0,0 +1,139 @@
import os
import click
import copy
import json
from collections import defaultdict
from surya.common.util import expand_bbox
from surya.debug.draw import draw_bboxes_on_image
from surya.inference import SuryaInferenceManager
from surya.layout import LayoutPredictor
from surya.logging import configure_logging, get_logger
from surya.scripts.config import CLILoader
from surya.table_rec import TableRecPredictor
configure_logging()
logger = get_logger()
@click.command(help="Run table recognition on an input file or folder.")
@CLILoader.common_options
@click.option(
"--skip_table_detection",
is_flag=True,
help="Tables are already cropped, so don't re-detect tables.",
default=False,
)
@click.option(
"--mode",
type=click.Choice(["simple", "full"]),
default="simple",
help="simple: rows+cols only (geometric cells). full: full HTML (BLOCK_PROMPT).",
)
def table_recognition_cli(
input_path: str, skip_table_detection: bool, mode: str, **kwargs
):
# Layout runs on the low-DPI render; table crops come from the high-DPI
# image so the table_rec model sees readable cell content.
loader = CLILoader(input_path, kwargs, highres=True)
manager = SuryaInferenceManager()
layout_predictor = LayoutPredictor(manager)
table_rec_predictor = TableRecPredictor(manager)
pnums = []
prev_name = None
for name in loader.names:
if prev_name is None or prev_name != name:
pnums.append(0)
else:
pnums.append(pnums[-1] + 1)
prev_name = name
table_imgs = []
table_counts = []
table_counts_per_img = []
if skip_table_detection:
for img in loader.highres_images:
table_imgs.append(img)
table_counts.append(1)
table_counts_per_img.append(0)
else:
layout_predictions = layout_predictor(
loader.images,
target_image_sizes=[img.size for img in loader.highres_images],
)
for layout_pred, img in zip(layout_predictions, loader.highres_images):
tables_on_page = [
line
for line in layout_pred.bboxes
if line.label in ("Table", "TableOfContents")
]
table_counts.append(len(tables_on_page))
for line in tables_on_page:
bbox = expand_bbox(line.bbox)
table_imgs.append(img.crop(bbox))
table_counts_per_img.append(line.count)
table_preds = table_rec_predictor(table_imgs, mode=mode)
img_idx = 0
prev_count = 0
table_predictions = defaultdict(list)
for i in range(sum(table_counts)):
while i >= prev_count + table_counts[img_idx]:
prev_count += table_counts[img_idx]
img_idx += 1
pred = table_preds[i]
orig_name = loader.names[img_idx]
pnum = pnums[img_idx]
table_img = table_imgs[i]
out_pred = pred.model_dump()
out_pred["page"] = pnum + 1
table_idx = i - prev_count
out_pred["table_idx"] = table_idx
table_predictions[orig_name].append(out_pred)
if loader.save_images and pred.rows:
rows = [line.bbox for line in pred.rows]
cols = [line.bbox for line in pred.cols]
row_labels = [f"Row {line.row_id}" for line in pred.rows]
col_labels = [f"Col {line.col_id}" for line in pred.cols]
cells = [line.bbox for line in pred.cells]
rc_image = copy.deepcopy(table_img)
rc_image = draw_bboxes_on_image(
rows, rc_image, labels=row_labels, label_font_size=20, color="blue"
)
rc_image = draw_bboxes_on_image(
cols, rc_image, labels=col_labels, label_font_size=20, color="red"
)
rc_image.save(
os.path.join(
loader.result_path,
f"{orig_name}_page{pnum + 1}_table{table_idx}_rc.png",
)
)
cell_image = copy.deepcopy(table_img)
cell_image = draw_bboxes_on_image(cells, cell_image, color="green")
cell_image.save(
os.path.join(
loader.result_path,
f"{orig_name}_page{pnum + 1}_table{table_idx}_cells.png",
)
)
with open(
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
) as f:
json.dump(table_predictions, f, ensure_ascii=False)
logger.info(f"Wrote results to {loader.result_path}")
if __name__ == "__main__":
table_recognition_cli()
@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Surya · Full-Page OCR</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<style>
:root {
--ink: #0f1115;
--bg: #f4f6f9;
--panel: #ffffff;
--border: #e4e7ec;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--muted: #667085;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
display: flex; align-items: center; gap: 16px;
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(16,24,40,.04);
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand img { height: 30px; width: auto; }
.brand .title { font-size: 18px; font-weight: 700; letter-spacing: -.01em; }
.brand .sub { font-size: 13px; color: var(--muted); font-weight: 500;
padding-left: 10px; margin-left: 4px; border-left: 1px solid var(--border); }
.controls { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
input[type=text] {
flex: 1; min-width: 260px; max-width: 560px;
padding: 9px 12px; font-size: 14px;
border: 1px solid var(--border); border-radius: 8px; background: #fff;
}
input[type=text]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
button {
padding: 9px 14px; font-size: 14px; font-weight: 600;
border: none; border-radius: 8px; cursor: pointer;
background: var(--accent); color: #fff; transition: background .15s;
}
button:hover:not(:disabled) { background: var(--accent-hover); }
button.secondary { background: #fff; color: var(--ink); border: 1px solid var(--border); }
button.secondary:hover:not(:disabled) { background: #f2f4f7; }
button:disabled { opacity: .45; cursor: not-allowed; }
.pager { display: flex; align-items: center; gap: 6px; }
.pager .pageind { font-size: 13px; color: var(--muted); min-width: 96px; text-align: center; }
.pager button { padding: 7px 11px; }
.toggle { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); cursor: pointer; user-select: none; }
.status { font-size: 13px; font-weight: 600; min-width: 80px; }
.status.loading { color: #b45309; }
.status.error { color: #d92d20; }
.status.ok { color: #079455; }
.stage {
flex: 1; display: flex; gap: 16px; padding: 16px; overflow: hidden;
}
.panel {
flex: 1; min-width: 0; display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
box-shadow: 0 1px 2px rgba(16,24,40,.05);
}
.panel-head {
padding: 11px 16px; font-size: 13px; font-weight: 700;
letter-spacing: .02em; text-transform: uppercase; color: var(--muted);
border-bottom: 1px solid var(--border); background: #fcfcfd;
}
.panel-body { flex: 1; overflow: auto; }
.img-wrap {
display: flex; align-items: flex-start; justify-content: center;
padding: 16px; background: #f0f2f5; min-height: 100%;
}
#pageCanvas { max-width: 100%; height: auto; border-radius: 6px;
box-shadow: 0 2px 10px rgba(16,24,40,.12); background: #fff; }
.ocr {
padding: 28px 32px; line-height: 1.6; font-size: 16px; color: #1d2433;
}
.ocr [data-label="SectionHeader"], .ocr [data-label="Title"] { font-weight: 700; font-size: 1.15em; margin: .5em 0 .3em; }
.ocr [data-label="PageHeader"], .ocr [data-label="PageFooter"] { color: var(--muted); font-size: .9em; }
.ocr table { border-collapse: collapse; margin: 14px 0; width: 100%; }
.ocr th, .ocr td { border: 1px solid #d0d5dd; padding: 6px 10px; text-align: left; }
.ocr th { background: #f2f4f7; font-weight: 600; }
.ocr img { max-width: 100%; height: auto; }
.placeholder { padding: 48px 32px; color: var(--muted); font-size: 15px; text-align: center; }
#dropOverlay { position: fixed; inset: 0; z-index: 100; display: none;
align-items: center; justify-content: center; background: rgba(15,17,21,.55); }
#dropOverlay.active { display: flex; }
#dropOverlay .drop-card { padding: 40px 64px; border: 3px dashed #fff;
border-radius: 16px; color: #fff; font-size: 22px; font-weight: 700;
background: rgba(37,99,235,.30); }
/* While screenshotting, expand panels to full content height so
html2canvas captures everything, not just the visible scroll area. */
body.capturing { height: auto !important; overflow: visible !important; }
body.capturing .stage { height: auto !important; overflow: visible !important; }
body.capturing .panel-body { height: auto !important; overflow: visible !important; }
</style>
</head>
<body>
<header>
<div class="brand">
{% if logo %}<img src="{{ logo }}" alt="Datalab">{% endif %}
<span class="title">Surya</span>
<span class="sub">Full-Page OCR</span>
</div>
<div class="controls">
<input type="text" id="filePath" placeholder="Drop a file, Browse, or type a server path…">
<input type="file" id="fileInput" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp" style="display:none" onchange="onPick(this)">
<button class="secondary" onclick="document.getElementById('fileInput').click()">Browse</button>
<button class="secondary" id="loadBtn" onclick="loadFile()">Load</button>
<div class="pager">
<button class="secondary" id="prevBtn" onclick="changePage(-1)" disabled></button>
<span class="pageind" id="pageInd"></span>
<button class="secondary" id="nextBtn" onclick="changePage(1)" disabled></button>
</div>
<button id="runBtn" onclick="runOCR()" disabled>Run Full-Page OCR</button>
<label class="toggle"><input type="checkbox" id="showBoxes" checked onchange="drawLeft()"> Layout boxes</label>
<button class="secondary" id="copyBtn" onclick="copyHtml()" disabled>Copy HTML</button>
<button class="secondary" id="shotBtn" onclick="saveScreenshot()" disabled>Save Screenshot</button>
<span class="status" id="status"></span>
</div>
</header>
<div class="stage">
<div class="panel">
<div class="panel-head">PDF Page</div>
<div class="panel-body"><div class="img-wrap"><canvas id="pageCanvas"></canvas></div></div>
</div>
<div class="panel">
<div class="panel-head">Full-Page OCR</div>
<div class="panel-body"><div class="ocr" id="ocr"><div class="placeholder">Load a file, scroll to a page, then run full-page OCR.</div></div></div>
</div>
</div>
<div id="dropOverlay"><div class="drop-card">Drop a PDF or image to load</div></div>
<script>
const S = { path: "", name: "", page: 0, count: 0, img: null, blocks: null, html: null, ocrPage: null };
const $ = (id) => document.getElementById(id);
function setStatus(msg, kind) { const s = $("status"); s.textContent = msg || ""; s.className = "status " + (kind || ""); }
async function post(url, body) {
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await r.json();
if (!r.ok) throw new Error(data.error || "Request failed");
return data;
}
async function loadFile() {
const val = $("filePath").value.trim();
if (!val) { setStatus("Drop a file or enter a path", "error"); return; }
// If the box still shows the name of an uploaded file, just reload it.
if (S.path && val === S.name) { S.page = 0; await showPage(); return; }
setStatus("Loading…", "loading");
try {
const info = await post("/info", { file_path: val });
S.path = val; S.name = val; S.count = info.page_count; S.page = 0;
await showPage();
setStatus("");
} catch (e) { setStatus(e.message, "error"); }
}
function onPick(input) { if (input.files && input.files[0]) uploadFile(input.files[0]); }
async function uploadFile(file) {
setStatus("Uploading…", "loading");
const fd = new FormData(); fd.append("file", file);
try {
const r = await fetch("/upload", { method: "POST", body: fd });
const data = await r.json();
if (!r.ok) throw new Error(data.error || "Upload failed");
S.path = data.file_path; S.name = data.name; S.count = data.page_count; S.page = 0;
$("filePath").value = data.name;
await showPage();
setStatus("");
} catch (e) { setStatus(e.message, "error"); }
}
async function showPage() {
setStatus("Rendering…", "loading");
try {
const data = await post("/page", { file_path: S.path, page: S.page });
// New page → clear any previous OCR output.
S.blocks = null; S.html = null; S.ocrPage = null;
$("ocr").innerHTML = '<div class="placeholder">Run full-page OCR to see the extracted content.</div>';
$("shotBtn").disabled = true;
$("copyBtn").disabled = true;
loadImage(data.image_base64, () => drawLeft());
updatePager();
$("runBtn").disabled = false;
setStatus("");
} catch (e) { setStatus(e.message, "error"); }
}
function loadImage(src, cb) {
const im = new Image();
im.onload = () => { S.img = im; cb && cb(); };
im.src = src;
}
function updatePager() {
$("pageInd").textContent = S.count ? `Page ${S.page + 1} of ${S.count}` : "—";
$("prevBtn").disabled = S.page <= 0;
$("nextBtn").disabled = S.page >= S.count - 1;
}
function changePage(delta) {
const next = S.page + delta;
if (next < 0 || next >= S.count) return;
S.page = next;
showPage();
}
function drawLeft() {
if (!S.img) return;
const c = $("pageCanvas"), ctx = c.getContext("2d");
c.width = S.img.naturalWidth; c.height = S.img.naturalHeight;
ctx.drawImage(S.img, 0, 0);
if (!S.blocks || !$("showBoxes").checked) return;
ctx.lineWidth = 3;
ctx.font = 'bold 15px -apple-system, "Segoe UI", sans-serif';
ctx.textBaseline = "top";
S.blocks.forEach((b) => {
const [x1, y1, x2, y2] = b.bbox;
ctx.strokeStyle = b.color; ctx.fillStyle = b.color + "26";
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
const tw = ctx.measureText(b.label).width, ly = Math.max(y1 - 22, 0);
ctx.fillStyle = b.color; ctx.fillRect(x1, ly, tw + 12, 21);
ctx.fillStyle = "#fff"; ctx.fillText(b.label, x1 + 6, ly + 3);
});
}
async function runOCR() {
if (!S.path) return;
setStatus("Running OCR…", "loading");
$("runBtn").disabled = true;
try {
const data = await post("/process", { file_path: S.path, page: S.page });
S.blocks = data.blocks; S.html = data.html || ""; S.ocrPage = S.page;
loadImage(data.image_base64, () => drawLeft());
const ocr = $("ocr");
ocr.innerHTML = data.html || '<div class="placeholder">No content detected on this page.</div>';
renderMath(ocr);
$("shotBtn").disabled = false;
$("copyBtn").disabled = !S.html;
setStatus(`${data.n_blocks} blocks`, "ok");
} catch (e) { setStatus(e.message, "error"); }
finally { $("runBtn").disabled = false; }
}
function renderMath(root) {
root.querySelectorAll("math").forEach((el) => {
const block = el.getAttribute("display") === "block";
try {
const span = document.createElement(block ? "div" : "span");
span.innerHTML = katex.renderToString(el.textContent, { displayMode: block, throwOnError: false });
el.replaceWith(span);
} catch (e) { /* leave raw on failure */ }
});
}
function copyHtml() {
if (!S.html) return;
const done = () => setStatus("HTML copied", "ok");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(S.html).then(done).catch(() => fallbackCopy(S.html, done));
} else {
fallbackCopy(S.html, done);
}
}
function fallbackCopy(text, done) {
const ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); done(); }
catch (e) { setStatus("Copy failed", "error"); }
document.body.removeChild(ta);
}
async function saveScreenshot() {
setStatus("Capturing…", "loading");
const stage = document.querySelector(".stage");
document.body.classList.add("capturing");
try {
// Let the expanded layout settle before measuring full size.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const w = stage.scrollWidth, h = stage.scrollHeight;
const canvas = await html2canvas(stage, {
backgroundColor: "#f4f6f9", scale: 2, useCORS: true, logging: false,
width: w, height: h, windowWidth: w, windowHeight: h, scrollX: 0, scrollY: 0,
});
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = `surya_ocr_page_${(S.ocrPage ?? S.page) + 1}.png`;
a.click();
setStatus("Saved", "ok");
} catch (e) { setStatus("Screenshot failed: " + e.message, "error"); }
finally { document.body.classList.remove("capturing"); }
}
$("filePath").addEventListener("keypress", (e) => { if (e.key === "Enter") loadFile(); });
// Drag & drop anywhere in the window.
let dragDepth = 0;
window.addEventListener("dragenter", (e) => { e.preventDefault(); dragDepth++; $("dropOverlay").classList.add("active"); });
window.addEventListener("dragover", (e) => { e.preventDefault(); });
window.addEventListener("dragleave", (e) => { e.preventDefault(); if (--dragDepth <= 0) { dragDepth = 0; $("dropOverlay").classList.remove("active"); } });
window.addEventListener("drop", (e) => {
e.preventDefault(); dragDepth = 0; $("dropOverlay").classList.remove("active");
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) uploadFile(f);
});
</script>
</body>
</html>