DEV Community

Gaurang
Gaurang

Posted on

PDF Toolkit Where Your Files Can't Leave the Browser (and the CSP Enforces It)

Think about the last time you merged two PDFs online. You dragged a bank statement, a lease or a scan of your passport into a website, it "processed" the file, and you downloaded the result. Where did the file go in between? Usually it went to someone else's server, and all you have is their word that they deleted it.

I wanted a tool where that question has a boring answer, so I built Dokwise: 16 PDF and image tools (merge, split, compress, OCR, protect, compare, scan-to-PDF, e-signature and more) that run entirely in your browser. There's no backend and no account, and after the first load it works offline.

"We don't upload your files" is easy to say, though, and plenty of sites that say it are wrong. This post covers how Dokwise backs the claim up.

1. There is no server to upload to

Dokwise is a static site. There's no API and no file-processing server. You can't accidentally send a file to a backend that doesn't exist.

The processing runs on libraries that work in the browser:

Job Library
Create / merge / split / edit PDFs pdf-lib
Render, rasterize, extract text pdfjs-dist
Image resize / compress / convert Canvas + OffscreenCanvas
OCR tesseract.js (WASM)
AES-256 PDF encryption @pdfsmaller/pdf-encrypt on top of native crypto.subtle

2. Heavy work runs in Web Workers and reports progress

Each tool is a plain config object with a process() function. The shell UI (drop zone, options, progress bar, download) is shared, so a new tool is just a config plus a worker. The worker receives ArrayBuffers, does its job, and transfers the result back rather than copying it:

self.onmessage = async (event: MessageEvent<MergeRequest>) => {
  const { type, files } = event.data;
  if (type !== 'merge') return;

  const bytes = await mergePdfs(
    files.map((f) => ({ name: f.name, bytes: new Uint8Array(f.buffer) })),
  );
  const buffer = new ArrayBuffer(bytes.byteLength);
  new Uint8Array(buffer).set(bytes);
  self.postMessage({ type: 'success', buffer }, { transfer: [buffer] });
};
Enter fullscreen mode Exit fullscreen mode

Any operation that can take more than a second has to post { type: 'progress', percent }. That's a hard rule in the project. Compressing a 200-page PDF on a phone shouldn't mean staring at a spinner and wondering whether the tab has frozen.

3. The CSP turns the promise into an enforced rule

This is the part I care most about. The production build injects a strict Content-Security-Policy:

default-src 'self';
script-src 'self' https://www.googletagmanager.com;
connect-src 'self' https://www.googletagmanager.com
            https://*.google-analytics.com https://*.analytics.google.com;
img-src 'self' data: blob:;
worker-src 'self' blob:;
object-src 'none';
frame-src 'none';
Enter fullscreen mode Exit fullscreen mode

Every fetch, XHR or WebSocket to a host that isn't listed gets blocked by the browser. It doesn't matter if the call comes from my code or from a dependency buried in node_modules. If a library quietly tries to phone home, it fails loudly instead of shipping silently.

Being honest about it: the only third-party hosts on that list belong to Google Analytics, which gets anonymous page views. It never receives file contents or filenames. It's disclosed on the site's privacy page, and you can see it in the CSP above.

Don't trust me, check it: open DevTools → Network, run any tool, and watch what goes out. Your file won't be in there.

4. The OCR problem: tesseract.js calls a CDN by default

The CSP caught a real problem here. Out of the box, tesseract.js downloads its worker script, WASM core and language data from a CDN at runtime. That's a third-party network call in the middle of processing your document, which is exactly what I'm trying to avoid.

The fix was to vendor all of it (~30 MB) and point tesseract at same-origin paths:

const worker = await Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
  workerPath: '/tessdata/worker.min.js',
  corePath: '/tessdata/core',
  langPath: '/tessdata',
  gzip: true,
});
Enter fullscreen mode Exit fullscreen mode

So nobody pays 30 MB on every visit, the PWA service worker leaves tessdata/ out of the install-time precache and caches it the first time you actually run OCR. After that, OCR works offline too.

What you can do with it today

  • ✅ Merge, split, organize (rotate / reorder / delete / crop pages)
  • ✅ Compress PDFs and images, convert JPG / PNG / WebP / HEIC
  • ✅ OCR scanned PDFs into searchable ones
  • ✅ Protect with AES-256, remove a password you know, strip metadata
  • ✅ Word ↔ PDF, compare two PDFs as a redline, repair broken PDFs
  • ✅ Scan to PDF with your camera, clean up a signature photo into a transparent PNG
  • ✅ Install it as a PWA and use it on a plane

Try it at dokwise.in. I'd really like feedback from people who poke at it with DevTools open. If you spot a request that shouldn't be there, tell me.


Built with React, Vite, TypeScript, pdf-lib, pdfjs-dist and tesseract.js. The privacy page, with the full CSP and a DevTools walkthrough, is at dokwise.in/privacy.

Top comments (0)