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 gzippedeng.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.fillTextwith 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.
Top comments (0)