DEV Community

Lucian (LKB)
Lucian (LKB)

Posted on Originally published at lkforge.com

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:

  1. Render each page to a canvas with pdf.js.
  2. Recognize the text with tesseract.js (WebAssembly OCR).
  3. 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); },
});
Enter fullscreen mode Exit fullscreen mode

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
  });
}
Enter fullscreen mode Exit fullscreen mode

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:",
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

(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,
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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:

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.

Top comments (1)

Collapse
 
to21as profile image
Tobias

The CSP part is the most useful thing here, particularly the Web Worker inheriting its CSP from its own script response. That one isn't obvious until it bites.

One change I'd make to the OCR layer. opacity: 0 in pdf-lib emits an ExtGState with /ca 0, so the glyphs are painted with a fill alpha of zero. The standard primitive for an invisible OCR layer is text rendering mode 3, neither fill nor stroke: positioned, selectable, never painted. Tesseract's own PDF renderer, Acrobat and ABBYY all use it.

Two reasons it's worth switching. PDF/A-1 forbids an ExtGState /ca or /CA with any value other than 1.0, so a scan OCR'd this way can never validate as PDF/A-1b, and archival scanning is exactly where someone downstream cares. And a zero-alpha glyph is still a painted object, so anything that flattens or re-renders the page can bring it back visible, whereas mode 3 never draws in the first place.

pdf-lib has it in the public API: page.pushOperators(setTextRenderingMode(TextRenderingMode.Invisible)) before your word loop. drawText wraps its own operators in q/Q and doesn't set Tr itself, so the mode carries. Same output, one less standards problem.

The signature framing is right, and rarer than it should be. Most tools bury that distinction.