Download static/index.html from Sidd2899/indic-ocr: direct link, hf CLI and curl.
- Browser
- Download file 24.9 kB
-
https://huggingface.co/spaces/Sidd2899/indic-ocr/resolve/main/static/index.html
- Command line
-
hf download hf://spaces/Sidd2899/indic-ocr/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Sidd2899/indic-ocr/resolve/main/static/index.html
24.9 kB
| <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 => ({"&":"&","<":"<",">":">",'"':"""}[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> | |