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>
332 lines
15 KiB
HTML
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>
|