// litert-ocr demo: a photo, screenshot, scan or PDF -> the text with its line boxes, Markdown with tables, and JSON. // Everything runs in this tab. Photos and screenshots go to worker.recognize() (lines and their boxes); PDFs, and the // Markdown and JSON tabs of an image, go to converter.convert(). One file is read at a time. A PDF's page is drawn // with pdf.js (the files the library pins) before it is read, and the blocks convert() finds are framed on it. // The record tools (record/) read window.demoState and window.demoRuns (one entry per file read). import { renderMarkdown } from './md.js'; const LIBRARY = 'https://cdn.jsdelivr.net/npm/litert-ocr@0.2.0/+esm'; /** Pages read per PDF on this page; the library itself reads up to `maxPages` (default 100). */ const MAX_PAGES = 20; /** convert() reads PDF pages at 150 dpi; block boxes are in pixels of that size. */ const PDF_SCALE = 150 / 72; const SVG = 'http://www.w3.org/2000/svg'; /** Block types of convert() with the words of the legend. */ const KINDS = [['heading', 'heading', 'headings'], ['paragraph', 'paragraph', 'paragraphs'], ['list_item', 'list item', 'list items'], ['table', 'table', 'tables']]; const $ = (id) => document.getElementById(id); const narrow = window.matchMedia('(max-width: 960px)'); const demoState = (window.demoState = { phase: 'starting', isolated: self.crossOriginIsolated === true, info: null, converter: 'waiting', pdfPages: 'waiting', notes: [], notKept: [], bytes: { network: 0, cache: 0 }, error: null }); const demoRuns = (window.demoRuns = []); const ui = { tab: 'text', source: false }; let current = null; let queue = Promise.resolve(); let pending = 0; let jobNotes = null; let lib = null; let worker = null; let converter = null; let converterReady = null; let pdfjsLoad = null; /** The pdf.js document of the PDF shown (closed when the next file comes). */ let pdfOpen = null; /** Coordinates of the boxes layer when a PDF page is shown: the page's size in convert()'s pixels. */ let pageSpace = null; boot().catch((e) => { demoState.phase = 'failed'; demoState.error = errorRecord(e); setStatus('failed', 'The page could not start'); $('detail').textContent = e?.message ?? String(e); }); async function boot() { showFramed(); setStatus('busy', 'Starting…'); const isolated = await isolation(); demoState.isolated = isolated; if (!isolated) note('This browser did not let the page turn on WebAssembly threads (cross-origin isolation), so OCR runs on 1 thread: several times slower.'); setStatus('busy', 'Loading the OCR library…'); try { lib = await import(LIBRARY); } catch (e) { throw new Error(`The OCR library could not be loaded from jsDelivr (${e?.message ?? e}). Check the connection and reload the page.`); } setStatus('busy', 'Loading the model…'); const t0 = performance.now(); // all five model files at start (eager): a read never waits for a recognizer to load, and the converter below // finds every file in the cache (each file is downloaded once) worker = await lib.createWorker('eng', 1, { logger: logger('worker'), eager: true, prefetch: false }); demoState.info = { ...worker.info, startMs: Math.round(performance.now() - t0) }; demoState.readyAt = Date.now(); demoState.phase = 'ready'; setStatus('ready', 'Ready'); showInfo(); enableInput(); // the first jobs in the line: a file given meanwhile waits for them instead of sharing the CPU with them converterReady = enqueue(startConverter); enqueue(startPdfPages); } /** * Inside a frame (as on the Space's page on huggingface.co) the page offers its own address, opened on its own: in a * frame Chrome on a Galaxy S26 read more slowly (2026-10-22). */ function showFramed() { if (window.self === window.top) return; const a = Object.assign(document.createElement('a'), { href: location.origin + location.pathname, target: '_blank', rel: 'noopener', textContent: 'Open this page on its own' }); $('framed').replaceChildren(a, ': in a frame like this one, Chrome on a Galaxy S26 read more slowly.'); $('framed').hidden = false; } /** * coi-serviceworker reloads the page once its service worker controls it: until then wait (instead of starting the * model twice). Resolves to whether the page is cross-origin isolated (WebAssembly threads). */ async function isolation() { if (self.crossOriginIsolated) { session('coi-wait', null); return true; } const sw = navigator.serviceWorker; if (!window.isSecureContext || !sw || sw.controller) return false; const tries = Number(session('coi-wait') ?? 0); if (tries >= 2) return false; session('coi-wait', String(tries + 1)); setStatus('busy', 'Turning on WebAssembly threads…'); await new Promise((r) => setTimeout(r, 5000)); return self.crossOriginIsolated === true; } /** The converter keeps its own OCR worker: started here with a blank one-page PDF, so pdf.js and every model file are loaded (and cached) before the first PDF. */ async function startConverter() { demoState.converter = 'starting'; showInfo(); const t0 = performance.now(); converter = lib.createConverter({ logger: logger('converter'), eager: true, prefetch: false, layout: 'geometry' }); try { await converter.convert(blankPdf(), { to: 'json' }); demoState.converterMs = Math.round(performance.now() - t0); demoState.converterReadyAt = Date.now(); demoState.converter = 'ready'; } catch (e) { demoState.converter = 'failed'; demoState.converterError = errorRecord(e); note(`PDFs and the Markdown and JSON tabs are not available: ${e?.message ?? e}`); } showInfo(); } /** pdf.js for drawing PDF pages, loaded once after the converter's start (so the first PDF does not wait for it). */ async function startPdfPages() { demoState.pdfPages = 'loading'; try { await pdfReader(); demoState.pdfPages = 'ready'; } catch (e) { demoState.pdfPages = 'failed'; demoState.pdfPagesError = errorRecord(e); note(`PDF pages cannot be shown here (${e?.message ?? e}); PDFs are still read.`); } } /** The page's own pdf.js: the two files the library pins (modelInfo.pdfjs: the same URLs, sizes and sha256), started * from blob: URLs as the library does, so its worker runs on this page's origin. A failed load is tried again later. */ function pdfReader() { pdfjsLoad ??= (async () => { const { base, files } = lib.modelInfo.pdfjs; const load = async (f) => { const url = new URL(f.file, base).href; const resp = await fetch(url, { credentials: 'omit' }); if (!resp.ok) throw new Error(`${url}: HTTP ${resp.status}`); const bytes = new Uint8Array(await resp.arrayBuffer()); const sha = crypto.subtle ? Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), (b) => b.toString(16).padStart(2, '0')).join('') : f.sha256; if (bytes.byteLength !== f.bytes || sha !== f.sha256) throw new Error(`${url} is not the file the library pins`); return URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' })); }; const [main, work] = await Promise.all([files.find((f) => !f.file.includes('worker')), files.find((f) => f.file.includes('worker'))].map(load)); const mod = await import(main); mod.GlobalWorkerOptions.workerSrc = work; return { mod, params: { cMapUrl: new URL('cmaps/', base).href, cMapPacked: true, standardFontDataUrl: new URL('standard_fonts/', base).href, wasmUrl: new URL('wasm/', base).href, iccUrl: new URL('iccs/', base).href } }; })().catch((e) => { pdfjsLoad = null; throw e; }); return pdfjsLoad; } function logger(source) { const seen = new Map(); return (m) => { if (typeof m.loaded === 'number') { // bytes per step: 'loading runtime' counts each file on its own, 'loading model' the files of a step together // (the start files are one step; a recognizer loaded later is a step of its own), fromCache = the file whose // bytes just arrived const key = `${m.status}|${m.jobId}|${m.status === 'loading runtime' ? m.file : m.total}`; const d = m.loaded - (seen.get(key) ?? 0); if (d > 0) { seen.set(key, m.loaded); demoState.bytes[m.fromCache ? 'cache' : 'network'] += d; } } if (source === 'worker' && demoState.phase === 'starting') { if (m.status === 'loading model') setStatus('busy', `Loading the model… ${Math.round(m.progress * 100)} %`); else if (m.status === 'initializing model') setStatus('busy', 'Preparing the model…'); } if (m.status === 'notice' && m.message) { if (/^Recognizer on /.test(m.message)) return; if (/not cross-origin isolated/.test(m.message)) return; // said above in this page's words // a file the browser would not store: the status line must not say the files are kept const notKept = /^Could not cache (\S+)/.exec(m.message)?.[1]; if (notKept && !demoState.notKept.includes(notKept)) { demoState.notKept.push(notKept); showInfo(); } if (jobNotes) jobNotes.add(m.message); else note(m.message); } }; } function showInfo() { const i = demoState.info; if (!i) return; const where = `Detector on ${i.detector === 'webgpu' ? 'WebGPU' : 'WebAssembly'}, recognizer on ${i.recognizer === 'webgpu' ? 'WebGPU' : i.recognizer === 'mixed' ? 'WebGPU and WebAssembly' : 'WebAssembly'}, ${i.threads} thread${i.threads === 1 ? '' : 's'}`; const mb = (b) => (b / 1e6).toFixed(1); const n = demoState.notKept.length; const kept = n ? `this browser did not keep ${n} of them, so the next visit downloads ${n === 1 ? 'it' : 'them'} again` : 'kept for later visits'; const files = demoState.bytes.network > 0 ? `files: ${mb(demoState.bytes.network)} MB from the network, ${kept}` : 'files: from this browser’s cache'; const pdf = demoState.converter === 'ready' ? '' : demoState.converter === 'failed' ? ' · PDF reader: not available' : ' · getting the PDF reader ready…'; $('detail').textContent = `${where} · ${files}${pdf}`; } function enableInput() { const input = $('file'); input.disabled = false; for (const b of document.querySelectorAll('.example')) b.disabled = false; input.addEventListener('change', () => { const f = input.files?.[0]; input.value = ''; if (f) read(f, 'picker'); }); const drop = $('drop'); let depth = 0; document.addEventListener('dragenter', (e) => { if (hasFiles(e)) { depth++; drop.classList.add('over'); } }); document.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; drop.classList.remove('over'); } }); document.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); }); document.addEventListener('drop', (e) => { if (!hasFiles(e)) return; e.preventDefault(); depth = 0; drop.classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) read(f, 'drop'); }); document.addEventListener('paste', (e) => { const f = [...(e.clipboardData?.files ?? [])][0]; if (f) { e.preventDefault(); read(f, 'paste'); } }); for (const b of document.querySelectorAll('.example')) { b.addEventListener('click', async () => { const name = b.dataset.file; const resp = await fetch(`examples/${name}`); if (!resp.ok) return showError({ name }, new Error(`The example ${name} could not be loaded (HTTP ${resp.status}).`)); const blob = await resp.blob(); read(new File([blob], name, { type: blob.type }), 'example'); }); } for (const t of document.querySelectorAll('.tab')) t.addEventListener('click', () => selectTab(t.dataset.tab)); $('source').addEventListener('change', (e) => { ui.source = e.target.checked; paint(); }); $('copy').addEventListener('click', copyTab); $('download').addEventListener('click', downloadTab); $('prev').addEventListener('click', () => turnPage(-1)); $('next').addEventListener('click', () => turnPage(1)); narrow.addEventListener('change', () => { if (!current?.pdf) return; placeFigure(); fitBoxes(); }); } const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files'); /** Queue a file: one read at a time, so two reads never share the CPU and every time shown is a time on its own. */ function read(file, how) { const job = { id: demoRuns.length + 1, name: file.name || 'file', bytes: file.size, type: file.type, how }; demoRuns.push(job); setStatus('busy', `Reading ${job.name}…`); return enqueue(() => run(file, job)); } function enqueue(fn) { pending++; paintPager(); const p = queue.then(fn); queue = p.catch(() => {}).finally(() => { pending--; paintPager(); }); return p; } async function run(file, job) { job.startedAt = Date.now(); job.kind = await kindOf(file); current = { file, job, kind: job.kind }; ui.tab = job.kind === 'pdf' ? 'md' : 'text'; ui.source = false; $('source').checked = false; showFile(file, job); setStatus('busy', `Reading ${job.name}…`); try { if (job.kind === 'image') await readImage(file, job); else { // the page on screen first, then the file to the library: the two never share the CPU, and the time shown // stays the library's own if (job.kind === 'pdf') await showPdf(current); await readDocument(file, job); } } finally { job.endedAt = Date.now(); job.done = true; $('reading').hidden = true; if (demoState.phase === 'ready') setStatus('ready', 'Ready'); reveal(); } } async function kindOf(file) { const h = new Uint8Array(await file.slice(0, 1024).arrayBuffer()); const s = String.fromCharCode(...h.subarray(0, 1024)); if (file.type === 'application/pdf' || s.includes('%PDF-')) return 'pdf'; if (file.type.startsWith('image/')) return 'image'; const png = h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47; const jpeg = h[0] === 0xff && h[1] === 0xd8 && h[2] === 0xff; const gif = s.startsWith('GIF8'); const webp = s.startsWith('RIFF') && s.slice(8, 12) === 'WEBP'; const bmp = s.startsWith('BM'); return png || jpeg || gif || webp || bmp ? 'image' : 'other'; } async function readImage(file, job) { jobNotes = new Set(); const t0 = performance.now(); let r; try { r = await worker.recognize(file, {}, { text: true, blocks: true }, `demo-${job.id}`); } catch (e) { job.ms = performance.now() - t0; finishNotes(job); return showError(job, e); } job.ms = performance.now() - t0; finishNotes(job); job.api = 'recognize'; job.text = r.data.text; job.lines = r.data.text.split('\n').filter((l) => l.length); const lines = r.data.blocks?.[0]?.paragraphs?.[0]?.lines ?? []; current.lines = lines; current.text = r.data.text; drawBoxes(lines); showTime(job.ms, `${lines.length} line${lines.length === 1 ? '' : 's'} · ${job.name}`); paint(); } async function readDocument(file, job, password) { if (converterReady) await converterReady; if (demoState.converter !== 'ready') return showError(job, demoState.converterError ?? new Error('The PDF reader is not available.')); jobNotes = new Set(); const t0 = performance.now(); const options = { to: 'md', maxPages: MAX_PAGES }; if (password !== undefined) options.password = password; let r; try { try { r = await converter.convert(file, options); } catch (e) { if (e?.code !== 'DOC_TOO_MANY_PAGES') throw e; r = await converter.convert(file, { ...options, pages: `1-${MAX_PAGES}` }); job.partial = true; } } catch (e) { job.ms = performance.now() - t0; finishNotes(job); if (e?.code === 'PDF_PASSWORD') return askPassword(file, job, e, password !== undefined); return showError(job, e); } job.ms = performance.now() - t0; finishNotes(job); job.api = 'convert'; setDoc(current, r, job.ms); job.markdown = current.doc.md; job.lines = current.doc.md.split('\n').filter((l) => l.length); const pages = r.document.pages; const total = r.document.source.pages; job.pages = { read: pages.length, total, status: pages.map((p) => p.status), engine: pages.map((p) => p.engine) }; const how = [...new Set(pages.map((p) => (p.engine === 'layer' ? 'text layer' : p.engine === 'ocr' ? 'OCR' : p.status)))].join(' and '); const what = r.document.source.type === 'pdf' ? `${pages.length} page${pages.length === 1 ? '' : 's'} (${how})` : how; showTime(job.ms, `${what} · ${job.name}`); if (current.pdf) { current.pdf.pages = pages; framePage(current); } if (job.partial) addResultNote(`This PDF has ${total} pages; this page reads the first ${MAX_PAGES}. The library reads up to its maxPages option (100 by default), and any range with pages.`); const bad = pages.filter((p) => p.status === 'failed'); for (const p of bad) addResultNote(`Page ${p.page} could not be read: ${p.reason}`); if (pages.every((p) => p.status !== 'ok')) addResultNote(pages.length === 1 ? 'No text was found in this file.' : 'No text was found on these pages.'); paint(); } function setDoc(cur, r, ms) { cur.doc = { md: r.content, // the same string as convert(file, { to: 'json' }).content json: JSON.stringify(r.document, null, 2), // the same string as convert(file, { to: 'text' }).content: the pages' text, separated by a form feed text: r.document.pages.map((p) => p.text).join('\f'), ms, }; cur.job.json = cur.doc.json; if (cur.kind !== 'image') cur.job.text = cur.doc.text; } /** The Markdown and JSON tabs of an image: convert() reads it once, when one of them is first opened. */ function ensureDoc(cur) { if (cur.kind !== 'image' || cur.doc || cur.docError || cur.docPending) return; cur.docPending = enqueue(async () => { if (converterReady) await converterReady; if (demoState.converter !== 'ready') { cur.docError = demoState.converterError ?? new Error('The Markdown reader is not available.'); return; } jobNotes = new Set(); const t0 = performance.now(); try { const r = await converter.convert(cur.file, { to: 'md' }); setDoc(cur, r, performance.now() - t0); cur.job.convertMs = cur.doc.ms; cur.job.markdown = cur.doc.md; } catch (e) { cur.docError = e; cur.job.convertError = errorRecord(e); } finally { cur.job.convertNotes = [...jobNotes]; jobNotes = null; } }).finally(() => { cur.docPending = null; if (cur === current) paint(); }); paint(); } function askPassword(file, job, e, wrong) { job.error = errorRecord(e); showResult(); setError(wrong ? 'That password did not open this PDF' : 'This PDF needs a password', `Type it to open the file; it is used only in this tab.\n\nlitert-ocr: ${e.message}`, 'info'); const form = $('password-form'); form.hidden = false; const input = $('password'); input.value = ''; input.focus(); form.onsubmit = (ev) => { ev.preventDefault(); const pw = input.value; form.hidden = true; const again = { id: demoRuns.length + 1, name: job.name, bytes: job.bytes, type: job.type, how: 'password', kind: 'pdf' }; demoRuns.push(again); enqueue(async () => { current = { file, job: again, kind: 'pdf' }; showFile(file, again); setStatus('busy', `Reading ${again.name}…`); again.startedAt = Date.now(); try { await showPdf(current, pw); await readDocument(file, again, pw); } finally { again.endedAt = Date.now(); again.done = true; $('reading').hidden = true; setStatus('ready', 'Ready'); reveal(); } }); }; } const TITLES = { IMAGE_DECODE_FAILED: 'This image could not be opened', IMAGE_UNSUPPORTED_TYPE: 'This file is not an image', DOC_UNSUPPORTED_TYPE: 'This kind of file is not read here', PDF_INVALID: 'This PDF could not be read', PDF_UNAVAILABLE: 'The PDF reader could not be loaded', MODEL_FETCH_FAILED: 'A model file could not be downloaded', MODEL_SHA256_MISMATCH: 'A model file did not match', WORKER_START_FAILED: 'The OCR engine could not start', RUNTIME_LOAD_FAILED: 'The OCR engine could not start', MODEL_COMPILE_FAILED: 'The OCR engine could not start', }; function showError(job, e) { job.error = errorRecord(e); showResult(); setError(TITLES[e?.code] ?? 'This file could not be read', e?.code ? `${e.message}\n\n(${e.code})` : (e?.message ?? String(e))); } function setError(title, text, kind = 'bad') { $('error').hidden = false; $('error').className = kind === 'info' ? 'error info' : 'error'; $('error-title').textContent = title; $('error-text').textContent = text; $('output').hidden = true; } function errorRecord(e) { return { code: e?.code ?? null, message: e?.message ?? String(e) }; } function finishNotes(job) { job.notes = [...(jobNotes ?? [])]; jobNotes = null; for (const n of job.notes) addResultNote(n); } // ---- drawing ---- function showFile(file, job) { $('result').hidden = true; // the previous file's result goes too: on a narrow screen a PDF's page shows in the result card while it is read $('output').hidden = true; $('error').hidden = true; $('password-form').hidden = true; $('took').textContent = ''; $('took-detail').textContent = ''; for (const n of document.querySelectorAll('.result-note')) n.remove(); closePdf(); placeFigure(); const fig = $('figure'); const img = $('image'); $('boxes').replaceChildren(); $('page').hidden = true; $('legend').hidden = true; pageSpace = null; paintPager(); if (img.dataset.url) URL.revokeObjectURL(img.dataset.url); if (job.kind === 'image') { const url = URL.createObjectURL(file); img.dataset.url = url; img.onerror = () => { fig.hidden = true; }; img.src = url; img.hidden = false; fig.hidden = false; $('caption').textContent = `${job.name} · ${(file.size / 1e6).toFixed(file.size < 1e5 ? 2 : 1)} MB`; } else { img.removeAttribute('src'); delete img.dataset.url; fig.hidden = true; } } /** * A PDF's first page in the figure before the library reads the file, drawn by the page's own pdf.js; the frames come * with the result. When pdf.js cannot open the file (damaged, or a password not given or not right) no figure is * shown and the read goes on as before. */ async function showPdf(cur, password) { const t0 = performance.now(); let task = null; try { const { mod, params } = await pdfReader(); task = mod.getDocument({ data: new Uint8Array(await cur.file.arrayBuffer()), isEvalSupported: false, verbosity: 0, ...params, ...(password !== undefined ? { password } : {}) }); const doc = await task.promise; cur.pdf = pdfOpen = { task, doc, total: doc.numPages, index: 0, pages: null }; // laid out but not painted until the page is in it (the room it gets decides how sharp the page is drawn) const shell = placeFigure() ? $('result') : $('figure'); shell.style.visibility = 'hidden'; if (shell === $('result')) $('result').hidden = false; $('figure').hidden = false; $('image').hidden = true; $('page').hidden = false; try { await drawPdfPage(cur, 1); } finally { shell.style.visibility = ''; } if (shell === $('result')) { // the whole page on screen while it is read, the time to come beside it $('reading').textContent = `Reading ${cur.job.name}…`; $('reading').hidden = false; $('result').scrollIntoView({ block: 'start' }); } cur.job.pdfPage = { ms: performance.now() - t0, ...cur.pdf.drawn }; } catch (e) { task?.destroy().catch(() => {}); if (pdfOpen?.task === task) pdfOpen = null; cur.pdf = null; cur.job.pdfPage = { ms: performance.now() - t0, error: errorRecord(e) }; $('figure').hidden = true; $('page').hidden = true; placeFigure(); $('result').hidden = true; } } /** Page n (1-based) of the open PDF into the figure, as sharp as this screen shows it, at most convert()'s 150 dpi. */ async function drawPdfPage(cur, n) { const page = await cur.pdf.doc.getPage(n); try { const one = page.getViewport({ scale: 1 }); const canvas = $('page'); const room = $('result').classList.contains('with-page') ? canvas.parentElement.clientWidth : $('figure').parentElement.clientWidth; const css = Math.min(room / one.width, (parseFloat(getComputedStyle(canvas).maxHeight) || Infinity) / one.height); const scale = Math.max(0.25, Math.min(PDF_SCALE, css * (window.devicePixelRatio || 1))); const vp = page.getViewport({ scale }); const off = document.createElement('canvas'); off.width = Math.ceil(vp.width); off.height = Math.ceil(vp.height); const ctx = off.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, off.width, off.height); await page.render({ canvasContext: ctx, canvas: off, viewport: vp }).promise; // into the shown canvas in one step: a page never shows half drawn canvas.width = off.width; canvas.height = off.height; canvas.getContext('2d').drawImage(off, 0, 0); watchSize(); cur.pdf.drawn = { page: n, scale, width: off.width, height: off.height }; $('caption').textContent = `Page ${n} of ${cur.pdf.total}`; } finally { page.cleanup(); } } function closePdf() { pdfOpen?.task.destroy().catch(() => {}); pdfOpen = null; } /** Frames of the blocks convert() found on the page shown, colored by block type, and the legend under the page. */ function framePage(cur) { const p = cur.pdf.pages[cur.pdf.index]; const g = document.createElementNS(SVG, 'g'); // an OCR page is read after it is turned back by its skew: the frames turn with the page as drawn if (p.skew) g.setAttribute('transform', `rotate(${p.skew} ${p.width / 2} ${p.height / 2})`); const count = {}; for (const b of p.blocks) { const [x0, y0, x1, y1] = b.type === 'table' && p.tables[b.table] ? p.tables[b.table].bbox : b.bbox; g.append(rect(x0, y0, x1, y1, `block ${b.type}`)); count[b.type] = (count[b.type] ?? 0) + 1; } $('boxes').replaceChildren(g); pageSpace = { width: p.width, height: p.height }; fitBoxes(); const keys = KINDS.filter(([t]) => count[t]).map(([t, one, many]) => Object.assign(document.createElement('span'), { className: `key ${t}`, textContent: `${count[t]} ${count[t] === 1 ? one : many}` })); $('legend').replaceChildren(...keys); $('legend').hidden = !keys.length; paintPager(); } /** Previous and next page, for a PDF with more than one page read; not while a job runs (no page is drawn during a read). */ function paintPager() { const pdf = current?.pdf; const n = pdf?.pages?.length ?? 0; $('pager').hidden = n < 2; if (n < 2) return; $('prev').disabled = pending > 0 || pdf.index === 0; $('next').disabled = pending > 0 || pdf.index >= n - 1; } function turnPage(step) { const cur = current; const i = (cur?.pdf?.index ?? 0) + step; if (!cur?.pdf?.pages || i < 0 || i >= cur.pdf.pages.length) return; const n = cur.pdf.pages[i].page; enqueue(async () => { if (cur !== current || !cur.pdf) return; await drawPdfPage(cur, n); cur.pdf.index = i; framePage(cur); }).catch((e) => { if (cur === current) addResultNote(`Page ${n} could not be drawn: ${e?.message ?? e}`); }); } /** * On a narrow screen a PDF's page sits in the result, beside the time, so the page, the time and the top of the * result fit one phone screen; otherwise the figure stays under the input. Returns whether the page is beside the time. */ function placeFigure() { const fig = $('figure'); const side = narrow.matches && !!current?.pdf; $('result').classList.toggle('with-page', side); if (side && fig.parentElement !== $('result')) $('result').prepend(fig); if (!side && fig.parentElement === $('result')) document.querySelector('.examples').after(fig); return side; } function showResult() { $('result').hidden = false; } /** On a narrow screen, bring the image (or the result) to the top once the job has drawn everything. */ function reveal() { if (!narrow.matches || $('result').hidden) return; const top = $('figure').hidden || $('result').classList.contains('with-page') ? $('result') : $('figure'); requestAnimationFrame(() => top.scrollIntoView({ block: 'start' })); } function showTime(ms, detail) { showResult(); $('error').hidden = true; $('output').hidden = false; const t = $('took'); const v = ms < 1000 ? `${Math.round(ms)}` : (ms / 1000).toFixed(ms < 10000 ? 2 : 1); // the number and its unit stay together (a narrow column puts them under "Read in") const value = Object.assign(document.createElement('span'), { className: 'value' }); value.append(`${v} `, Object.assign(document.createElement('span'), { className: 'unit', textContent: ms < 1000 ? 'ms' : 's' })); t.replaceChildren('Read in ', value); $('took-detail').textContent = detail; } function addResultNote(text) { const p = document.createElement('p'); p.className = 'tab-note result-note'; p.textContent = text; $('output').before(p); } /** * The boxes layer takes the file's own pixels as its coordinates (an image's natural size; a PDF page's size in * convert()'s pixels) and the shown size as its size. */ function fitBoxes() { const onPage = !$('page').hidden; const shown = onPage ? $('page') : $('image'); const space = onPage ? pageSpace : { width: shown.naturalWidth, height: shown.naturalHeight }; const svg = $('boxes'); if (!space?.width) return; svg.setAttribute('viewBox', `0 0 ${space.width} ${space.height}`); svg.setAttribute('preserveAspectRatio', 'none'); svg.style.width = `${shown.clientWidth}px`; svg.style.height = `${shown.clientHeight}px`; } let resizeWatch = null; function watchSize() { if (resizeWatch) return; resizeWatch = new ResizeObserver(fitBoxes); resizeWatch.observe($('image')); resizeWatch.observe($('page')); } function rect(x0, y0, x1, y1, cls) { const r = document.createElementNS(SVG, 'rect'); r.setAttribute('x', x0); r.setAttribute('y', y0); r.setAttribute('width', Math.max(1, x1 - x0)); r.setAttribute('height', Math.max(1, y1 - y0)); r.setAttribute('rx', 2); if (cls) r.setAttribute('class', cls); return r; } function drawBoxes(lines) { const img = $('image'); const svg = $('boxes'); svg.replaceChildren(...lines.map((l, i) => { const b = l.bbox; const r = rect(b.x0, b.y0, b.x1, b.y1); r.dataset.i = i; return r; })); if (img.complete) fitBoxes(); img.onload = fitBoxes; watchSize(); } function selectTab(tab) { ui.tab = tab; if (tab !== 'text' && current) ensureDoc(current); paint(); } /** Fill the selected tab from the current result. */ function paint() { if (!current) return; for (const t of document.querySelectorAll('.tab')) t.setAttribute('aria-selected', String(t.dataset.tab === ui.tab)); const cur = current; const panes = { text: $('pane-text'), md: $('pane-md'), mdSource: $('pane-md-source'), json: $('pane-json') }; for (const p of Object.values(panes)) p.hidden = true; $('source-toggle').hidden = ui.tab !== 'md'; const note = $('tab-note'); note.hidden = true; const content = tabContent(cur); if (content === null) { note.hidden = false; note.textContent = cur.docError ? `Markdown and JSON could not be made: ${cur.docError.message}` : 'Reading the layout for Markdown and JSON…'; return; } if (cur.kind === 'image' && ui.tab !== 'text' && cur.doc) { note.hidden = false; note.textContent = `Markdown and JSON from convert(): ${formatMs(cur.doc.ms)}.`; } if (ui.tab === 'text') { panes.text.hidden = false; if (cur.kind === 'image') { const lines = cur.lines ?? []; if (!lines.length) panes.text.replaceChildren(Object.assign(document.createElement('p'), { className: 'empty', textContent: 'No text was found in this image.' })); else panes.text.replaceChildren(...lines.map((l, i) => lineEl(l.text.replace(/\n$/, ''), i))); } else { const pages = content.split('\f'); const out = []; pages.forEach((t, k) => { if (pages.length > 1) out.push(Object.assign(document.createElement('p'), { className: 'page-mark', textContent: `Page ${k + 1}` })); for (const line of t.split('\n')) out.push(Object.assign(document.createElement('div'), { className: 'line', textContent: line || ' ' })); }); panes.text.replaceChildren(...out); } } else if (ui.tab === 'md') { if (ui.source) { panes.mdSource.hidden = false; panes.mdSource.textContent = content; } else { panes.md.hidden = false; renderMarkdown(content, panes.md); } } else { panes.json.hidden = false; panes.json.textContent = content; } } /** The text of the selected tab, or null while it is being made. */ function tabContent(cur) { if (ui.tab === 'text') return cur.kind === 'image' ? (cur.text ?? '') : (cur.doc?.text ?? ''); if (!cur.doc) return null; return ui.tab === 'md' ? cur.doc.md : cur.doc.json; } function lineEl(text, i) { const d = document.createElement('div'); d.className = 'line'; d.textContent = text; const rect = () => $('boxes').querySelector(`rect[data-i="${i}"]`); d.addEventListener('mouseenter', () => rect()?.classList.add('hot')); d.addEventListener('mouseleave', () => rect()?.classList.remove('hot')); return d; } async function copyTab() { const text = current && tabContent(current); if (text === null || text === undefined) return; try { await navigator.clipboard.writeText(text); flash($('copy'), 'Copied'); } catch { flash($('copy'), 'Not allowed'); } } function downloadTab() { const text = current && tabContent(current); if (text === null || text === undefined) return; const ext = ui.tab === 'json' ? 'json' : ui.tab === 'md' ? 'md' : 'txt'; const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type: ui.tab === 'json' ? 'application/json' : 'text/plain' })); a.download = `${current.job.name.replace(/\.[^.]+$/, '') || 'result'}.${ext}`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } function flash(button, text) { const was = button.textContent; button.textContent = text; setTimeout(() => { button.textContent = was; }, 1200); } // ---- small helpers ---- function setStatus(kind, text) { $('status').className = `status ${kind}`; $('status-text').textContent = text; demoState.status = text; } function note(text) { if (demoState.notes.includes(text)) return; demoState.notes.push(text); const li = document.createElement('li'); li.textContent = text; $('notes').append(li); } const formatMs = (ms) => (ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`); function session(key, value) { try { if (value === undefined) return sessionStorage.getItem(key); if (value === null) sessionStorage.removeItem(key); else sessionStorage.setItem(key, value); } catch { return null; } return null; } /** A one-page PDF with nothing on it (1 x 1 inch): reading it loads pdf.js and the OCR models. */ function blankPdf() { const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Resources << >> /Contents 4 0 R >>', '<< /Length 0 >>\nstream\n\nendstream', ]; let s = '%PDF-1.4\n'; const offsets = objects.map((o, i) => { const at = s.length; s += `${i + 1} 0 obj\n${o}\nendobj\n`; return at; }); const xref = s.length; s += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')}`; s += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; return new TextEncoder().encode(s); }