indic-ocr / static /index.html
Sidd2899's picture
Deploy Indic OCR Studio
9e7ba62 verified
Raw History Blame Contribute Delete
24.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Indic OCR Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Devanagari:wght@400;600&family=Noto+Sans+Kannada:wght@400;600&family=Noto+Sans+Malayalam:wght@400;600&family=Noto+Sans+Tamil:wght@400;600&family=Noto+Sans+Telugu:wght@400;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<script src="vendor/marked.min.js"></script>
<script src="vendor/purify.min.js"></script>
<style>
:root{
--bg:#f6f7fb; --card:#fff; --ink:#151a2d; --muted:#626a85; --line:#e3e6ef;
--accent:#3b5bfd; --accent-ink:#fff; --accent-soft:#eef1ff; --warn:#e5393f; --warn-soft:#fdecec;
--ok:#0b8f6b; --shadow:0 1px 2px rgba(20,25,50,.06),0 6px 24px rgba(20,25,50,.06);
--script:"Noto Sans Kannada","Noto Sans Devanagari","Noto Sans Tamil","Noto Sans Telugu","Noto Sans Malayalam";
}
@media (prefers-color-scheme:dark){:root{
--bg:#0f1220; --card:#171b2e; --ink:#e8eaf3; --muted:#9aa1bd; --line:#262b45;
--accent:#7088ff; --accent-ink:#0f1220; --accent-soft:#1f2645; --warn:#ff6b6f; --warn-soft:#3a1d24; --ok:#3ccf9f;
--shadow:none;}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 Inter,var(--script),system-ui,sans-serif}
header{display:flex;align-items:center;gap:12px;padding:14px 24px;border-bottom:1px solid var(--line);background:var(--card)}
.logo{width:30px;height:30px;border-radius:8px;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center;font-weight:700;font-family:"Noto Sans Kannada"}
header h1{font-size:16px;margin:0;font-weight:600}
header .sub{color:var(--muted);font-size:13px}
#status{margin-left:auto;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px}
.dot{width:8px;height:8px;border-radius:50%;background:#bbb}
.dot.ok{background:var(--ok)} .dot.wait{background:#f0a400;animation:pulse 1s infinite}
@keyframes pulse{50%{opacity:.35}}
main{max-width:1400px;margin:0 auto;padding:20px 24px 48px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}
#uploader{display:grid;grid-template-columns:1fr auto;gap:16px;padding:16px;align-items:stretch}
#drop{border:2px dashed var(--line);border-radius:12px;padding:22px;display:flex;align-items:center;gap:16px;cursor:pointer;transition:.15s}
#drop:hover,#drop.over{border-color:var(--accent);background:var(--accent-soft)}
#drop .ic{width:44px;height:44px;border-radius:10px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;flex:none}
#drop b{display:block} #drop span{color:var(--muted);font-size:13px}
.controls{display:flex;flex-direction:column;gap:10px;justify-content:center;min-width:230px}
label.small{font-size:12px;color:var(--muted);font-weight:500}
select,button{font:inherit}
select{padding:9px 10px;border-radius:9px;border:1px solid var(--line);background:var(--card);color:var(--ink)}
.btn{padding:10px 16px;border-radius:9px;border:1px solid transparent;background:var(--accent);color:var(--accent-ink);font-weight:600;cursor:pointer}
.btn:disabled{opacity:.45;cursor:not-allowed}
a.btn{text-decoration:none}
.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line);font-weight:500;padding:7px 12px;font-size:13px}
.btn.ghost:hover{border-color:var(--accent);color:var(--accent)}
#progress{display:none;padding:28px;text-align:center;margin-top:16px}
.spinner{width:34px;height:34px;border:3px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;margin:0 auto 12px}
@keyframes spin{to{transform:rotate(360deg)}}
#error{display:none;margin-top:16px;padding:12px 16px;border-radius:10px;background:var(--warn-soft);color:var(--warn)}
#results{display:none;margin-top:16px}
.stats{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:14px;align-items:center}
.stat{padding:8px 14px;border-radius:10px;background:var(--card);border:1px solid var(--line)}
.stat b{font-size:18px;display:block;line-height:1.1}
.stat span{font-size:12px;color:var(--muted)}
.stat.warn b{color:var(--warn)}
.stats .dl{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:16px;align-items:start}
.viewer{padding:12px;position:sticky;top:12px}
.viewer .bar{display:flex;gap:6px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
.pagebtn{padding:4px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:var(--ink);cursor:pointer;font-size:13px}
.pagebtn.on{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
.toggle{margin-left:auto;font-size:13px;color:var(--muted);display:flex;gap:6px;align-items:center}
.stage{max-height:78vh;overflow:auto;border-radius:8px;background:#0001}
.pagewrap{position:relative}
.pagewrap img{display:block;width:100%;height:auto}
.pagewrap svg{position:absolute;inset:0;width:100%;height:100%}
/* matches the notebook's CELL 4b: fill alpha 50/255, line width and label size scale with the page */
.box{fill-opacity:.2;cursor:pointer;transition:fill-opacity .1s}
.box.hot{fill-opacity:.45}
.box.pii{fill-opacity:1}
.lbl{pointer-events:none}
.lbl rect{fill-opacity:.92}
.lbl text{fill:#fff;font-family:Inter,"DejaVu Sans",sans-serif;font-weight:700;dominant-baseline:hanging}
.panel{display:flex;flex-direction:column;min-height:300px}
.tabs{display:flex;border-bottom:1px solid var(--line);padding:0 8px}
.tab{padding:12px 14px;border:0;background:none;color:var(--muted);cursor:pointer;font-weight:500;border-bottom:2px solid transparent;margin-bottom:-1px}
.tab.on{color:var(--ink);border-bottom-color:var(--accent)}
.tab-body{padding:14px;overflow:auto;max-height:78vh}
.blk{display:grid;grid-template-columns:32px 1fr;gap:10px;padding:10px;border-radius:10px;border:1px solid transparent;cursor:pointer}
.blk:hover,.blk.hot{border-color:var(--line);background:var(--accent-soft)}
.blk .n{font-size:12px;color:var(--muted);text-align:right;padding-top:3px;font-variant-numeric:tabular-nums}
.chip{display:inline-block;font-size:11px;font-weight:600;padding:1px 8px;border-radius:99px;color:#fff;margin-right:6px;vertical-align:1px}
.chip.review{background:var(--warn)} .chip.pii{background:#222}
.blk .t{font-family:var(--script),Inter,sans-serif;font-size:16px;white-space:pre-wrap;word-break:break-word;margin-top:4px}
.md{font-family:var(--script),Inter,sans-serif;font-size:16px}
.md table,.t table{border-collapse:collapse;margin:10px 0;width:100%}
.md td,.md th,.t td,.t th{border:1px solid var(--line);padding:6px 10px;text-align:left}
.md h1,.md h2,.md h3{font-size:18px;margin:14px 0 6px}
pre.json{font:12.5px/1.5 "JetBrains Mono",monospace;white-space:pre-wrap;word-break:break-all;margin:0}
.empty{color:var(--muted);text-align:center;padding:30px}
.legend{display:flex;gap:10px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:8px}
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:4px;vertical-align:-1px}
.gtrow{display:flex;gap:6px;align-items:center;font-size:13px;color:var(--muted);min-width:0}
.gtrow span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.gtrow .x{border:0;background:none;color:var(--muted);cursor:pointer;padding:0 4px;font-size:15px}
.stat.acc{border-color:var(--accent);background:var(--accent-soft)}
.diff{font-family:var(--script),Inter,sans-serif;font-size:16px;line-height:1.9;white-space:pre-wrap;word-break:break-word}
.diff del{background:var(--warn-soft);color:var(--warn);text-decoration:line-through;padding:1px 3px;border-radius:4px}
.diff ins{background:#0b8f6b22;color:var(--ok);text-decoration:none;padding:1px 3px;border-radius:4px}
.diff-key{font-size:12px;color:var(--muted);margin-bottom:10px}
#history{display:none;margin-top:16px;padding:14px;overflow-x:auto}
#history h2{font-size:15px;margin:0 0 10px;display:flex;align-items:center;gap:10px}
#history table{border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums}
#history th,#history td{border-bottom:1px solid var(--line);padding:6px 10px;text-align:right;white-space:nowrap}
#history th:first-child,#history td:first-child{text-align:left}
#history tfoot td{font-weight:600}
@media (max-width:900px){
#uploader{grid-template-columns:1fr} .grid{grid-template-columns:1fr} .viewer{position:static}
main{padding:16px} header{padding:12px 16px} header .sub{display:none}
}
</style>
</head>
<body>
<header>
<div class="logo">ಅ</div>
<div><h1>Indic OCR Studio</h1><div class="sub">Handwritten Hindi · Kannada · Tamil · Telugu · Malayalam</div></div>
<div id="status"><span class="dot wait" id="dot"></span><span id="statusText">Connecting…</span></div>
</header>
<main>
<section class="card" id="uploader">
<div id="drop" tabindex="0" role="button" aria-label="Choose a file">
<div class="ic"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 16V4m0 0l-4 4m4-4l4 4"/><path d="M4 16v3a1 1 0 001 1h14a1 1 0 001-1v-3"/></svg></div>
<div><b id="fileName">Drop a scan or PDF here, or click to choose</b>
<span id="fileHint">PNG, JPG, WEBP, TIFF, BMP or PDF</span></div>
<input type="file" id="file" hidden multiple accept=".pdf,.png,.jpg,.jpeg,.webp,.tif,.tiff,.bmp,.txt">
</div>
<div class="controls">
<label class="small" for="lang">Document language</label>
<select id="lang"><option value="">Auto-detect</option></select>
<label class="small">Ground truth (optional, .txt)</label>
<div class="gtrow">
<button class="btn ghost" id="gtPick" type="button">Add ground truth</button>
<span id="gtName"></span><button class="x" id="gtClear" type="button" title="Remove" hidden>×</button>
</div>
<input type="file" id="gtFile" hidden accept=".txt,text/plain">
<button class="btn" id="run" disabled>Extract text</button>
</div>
</section>
<section class="card" id="progress">
<div class="spinner"></div>
<div id="progText">Running OCR…</div>
<div class="small" style="color:var(--muted);font-size:13px" id="progSub"></div>
</section>
<div id="error"></div>
<section id="results">
<div class="stats" id="stats"></div>
<div class="grid">
<div class="card viewer">
<div class="bar" id="pages"></div>
<div class="stage" id="stage"></div>
<div class="legend" id="legend"></div>
</div>
<div class="card panel">
<div class="tabs">
<button class="tab on" data-tab="blocks">Blocks</button>
<button class="tab" data-tab="text">Text</button>
<button class="tab" data-tab="json">JSON</button>
<button class="tab" data-tab="diff" id="diffTab" hidden>vs ground truth</button>
<span style="margin-left:auto;align-self:center"><button class="btn ghost" id="copy">Copy text</button></span>
</div>
<div class="tab-body" id="tabBody"></div>
</div>
</div>
</section>
<section class="card" id="history">
<h2>Ground-truth results (this session)
<button class="btn ghost" id="csv" style="margin-left:auto">Download CSV</button></h2>
<table><thead><tr><th>File</th><th>Language</th><th>CER</th><th>CERnorm</th><th>WER</th>
<th>Exact</th><th>GT chars</th><th>Seconds</th></tr></thead><tbody></tbody><tfoot></tfoot></table>
</section>
</main>
<script>
const COLORS = {Text:"#2196f3",Title:"#8e24aa",SectionHeader:"#8e24aa",Table:"#00897b",Picture:"#f57c00",
Figure:"#f57c00",Equation:"#6d4c41",PageHeader:"#607d8b",PageFooter:"#607d8b",PageNumber:"#607d8b"};
const REVIEW = "#e53935", DEFAULT = "#546e7a";
const $ = s => document.querySelector(s);
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const colorOf = b => b.redacted ? "#111" : b.needs_review ? REVIEW : (COLORS[b.type] || DEFAULT);
let file = null, gtFile = null, result = null, page = 0, tab = "blocks", showBoxes = true, cfg = {};
const scored = []; // one row per document scored against ground truth
// ---------- health / model warm-up
async function health() {
try {
const h = await (await fetch("api/health")).json();
cfg = h;
if ($("#lang").options.length === 1)
for (const [code, name] of Object.entries(h.languages)) $("#lang").add(new Option(name, code));
$("#fileHint").textContent = `PNG, JPG, WEBP, TIFF, BMP or PDF · up to ${h.max_upload_mb} MB · ${h.max_pages} pages`;
if (!h.scoring) { $("#gtPick").disabled = true; $("#gtName").textContent = "not available on this server"; }
if (h.error) { setStatus("", "Model failed to load"); return; }
setStatus(h.ready ? "ok" : "wait", h.ready ? (h.mock ? "Ready (mock mode)" : "Model ready") : "Loading model…");
if (!h.ready) setTimeout(health, 4000);
} catch { setStatus("wait", "Waking server…"); setTimeout(health, 4000); }
}
function setStatus(cls, text) { $("#dot").className = "dot " + cls; $("#statusText").textContent = text; }
// ---------- file picking
const drop = $("#drop"), input = $("#file");
drop.onclick = () => input.click();
drop.onkeydown = e => (e.key === "Enter" || e.key === " ") && input.click();
input.onchange = () => pick(input.files);
["dragenter","dragover"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add("over"); }));
["dragleave","drop"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove("over"); }));
drop.addEventListener("drop", e => pick(e.dataTransfer.files));
function pick(files) { // a document, and optionally its .txt ground truth in the same drop / selection
const all = [...files], txt = all.find(isTxt), f = all.find(x => !isTxt(x));
if (txt && cfg.scoring !== false) setGt(txt);
if (!f) return;
file = f;
$("#fileName").textContent = f.name;
$("#fileHint").textContent = (f.size / 1048576).toFixed(2) + " MB";
$("#run").disabled = false;
if (!txt && gtFile && stem(gtFile.name) !== stem(f.name)) setGt(null); // don't score against the last doc's text
guessLang(f.name);
}
const isTxt = f => /\.txt$/i.test(f.name);
const stem = n => n.replace(/\.[^.]+$/, "");
function setGt(f) {
gtFile = f;
$("#gtName").textContent = f ? f.name : "";
$("#gtClear").hidden = !f;
$("#gtPick").textContent = f ? "Change" : "Add ground truth";
$("#gtFile").value = "";
}
$("#gtPick").onclick = () => $("#gtFile").click();
$("#gtFile").onchange = e => e.target.files[0] && setGt(e.target.files[0]);
$("#gtClear").onclick = () => setGt(null);
function guessLang(name) { // nice touch for demos: "Tamil.webp" -> ta-IN
const n = name.toLowerCase();
const map = {hindi:"hi-IN",kannada:"kn-IN",malayalam:"ml-IN",tamil:"ta-IN",telugu:"te-IN"};
for (const k in map) if (n.includes(k)) { $("#lang").value = map[k]; return; }
}
// ---------- run
$("#run").onclick = async () => {
if (!file) return;
const fd = new FormData(); fd.append("file", file); fd.append("lang", $("#lang").value);
if (gtFile) fd.append("gt", gtFile);
$("#run").disabled = true; $("#error").style.display = "none"; $("#results").style.display = "none";
$("#progress").style.display = "block";
const t0 = Date.now();
const tick = setInterval(() => {
const s = Math.round((Date.now() - t0) / 1000);
$("#progText").textContent = `Running OCR… ${s}s`;
$("#progSub").textContent = s > 20 ? "First request after idle can take a minute while the GPU warms up." : "";
}, 500);
try {
const r = await fetch("api/ocr", {method: "POST", body: fd});
const j = await r.json().catch(() => ({detail: r.statusText}));
if (!r.ok) throw new Error(j.detail || r.statusText);
result = j; page = 0; render();
} catch (e) {
$("#error").textContent = "Error: " + e.message; $("#error").style.display = "block";
} finally {
clearInterval(tick); $("#progress").style.display = "none"; $("#run").disabled = false;
}
};
// ---------- render
function render() {
const s = result.stats, base = `api/jobs/${result.job_id}/`, ev = result.eval;
const pct = x => (100 * x).toFixed(1) + "%";
$("#stats").innerHTML = (ev ? stat(pct(ev.cer), "CER", "acc") + stat(pct(ev.cer_norm), "CER (lenient)", "acc")
+ stat(pct(ev.wer), "WER", "acc") + stat(pct(1 - ev.cer), "char accuracy", "acc") : "") + `
${stat(s.pages, "pages")}${stat(s.blocks, "blocks")}${stat(s.chars.toLocaleString(), "characters")}
${stat(s.seconds + "s", "processing")}${stat(result.lang_name, "language")}
${s.needs_review ? stat(s.needs_review, "need review", "warn") : ""}
${s.redacted ? stat(s.redacted, "IDs masked") : ""}
<div class="dl">
<a class="btn ghost" href="${base}${result.artifacts.json}" download>JSON</a>
<a class="btn ghost" href="${base}${result.artifacts.md}" download>Markdown</a>
<a class="btn ghost" href="${base}${result.artifacts.pdf}" download>Highlighted PDF</a>
</div>`;
const types = [...new Set(result.pages.flatMap(p => p.blocks.map(b => b.type)))];
$("#legend").innerHTML = types.map(t => `<span><i style="background:${COLORS[t] || DEFAULT}"></i>${esc(t)}</span>`).join("")
+ (s.needs_review ? `<span><i style="background:${REVIEW}"></i>Needs review</span>` : "")
+ (s.redacted ? `<span><i style="background:#111"></i>Masked ID</span>` : "");
$("#diffTab").hidden = !ev;
if (ev) {
scored.push({file: result.file, lang: result.lang_name, cer: ev.cer, cer_norm: ev.cer_norm, wer: ev.wer,
exact: ev.word_acc, gt_chars: ev.gt_chars, seconds: s.seconds});
renderHistory();
}
$("#results").style.display = "block";
if (ev) tab = "diff"; else if (tab === "diff") tab = "blocks";
document.querySelectorAll(".tab").forEach(b => b.classList.toggle("on", b.dataset.tab === tab));
renderPage();
}
function renderHistory() {
const f = x => (100 * x).toFixed(1) + "%", mean = k => scored.reduce((a, r) => a + r[k], 0) / scored.length;
$("#history tbody").innerHTML = scored.map(r => `<tr><td>${esc(r.file)}</td><td>${esc(r.lang)}</td>
<td>${f(r.cer)}</td><td>${f(r.cer_norm)}</td><td>${f(r.wer)}</td><td>${r.exact ? "yes" : "no"}</td>
<td>${r.gt_chars}</td><td>${r.seconds}</td></tr>`).join("");
$("#history tfoot").innerHTML = scored.length > 1 ? `<tr><td>Mean of ${scored.length}</td><td></td>
<td>${f(mean("cer"))}</td><td>${f(mean("cer_norm"))}</td><td>${f(mean("wer"))}</td><td></td><td></td>
<td>${mean("seconds").toFixed(1)}</td></tr>` : "";
$("#history").style.display = "block";
}
$("#csv").onclick = () => {
const cols = ["file", "lang", "cer", "cer_norm", "wer", "exact", "gt_chars", "seconds"];
const q = v => /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : v;
const csv = [cols.join(","), ...scored.map(r => cols.map(c => q(r[c])).join(","))].join("\n");
const a = Object.assign(document.createElement("a"), {download: "ocr_ground_truth_results.csv",
href: URL.createObjectURL(new Blob(["" + csv], {type: "text/csv"}))});
a.click(); URL.revokeObjectURL(a.href);
};
const stat = (v, l, cls = "") => `<div class="stat ${cls}"><b>${esc(v)}</b><span>${l}</span></div>`;
function renderPage() {
const p = result.pages[page];
$("#pages").innerHTML = (result.pages.length > 1
? result.pages.map((_, i) => `<button class="pagebtn ${i === page ? "on" : ""}" data-p="${i}">Page ${i + 1}</button>`).join("")
: `<span style="font-size:13px;color:var(--muted)">${esc(result.file)}</span>`)
+ `<label class="toggle"><input type="checkbox" id="boxes" ${showBoxes ? "checked" : ""}> Show boxes</label>`;
document.querySelectorAll(".pagebtn").forEach(b => b.onclick = () => { page = +b.dataset.p; renderPage(); });
$("#boxes").onchange = e => { showBoxes = e.target.checked; $("#stage svg").style.display = showBoxes ? "" : "none"; sizeLabels(); };
const side = Math.max(p.width, p.height), lw = Math.max(2, Math.round(side / 700)),
fs = Math.max(14, Math.round(side / 80));
// one box per written line (tables / pictures / masked IDs keep the block box)
const boxesOf = b => b.lines?.length && !b.redacted ? b.lines : [{bbox_xyxy: b.bbox_xyxy}];
const rects = p.blocks.map((b, i) => boxesOf(b).map(l => {
const [x0, y0, x1, y1] = l.bbox_xyxy, c = colorOf(b);
return `<rect class="box${b.redacted ? " pii" : ""}" data-i="${i}" x="${x0}" y="${y0}" width="${x1 - x0}" height="${y1 - y0}"
fill="${c}" stroke="${c}" stroke-width="${lw}"><title>${b.order} ${esc(b.type)}${l.text ? "\n" + esc(l.text) : ""}</title></rect>`;
}).join("")).join("");
// label tag above each block's first box ("3 Text", "5 Table REVIEW"), like the highlighted PDF
const labels = p.blocks.map(b => {
const [x0, y0] = boxesOf(b)[0].bbox_xyxy, h = fs + 6, ly = y0 - h >= 0 ? y0 - h : y0;
return `<g class="lbl"><rect x="${x0}" y="${ly}" height="${h}" fill="${colorOf(b)}"/>
<text x="${x0 + 4}" y="${ly + 3}" font-size="${fs}">${esc(`${b.order} ${b.type}${b.needs_review ? " REVIEW" : ""}`)}</text></g>`;
}).join("");
$("#stage").innerHTML = `<div class="pagewrap"><img src="${p.image}" alt="Page ${page + 1}">
<svg viewBox="0 0 ${p.width} ${p.height}" preserveAspectRatio="none" style="${showBoxes ? "" : "display:none"}">${rects}${labels}</svg></div>`;
sizeLabels();
$("#stage").querySelectorAll(".box").forEach(r => {
r.onmouseenter = () => hot(+r.dataset.i, "box");
r.onmouseleave = () => hot(-1);
r.onclick = () => { if (tab !== "blocks") setTab("blocks"); hot(+r.dataset.i, "box"); };
});
renderTab();
}
function sizeLabels() { // text width is only measurable once the svg is visible
if (!showBoxes) return;
$("#stage").querySelectorAll(".lbl").forEach(g =>
g.querySelector("rect").setAttribute("width", g.querySelector("text").getComputedTextLength() + 8));
}
function renderTab() {
const p = result.pages[page], body = $("#tabBody");
if (tab === "blocks") {
body.innerHTML = p.blocks.length ? p.blocks.map((b, i) => `
<div class="blk" data-i="${i}">
<div class="n">${b.order}</div>
<div><span class="chip" style="background:${b.redacted ? "#111" : COLORS[b.type] || DEFAULT}">${esc(b.type)}</span>
${b.needs_review ? '<span class="chip review">Needs review</span>' : ""}
${b.redacted ? '<span class="chip pii">ID masked</span>' : ""}
<div class="t">${b.type === "Table" ? md(b.text) : esc(b.text) || '<i style="color:var(--muted)">(empty)</i>'}</div></div>
</div>`).join("") : `<div class="empty">No text blocks found on this page.</div>`;
body.querySelectorAll(".blk").forEach(el => {
el.onmouseenter = () => hot(+el.dataset.i, "list");
el.onmouseleave = () => hot(-1);
});
} else if (tab === "diff") {
const ev = result.eval;
body.innerHTML = `<div class="diff-key">Whole document, all pages · ${ev.gt_words} words in ground truth ·
<del>missing / wrong (ground truth)</del> <ins>what OCR wrote instead</ins></div>
<div class="diff">${ev.diff.map(([op, t]) => op === "eq" ? esc(t)
: `<${op}>${esc(t)}</${op}>`).join(" ")}</div>`;
} else if (tab === "text") {
body.innerHTML = `<div class="md">${md(p.markdown) || '<div class="empty">No text.</div>'}</div>`;
} else {
const {image, ...rest} = p;
body.innerHTML = `<pre class="json">${esc(JSON.stringify(rest, null, 2))}</pre>`;
}
}
const md = s => DOMPurify.sanitize(marked.parse(s || ""));
function hot(i, from) {
document.querySelectorAll(".box.hot,.blk.hot").forEach(e => e.classList.remove("hot"));
if (i < 0) return;
const rs = document.querySelectorAll(`.box[data-i="${i}"]`), r = rs[0], l = document.querySelector(`.blk[data-i="${i}"]`);
rs.forEach(e => e.classList.add("hot")); l && l.classList.add("hot");
if (from === "box" && l) l.scrollIntoView({block: "nearest", behavior: "smooth"});
if (from === "list" && r) {
const st = $("#stage"), bb = r.getBoundingClientRect(), sb = st.getBoundingClientRect();
if (bb.top < sb.top || bb.bottom > sb.bottom) st.scrollBy({top: bb.top - sb.top - 40, behavior: "smooth"});
}
}
function setTab(t) {
tab = t;
document.querySelectorAll(".tab").forEach(b => b.classList.toggle("on", b.dataset.tab === t));
renderTab();
}
document.querySelectorAll(".tab").forEach(b => b.onclick = () => setTab(b.dataset.tab));
$("#copy").onclick = async () => {
if (!result) return;
const text = result.pages.map(p => p.blocks.map(b => b.text).join("\n")).join("\n\n");
await navigator.clipboard.writeText(text);
$("#copy").textContent = "Copied"; setTimeout(() => $("#copy").textContent = "Copy text", 1200);
};
health();
</script>
</body>
</html>