Dev.to · 6 min read

OCR and sign a PDF entirely in the browser — no upload, no server

OCR and sign a PDF entirely in the browser — no upload, no server

Most "free online PDF" tools quietly upload your document to a server. For a signed contract or a scanned ID, that's the whole problem. So I built two tools that don't: an OCR PDF that turns a scanned PDF into a searchable one, and a Sign PDF that adds your signature — both running 100% in the browser. Nothing is uploaded; close the tab and it's gone. Here's how each one works, plus the part that actually took the effort: getting WebAssembly OCR to run under a strict Content-Security-Policy with no CDN. OCR: scanned PDF → searchable PDF, client-side A scanned PDF is just images. You can't select or search the text. "OCR" means recognizing the characters and — for a searchable PDF — laying an invisible text layer over the page image so the document looks identical but the words are now selectable. The pipeline, entirely in the tab: Render each page to a canvas with pdf.js. Recognize the text with tesseract.js (WebAssembly OCR). Rebuild a new PDF with pdf-lib: draw the page image, then stamp each recognized word as invisible text at its bounding box. The recognizer is a vendored worker — no CDN, English fast model: const worker = await Tesseract.createWorker('eng', 1, { workerPath: VENDOR + '/worker.min.js', corePath: VENDOR + '/tesseract-core-simd-lstm.wasm.js', langPath: VENDOR, // holds eng.traineddata.gz workerBlobURL: false, logger: m => { if (m.status === 'recognizing text') setProgress(m.progress); }, }); The interesting bit is the invisible text layer. tesseract gives you words with bounding boxes in canvas pixels; pdf-lib draws text in PDF points with the origin bottom-left. So for each word you map the box, size the font to the box height, and draw it with opacity: 0: const page = out.addPage([Wpt, Hpt]); page.drawImage(jpg, { x: 0, y: 0, width: Wpt, height: Hpt }); // the scan const k = Wpt / canvas.width; // px → pt for (const w of words) { const size = Math.max(4, (w.y1 - w.y0) * k * 0.9); page.drawText(sanitize(w.text), { x: w.x0 * k, y: Hpt - w.y1 * k + size * 0.15, // flip Y, sit on the baseline size, font, opacity: 0, // invisible, but selectable }); } The result looks exactly like the scan, but you can select, copy, and Ctrl-F the text — and I also dump the plain text into a box you can copy or download as .txt. Honest limits (all stated on the page too): it uses the English model, alignment is word-level (great for search, not a character-perfect copy), and accuracy tracks scan quality — clean, straight, high-contrast pages read best. A quick sanity check: feed it an image of OCR Verification Test 2026 and the output PDF's text layer comes back as exactly that. The hard part: WASM under a strict CSP, no CDN The site runs a locked-down CSP — default-src 'self', connect-src 'self', no unsafe-eval. That breaks the usual tesseract.js setup twice over: No CDN. The worker, the WASM core, and the language data all have to be same-origin. So I vendor them (tesseract.min.js, worker.min.js, tesseract-core-simd-lstm.wasm(.js), and a gzipped eng.traineddata.gz — ~8.8 MB total, served from my own origin). WASM needs wasm-unsafe-eval. But I don't want that on the whole site — just the one route. The CSP is built per-request in a Cloudflare Worker, so I scope a flag to the OCR path (and, crucially, its vendored assets, because the Web Worker inherits its CSP from its own script response): const ocr = url.pathname.startsWith('/tools/pdf/ocr-pdf'); // page + /vendor/* // in buildCsp(): `script-src 'self' 'nonce-${nonce}'${ocr ? " 'wasm-unsafe-eval'" : ''}`, ocr && "worker-src 'self' blob:", Everything else on the site stays wasm-unsafe-eval-free. The gotcha that cost me an hour: tesseract resolves workerPath/corePath against its own script's location, not the page. A relative ./vendor/... fails with a silent, empty-message "unknown error." The fix is an absolute same-origin URL: const VENDOR = window.location.origin + '/tools/pdf/ocr-pdf/vendor'; (If you've self-hosted ffmpeg.wasm before, this will feel familiar — same lesson.) Sign: draw, type, or upload — then flatten it in The signature tool is simpler but the same philosophy. You make a signature three ways: Draw on a canvas with pointer events. Type your name in a script font (ctx.fillText with a cursive stack). Upload an image (a transparent PNG looks best). Whichever you pick, I trim the transparent margins, turn it into a PNG, let you drag/resize it onto a page preview (rendered by pdf.js), then stamp it in with pdf-lib — mapping the on-screen box to page coordinates the same way the OCR layer does: const png = await doc.embedPng(signatureBytes); const pg = doc.getPages()[pageIndex]; pg.drawImage(png, { x: fx * W, y: H - (fy + fh) * H, // top-left screen box → bottom-left PDF width: fw * W, height: fh * H, }); Honest framing (also on the page): this adds a visible signature image — like signing a printout and scanning it. It is not a certificate-based cryptographic e-signature with a verified identity. For most "just sign this and send it back" jobs, that's exactly what you want; for anything that needs legal non-repudiation, use a dedicated e-sign service. Why do it all client-side? Three reasons, in order: Privacy. The file never leaves the tab. There's no upload to log, cache, or breach — a real difference for signed documents and scanned IDs. No infrastructure. No servers, no queues, no per-file cost. The browser does the work; I just ship static assets and a thin Worker for headers. It's free and account-free, because it's cheap to run when the user's device is the compute. The trade-off is honest: OCR is slower than a beefy server (it's your CPU + WASM), and there's an ~8.8 MB one-time download for the model. For a privacy-first tool, I'll take that. Both tools are live and free — no signup, no watermark, nothing uploaded: OCR PDF → https://lkforge.com/tools/pdf/ocr-pdf/ Sign PDF → https://lkforge.com/tools/pdf/sign-pdf/ They're part of a 24-tool browser-only PDF suite (crop, merge, compress, watermark, page numbers, HTML→PDF, and more) — all built on the same "nothing leaves your device" rule. If you've fought tesseract.js under a strict CSP, I'd love to hear how you scoped it.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Cybersecurity News