DEV Community

Edem DOGBE
Edem DOGBE

Posted on

Shrinking a PDF Without Turning Its Pages Into Pictures

Most browser-side "compress PDF" code does the same thing. It renders every page to a canvas, encodes that canvas as a JPEG, and builds a new PDF with one picture per page. It works on anything, and the file usually gets smaller. It also destroys the document: the text can no longer be selected or searched, vector drawings become blurry pixels, links are gone, and a screen reader finds nothing to read.

For a scanned contract that is a fair trade. For a report that is heavy only because someone pasted ten phone photos into it, it is the wrong tool. The text and vector graphics weigh almost nothing; the photos are the only thing worth touching.

This post walks through the second approach as we implemented it in the PDF compressor of VellumPDF, which runs entirely in the browser with pdf-lib and a canvas. Most of the work is not compressing. It is deciding which images you are allowed to compress.

Images in a PDF are just streams

An image in a PDF is an object, usually shared: a stream whose dictionary says /Subtype /Image, with its width, height, color space, bits per component and the filter that encodes its bytes. Pages do not contain the image; they reference it by object number. The same logo can appear on every page and exist once in the file.

That gives you a clean plan:

  1. Walk every indirect object in the document.
  2. Keep the raw streams whose dictionary says /Subtype /Image.
  3. Decode the pixels, resize them if they are larger than needed, re-encode them as JPEG.
  4. Put the new bytes back under the same object number, so every page that pointed to the old image now points to the new one without being touched.

With pdf-lib, step 1 is doc.context.enumerateIndirectObjects(), which returns [ref, object] pairs. Step 4 is less obvious. pdf-lib's JpegEmbedder has an embedIntoContext(context, ref) method, and if you pass it an existing ref, it writes the new image stream into that slot instead of allocating a new one:

const embedder = await JpegEmbedder.for(newJpegBytes)
await embedder.embedIntoContext(doc.context, ref)
Enter fullscreen mode Exit fullscreen mode

The embedder builds a fresh dictionary for the stream (width, height, color space read from the JPEG itself, /DCTDecode). Anything the old dictionary carried is lost, which matters for transparency, as we will see.

The eligibility list is the real work

If you recompress every image you find, some documents will come out with wrong colors, broken transparency or invalid structure. Our eligibility function returns "leave it alone" for all of the following. Each rule exists because of a specific way to break a file.

Stencil masks (/ImageMask true). These are 1-bit shapes painted with the current fill color, not pictures. Turning one into a JPEG makes no sense.

An explicit /Decode array. It remaps sample values, sometimes inverting them. Re-encoding the decoded pixels and dropping the array would flip or shift the image.

A /Mask entry. Either a color-key mask (an array: "treat these colors as transparent") or a stencil stream. JPEG is lossy and shifts colors slightly, so a color key that matched exact values stops matching. And since the new dictionary is built from scratch, the entry would be lost anyway.

A soft mask carrying /Matte. A soft mask (/SMask) is a grayscale stream that gives an image its alpha channel. When it has a /Matte entry, the image was pre-blended with a background color, and the specification requires the mask and the image to have the same dimensions. Shrink the image and the file is no longer valid. So these are skipped too.

Any filter other than DCTDecode or FlateDecode. JBIG2 and CCITT are bilevel formats that already compress black-and-white scans far better than JPEG ever will. JPX (JPEG 2000) and LZW are rare enough that we left them out rather than ship a decoder for them.

Anything other than 8 bits per component, for Flate images.

Flate images with a PNG or TIFF predictor. With /DecodeParms << /Predictor 10..15 >>, the inflated bytes are not pixels yet; each row starts with a filter byte and stores differences from the row above. You have to undo that before you have an image. We do not, so these images stay as they are. This is a real gap: images that came from PNG files often use predictors.

Color spaces other than gray or RGB. We accept DeviceGray, DeviceRGB, and ICCBased with 1 or 3 components. Everything else is skipped: CMYK, Indexed, Separation, DeviceN, CalRGB. CMYK JPEGs deserve a special mention. Files written by Adobe software often store them inverted and flag it in an APP14 marker. Decode one in a browser, re-encode it, and you get an RGB image with colors that range from slightly off to photographic negative. Not worth the risk.

One more rule sits outside the per-image check. Before the main loop, we collect every object that some other image references as its /SMask. Those streams look exactly like ordinary 8-bit grayscale images, and the main loop would happily recompress them. A soft mask turned into a lossy JPEG gets fuzzy edges and halos, so mask streams are never touched.

Decoding, resizing, encoding

Once an image passes, the pixels come from one of two paths.

For a DCTDecode stream, the bytes are a JPEG file. The browser decodes it: createImageBitmap(new Blob([bytes], { type: 'image/jpeg' })), draw it on a canvas, read back getImageData.

For a FlateDecode stream, pdf-lib inflates it with decodePDFRawStream(stream).decode(), and we expand the gray or RGB samples to RGBA ourselves. If the inflated buffer is shorter than width × height × components, the stream is truncated or we misread it, and we bail out on that image.

Then: optional grayscale conversion, a resize so that the longest side does not exceed a limit, and canvas.toBlob(callback, 'image/jpeg', quality). Our default setting caps the longest side at 2000 pixels and uses quality 0.75.

The step that matters most comes next:

if (newJpeg.length >= original.length) continue // keep the original
Enter fullscreen mode Exit fullscreen mode

An image that is already a well-compressed JPEG, or a small flat-color Flate image, can come out larger after re-encoding. We only swap when the result is lighter. Without this check, "compression" makes some files grow.

If the original image had a soft mask, we put the reference back on the new dictionary after embedding:

const smask = dict.get(PDFName.of('SMask'))
await embedder.embedIntoContext(context, ref)
if (smask) context.lookup(ref, PDFStream).dict.set(PDFName.of('SMask'), smask)
Enter fullscreen mode Exit fullscreen mode

A decode or encode failure on a single image (a damaged progressive JPEG, for instance) is caught and skipped. One bad image should not fail the whole document.

Finally the document is saved with doc.save({ useObjectStreams: true }), which packs the small non-stream objects into compressed object streams. On its own, that re-save is our lossless "Light" level, which can save a little on files written by tools that did not use object streams.

The grayscale trap

Our "grayscale images" option has a limit worth knowing. The canvas JPEG encoder only writes three-component JPEGs. Set R = G = B on every pixel and the image looks gray, but the file keeps three channels, and pdf-lib tags it DeviceRGB. You lose the color without saving the bytes a one-channel JPEG would save. Real single-channel output means shipping your own JPEG encoder.

Hitting a target size without compounding damage

People rarely want "compress at level 2". They want "under 2 MB, because the upload form says so". Our target-size mode tries settings in order: first image recompression with progressively stricter limits (2000 px at 0.75, then 1600 px at 0.6, 1200 px at 0.45, 900 px at 0.3), and only after that, as a last resort, full-page rasterization at decreasing scales.

Two details made this behave well.

Every attempt starts from the original bytes. It is tempting to feed attempt N's output into attempt N+1. That re-encodes JPEGs that were already re-encoded, and each pass adds artifacts on top of the previous ones. Reloading the original is slower and gives a cleaner result at the same size.

The best result is kept even if the target is missed, and the user is told. The interface says plainly whether the target was reached, and shows a separate warning when reaching it required converting pages to images, because at that point the text is no longer selectable. If the output is heavier than the input, a "Keep the original" button appears. Silently delivering a worse file is the failure mode to avoid.

Keeping the PDF logic testable

The module that walks the PDF never imports a canvas. The JPEG decoder and encoder are passed in as functions. In the browser they wrap createImageBitmap and canvas.toBlob. In a Node test they wrap @napi-rs/canvas. Everything else (object traversal, eligibility, stream replacement, the size comparison) is plain JavaScript that runs under Node against real PDF files, without a browser.

That split matters because the eligibility rules are where the bugs hide, and the cases worth checking (a CMYK JPEG, a masked image, a predictor-encoded Flate stream) are much easier to feed to a Node script than to click through in a browser.

Try it on a real file

If you want to see the behavior on your own files, the compressor runs locally in the tab, next to the other free PDF tools on VellumPDF. Try a document with selectable text on the default level, then on "Maximum", and compare what you can still copy out of each.

Top comments (0)