DEV Community

shaojie gong
shaojie gong

Posted on

Compressing PDFs in the browser: most of the work is deciding which images not to touch

I build MakeQuickWork, a small set of free browser tools, on my own. This is how its Compress PDF tool works and where the edge cases were.

A PDF that's too big is almost always a PDF full of images. Scans from a phone app, a report with photos pasted in, a brochure exported at print resolution. The text and vector parts are usually a few hundred KB at most. So the plan for Compress PDF was narrow on purpose: find every image, make it smaller if that's safe, and copy everything else byte for byte.

The "if that's safe" part ended up being most of the code.

It all runs in the tab. The page itself loads about 3 KB of JS; the PDF library and the MozJPEG encoder (WebAssembly, via jSquash) are only fetched once you actually pick a file and compress. The PDF is never uploaded, which you can watch in the Network tab: https://makequickwork.com/pdf/compress-pdf/

Walking the image XObjects

I use a maintained fork of pdf-lib. After loading the document, I enumerate every indirect object and keep the raw streams whose /Subtype is /Image. That catches images regardless of which page or form XObject references them, and an image shared by ten pages gets compressed once.

Before touching anything, a first pass collects every object referenced as /SMask or /Mask. Those are transparency masks. Lossy-compressing a soft mask gives you grey halos around logos, so they're off limits.

Then each image goes through a list of reasons to skip it:

  • filter is not exactly DCTDecode (JPEG) or FlateDecode (lossless). That rules out JPEG 2000, JBIG2 and CCITT fax images
  • colour space isn't DeviceRGB, DeviceGray or a 1- or 3-channel ICCBased profile. CMYK print images are skipped rather than risk a colour shift
  • BitsPerComponent isn't 8, or the image has a /Decode array, or it's an image mask
  • smaller than 64 px on a side (icons, bullets: not worth it)

Anything that survives gets decoded to a canvas.

Two decoders

JPEG images are easy: the stream contents already are a JPEG file, so createImageBitmap(new Blob([bytes], { type: "image/jpeg" })) does the work, with imageOrientation: "none" so the browser doesn't apply EXIF rotation the PDF never asked for.

Flate images are raw pixel rows, zlib-compressed. pdf-lib inflates them, but many PDFs also use a PNG predictor (/DecodeParms << /Predictor 15 >>), which means every row starts with a filter-type byte and is delta-encoded against its neighbours. The browser can't help there, so I undo the five PNG filters (None, Sub, Up, Average, Paeth) by hand in a small loop before putting the pixels into ImageData. Any other predictor value: skip.

Not every lossless image should become a JPEG

A Flate image in a PDF is often a screenshot, a chart or a logo. Converting those to JPEG makes ringing around text and edges, and they're usually small as Flate anyway. I didn't want a classifier, so it's a cheap heuristic: sample every 7th pixel and check whether it's identical to the pixel before it.

/** Flat colour graphics (screenshots, charts, logos) look bad as JPG and are usually small as Flate. */
function isFlatGraphic(rgba: Uint8ClampedArray) {
  let same = 0, total = 0;
  for (let i = 4; i < rgba.length; i += 4 * 7) {
    total++;
    if (rgba[i] === rgba[i - 4] && rgba[i + 1] === rgba[i - 3] && rgba[i + 2] === rgba[i - 2]) same++;
  }
  return total > 0 && same / total > 0.5;
}
Enter fullscreen mode Exit fullscreen mode

If more than half the samples match their neighbour, the image stays lossless. Photos almost never pass that; flat UI screenshots almost always do. On the Light level, Flate images aren't re-encoded at all.

Downscale, encode, and only replace if it's clearly worth it

Each level is a maximum long side and a MozJPEG quality: roughly 300 DPI / q82 for Light, 150 DPI / q72 for Recommended, 100 DPI / q55 for Strong, assuming the image covers a normal page. The canvas is downscaled, then encoded as baseline JPEG, because some older PDF readers don't handle progressive JPEG inside a PDF.

Then the check that matters most for quality:

// Only replace when it is clearly smaller; re-encoding a small JPG for 2% is not worth the loss.
const limit = kind === "flate" ? 0.7 : 0.9;
if (jpg.length >= stream.contents.length * limit) continue;
Enter fullscreen mode Exit fullscreen mode

A JPEG has to come out at least 10% smaller to replace the original; a lossless image has to shrink by at least 30%, since that swap also changes it from lossless to lossy. When it does get replaced, the new stream keeps the original dictionary minus Filter/DecodeParms/Length, with new width, height and a plain DeviceRGB/DeviceGray colour space.

The lossless leftovers

Some PDF generators write content streams with no filter at all. After the image pass, any unfiltered stream over 256 bytes gets deflated, kept only if that saves 10% or more. Then the document is saved with object streams on, which packs the many small objects together.

Text, fonts, vector paths, links, bookmarks and form fields aren't touched by any of this, so text stays selectable and searchable. That's the main difference from approaches that rasterise whole pages.

What it does to a real scan

On my test file, three phone-scanned pages, Recommended took it from 0.6 MB to 165 KB, 73% smaller. The page shows original size, compressed size and percent saved:

And the honest part: a mostly-text PDF barely shrinks, because there's nothing big to recompress. If the result isn't at least about 3% smaller, the tool doesn't download anything and tells you to keep the original. Strong makes photos visibly softer. Password-protected PDFs aren't supported yet, and it's one file at a time.

Checking that nothing is uploaded

The site is static (Astro on Cloudflare Pages), so there's no endpoint to upload to. There's also an automated test that processes a file in each tool and fails on any POST or any request to a third party other than Google Analytics, and fails if an analytics event contains a file name. GA4 is there for anonymous page views and a "tool used" event. You still need a connection to open the page and fetch the encoder the first time.

If you've got a PDF that you expected to shrink and didn't, I'm curious what's inside it. CMYK and JBIG2 are the two skips I'm least sure about keeping.

https://makequickwork.com/pdf/compress-pdf/

Top comments (0)