Files
Fu DaiandClaude Opus 4.8 1a585693be 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>
2026-06-17 10:20:02 +04:00

332 lines
15 KiB
HTML

<!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>