DEV Community

Cover image for I built a PDF & image toolbox that never uploads your files — 100% client-side
Mehdi Laalali2000
Mehdi Laalali2000

Posted on

I built a PDF & image toolbox that never uploads your files — 100% client-side

Every "free online tool" works the same way: you upload your file to a server, hope for the best, and get a result back. I got tired of that model — both as a user (why does a PDF compressor need my documents?) and as a developer (why pay for servers that just shuffle files?).

So I built ToolHex — a set of tools (image compressor/converter, PDF merge/split/compress, JPG→PDF, text tools, calculators) where the browser does everything. Your files never leave your device. There's no backend at all.

Here's how the interesting parts work.

The browser is a pretty good runtime

Most people don't realize how capable the web platform got:

// Canvas API: decode any image, re-encode at any quality
const bitmap = await createImageBitmap(file);
canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
canvas.toBlob(blob => saveIt(blob), 'image/jpeg', 0.8);
Enter fullscreen mode Exit fullscreen mode

That's the core of the image compressor. No library needed for the happy path — the browser IS the image library.

mozjpeg in WASM for real JPEG quality

The canvas encoder is fine, but mozjpeg produces ~20% smaller JPEGs at the same quality. It's a C library — running it in the browser means WebAssembly. I vendored the @jsquash/jpeg codec and call it lazily:

const { default: encode } = await import('/assets/vendor/jsquash-jpeg/encode.js');
const buf = await encode(imageData, { quality: 80 });
Enter fullscreen mode Exit fullscreen mode

Self-hosted, ~290 KB of wasm, cached after first use.

PNG is lossless — until you quantize the palette

This one surprised me while building. "PNG compression" tools like TinyPNG don't re-encode losslessly — they do lossy palette quantization (pngquant-style): reduce a photo to ≤256 colors with dithering, then write an indexed-color PNG. The file stays a real PNG, transparency intact, 60–85% smaller.

I did it with UPNG.js + a Floyd–Steinberg dithering pass against the computed palette (with a 5-bit/channel lookup table so nearest-color search stays fast):

const q = UPNG.quantize([rgba.buffer], colors, false);
// extract palette -> dither each pixel against it ->
// UPNG.encode([dithered], w, h, colors) => indexed PNG
Enter fullscreen mode Exit fullscreen mode

Banding without dithering is ugly; with error diffusion a 256-color photo looks nearly identical to the original. Fun rabbit hole.

Ghostscript. In the browser. In WASM.

The PDF compressor is the one that felt impossible: real PDF compression needs a real PDF engine. There's a WASM build of Ghostscript (@okathira/ghostpdl-wasm, ~15 MB — lazy-loaded, cached) with an in-memory filesystem and a callMain API. Which means I can literally run:

Module.FS.writeFile('in.pdf', bytes);
Module.callMain([
  '-sDEVICE=pdfwrite', '-dPDFSETTINGS=/ebook',
  '-dNOPAUSE', '-dQUIET', '-dBATCH',
  '-sOutputFile=out.pdf', 'in.pdf'
]);
const compressed = Module.FS.readFile('out.pdf', { encoding: 'binary' });
Enter fullscreen mode Exit fullscreen mode

The same Ghostscript command that powers server-side tools — except the "server" is the user's own CPU. In benchmarks on my machine, a 19.6 MB scan-heavy PDF compresses to 0.15 MB in ~1.7 seconds.

(One heads-up if you copy this: Ghostscript is AGPL v3. Conveying the binary means shipping the license + source link, which I do on a licenses page. Not legal advice, but read the license before embedding it anywhere commercial.)

PDF surgery with pdf-lib

Merge, split, rotate, delete pages, JPG→PDF — all pdf-lib. Its mental model is simple: load documents into memory, copy page objects between them, save. Lossless by construction since pages are never re-rendered:

const out = await PDFLib.PDFDocument.create();
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach(p => out.addPage(p));
Enter fullscreen mode Exit fullscreen mode

The infra is one nginx file

Every tool lives on its own subdomain (compress.toolhex.online, pdf.toolhex.online…), which keeps each site focused and SEO-clean. One regex server block routes all of them:

server_name ~^(?<sub>[a-z0-9-]+)\.toolhex\.online$;
root /var/www/toolhex/$sub;
Enter fullscreen mode Exit fullscreen mode

No if blocks, unknown subdomains 404 naturally because their docroot doesn't exist. Wildcard cert from Let's Encrypt via DNS-01 (HTTP-01 can't do wildcards). Deploys are rsync + nginx -t && systemctl reload nginx from a tiny script.

What I learned

  • "No upload" is a feature users understand instantly. It's on every page and people genuinely care.
  • WASM quietly changed what "client-side" means. mozjpeg and Ghostscript in a browser tab would have sounded absurd a few years ago.
  • Client-side ≠ simple. Adaptively stepping quality down until a file actually shrinks, palette quantization, PDF object copying — the edge cases are real.

If you want to see it: toolhex.online — everything's free, no signup, and (obviously) nothing to upload. The image compressor and PDF compressor are the two I'd start with.

Happy to answer questions about the WASM integration or the nginx setup in the comments. And if you have an idea for a tool that belongs in this family, I'm building more.

Top comments (0)