DEV Community

swifttooly
swifttooly

Posted on

Compressing PDFs in the browser, and the bug that shipped empty files

Last time I wrote about compressing images in the browser with the Canvas API. I ended that post by saying the PDF version of the story was messier. It is, and one of the bugs sat in production longer than I'd like to admit.

Same constraint as before: everything runs on the user's device. No upload, no server, no API.

A PDF has no quality slider

With an image you call canvas.toBlob(resolve, 'image/jpeg', 0.7) and you're done. There's a dial, you turn it.

A PDF is a container. Fonts, vector paths, embedded images, metadata, and a cross-reference table holding it together. No single number makes it smaller. What's making a given file heavy depends entirely on what's inside it, and two documents with the same page count can need completely different treatment.

You get roughly two options.

Rebuild the file structure. Load it with pdf-lib and write it back out:

js
import { PDFDocument } from 'pdf-lib';

const pdf = await PDFDocument.load(bytes);
const out = await pdf.save({ useObjectStreams: true });

This is lossless. Text stays selectable, fonts stay fonts. The savings are modest and sometimes zero, because all you're doing is tidying up structure.

Re-render every page. Draw each page onto a canvas with pdf.js, re-encode it as JPEG, then assemble a new PDF from those images. This is where the impressive percentages come from, and it costs you a lot: text becomes pixels, selection stops working, screen readers get nothing, and small type goes fuzzy at low quality.

Most of the online compressors advertising "up to 90% smaller" are quietly doing the second one.

The bug: pdf.js empties your buffer

This is the one that cost me real debugging time.

Here's the shape of the code that broke:

js
const buf = await file.arrayBuffer();

const doc = await pdfjsLib.getDocument({ data: buf }).promise;
const compressed = await compress(doc);

if (compressed.size >= file.size) {
// Can't beat the original, so hand it back untouched
return new Blob([buf], { type: 'application/pdf' });
}
return compressed;

Looks fine. It isn't.

getDocument({ data }) transfers the ArrayBuffer to its worker. Transferred, not copied. The moment that happens your buf is detached and its byteLength is 0. No error, no warning, just an empty buffer sitting where your file used to be.

So the fallback branch, the one that runs when a PDF is already well optimized and can't be shrunk, handed people a 0-byte download. And it only ever fired for files that couldn't be compressed, which is precisely the case I never thought to test.

The fix is one line:

js
if (compressed.size >= file.size) {
return file; // File already extends Blob
}

You never needed to reconstruct anything.

If you genuinely need the bytes after loading, copy before you hand them over:

js
const buf = await file.arrayBuffer();
const doc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise;

Two things worth knowing. getDocument also takes a plain Uint8Array, and the same detach rule applies there. And this isn't a pdf.js bug. Transferables are how you move data into a worker without copying it, and losing access on the sending side is the entire point. It's documented behaviour. I just didn't read closely enough.

Never hand back a bigger file

Same rule as the image post, and it matters more here:

js
if (out.byteLength >= file.size) return file;

PDFs produced by decent tooling are frequently well compressed already, and re-saving one can add bytes rather than remove them. A compressor that returns a larger file while reporting success is worse than one that does nothing at all.

Memory will end you

Rendering every page to canvas at full resolution kills a mobile tab. A 40-page document at 2x scale is an enormous number of pixels to hold at once, and the tab usually dies silently.

Render one page, encode it, throw the canvas away, then move to the next. Don't collect canvases in an array and process them at the end. I also cap the render scale instead of letting page dimensions decide it for me.

What I'd tell someone starting this

Try the lossless path first and measure whether it was enough. Only rasterize when the user asks for it and understands what they're trading away.

And test the path where compression fails. That's where my bug lived, and it's the branch nobody writes a test for.

The tool is at SwiftTooly if you want to try it. Free, and the file never leaves your machine.

Top comments (6)

Collapse
 
to21as profile image
Tobias

The detached-buffer one is worth writing up on its own, not least because it only fires on the branch you'd never think to test.

One thing to add to the lossless path, because it's lossless for rendering and not for conformance. pdf.save({ useObjectStreams: true }) writes object streams, and object streams force a cross-reference stream. Both are PDF 1.5 features, and PDF/A-1 is defined against PDF 1.4 and forbids them (ISO 19005-1, clause 6.1.4). So hand it a PDF/A-1b archival file and your lossless path returns something that renders identically, really is smaller, and no longer conforms, while its XMP still says pdfaid:part 1. The file now makes a claim about itself that isn't true, and nothing in the output says so.

I know this one from the other direction. My own pipeline has a pdf-lib pass upstream that writes xref streams, and the fix is a finalise step that rebuilds the container as a classic table, for level 1 only. PDF/A-2 and 3 allow object streams, so the trap is specific to 1.

The guard is cheap and needs no validator: read pdfaid:part from the XMP on load, and skip useObjectStreams when it's 1. And if the file declares 1a, or carries pdfuaid, the rasterize path deserves an explicit warning rather than a quality slider, because tagged structure and real text are the entire point of those levels and the output will keep declaring conformance it no longer has.

Collapse
 
swifttooly profile image
swifttooly

This is a gap I didn't know I had. What bothers me most isn't the lost
conformance, it's that the output keeps declaring pdfaid:part 1 while no
longer meeting it. Nothing downstream would catch that.

Reading pdfaid:part on load and skipping object streams for level 1 is cheap
enough that there's no argument against it. Adding it.

The 1a and pdfuaid case I'd have missed completely. Rasterizing a tagged
document isn't a quality tradeoff, it's throwing away the reason the file
exists, and it would carry on claiming conformance afterwards. That needs a
refusal or a very loud warning, not a slider.

Useful to hear it from the other direction. Thanks for writing it up.

Collapse
 
to21as profile image
Tobias

One more that lands in exactly the path you're adding, and it's the one that got me.

pdf-lib stamps the Info dictionary on its own. PDFDocument.load() defaults to updateMetadata: true, so on save it writes its own Producer and a fresh ModDate. Harmless for most files, but PDF/A treats the XMP stream as authoritative and requires the Info dictionary entries to have equivalent XMP properties, and that pair gets checked. pdf-lib writes Info and doesn't touch XMP, so a round-trip leaves Producer saying "pdf-lib" in one place and whatever actually produced the file in the other. Your guarded path would then still hand back a broken PDF/A-1, having correctly skipped object streams.

PDFDocument.load(bytes, { updateMetadata: false }) turns it off, which is what you want in a compressor anyway, since you aren't the producer and shouldn't be claiming to be.

Worth knowing before you go looking for an XMP API in pdf-lib, because there isn't much of one. Reading pdfaid:part means going at the catalog's Metadata stream directly, and writing XMP meant a second library entirely in my case, which is how I found this in the first place.

Thread Thread
 
swifttooly profile image
swifttooly

That would have bitten me right after shipping the first guard. Skip the
object streams correctly, hand back a file that's still broken, and feel good
about it.

updateMetadata: false is right even without PDF/A in the picture. A compressor
isn't the producer and shouldn't be writing itself into that field. I'd been
treating it as harmless bookkeeping.

Good warning about the XMP API too. Reaching into the catalog's Metadata
stream for one string I can live with. Needing a second library to write it
back would have changed how I scoped this.

Thread Thread
 
to21as profile image
Tobias

Glad it was useful. One more reason updateMetadata: false is the right default, past tidiness: it also keeps the Info dictionary and the XMP stream from disagreeing.

If pdf-lib stamps itself into Info while the XMP stream keeps the original producer, you've made a mismatch, and mismatched metadata is one of the standard tells forensic tooling reads as "edited by something other than what it claims". For a compressor that's technically accurate and completely innocent, and you'd rather not be the one explaining it. Leaving both alone sidesteps it without the second library.

Thread Thread
 
swifttooly profile image
swifttooly

Hadn't thought about the forensic side at all. It's a strange position to be
in: the tool did nothing wrong, the file is fine, and it still reads as edited
by something that won't say what it was.

Leaving both fields alone is the cheapest possible answer to that, which makes
it hard to argue for anything else. No second library, no XMP writing, nothing
to keep in sync.

Three separate reasons to stop touching metadata I never needed to touch. That
default is doing real damage quietly.