Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 9 min read

How we compress a PDF to an exact size in the browser, with no upload

We are Mirza Games, a small independent studio in Dubai (on Google Play we publish as Sezarr). Most of what we build is puzzle games, but one of the most used things on our site is a plain PDF tool: compress a PDF to a s

We are Mirza Games, a small independent studio in Dubai (on Google Play we publish as Sezarr). Most of what we build is puzzle games, but one of the most used things on our site is a plain PDF tool: compress a PDF to a size in KB. This post explains how it works, with excerpts from the real code.

The problem: "upload a PDF under 200 KB"

Job portals, exam sites and government forms love hard limits. "Certificate, PDF, max 200 KB." "Scanned ID, under 100 KB." The person filling in the form has one number in mind, and the file either passes or it does not.

Most compressors offer three buttons: low, medium, high. You pick one, check the size, and try again. If "high" gives 230 KB, there is no button for "a bit more". If it gives 40 KB, the scan may be unreadable, and the reviewer rejects it anyway.

So we set ourselves two goals:

  1. The user types a target (50, 100, 200, 300, 500 KB, 1 MB, or a custom number), and the file comes out at or under it.
  2. The output must stay readable. If the target is too small for that, we say so instead of returning mush.

Design choice: everything runs in the browser

The files people send to these portals are ID cards, mark sheets, bank letters. We did not want them on our server, so there is no server step at all. The page loads a small UI, and the heavy libraries load only when the user picks a file:

  • qpdf, compiled to WebAssembly (from @mssio/qpdf-wasm), running in its own Web Worker.
  • pdf-lib, for reading and rewriting PDF objects, also in a worker.
  • pdf.js (the legacy build, so older Android Chrome versions work) for rendering pages to a canvas.

All three are vendored in the repo, not loaded from a CDN. Our browser test fails if the page makes any request other than a same-origin GET (plus the analytics script and its hits), so "no upload" is checked on every run, not just promised.

The trade-off is that the user's device does all the work. A phone with 2 GB of RAM cannot hold a 300 dpi render of a large page, so the renderer caps each page canvas by navigator.deviceMemory: 1.6 megapixels on 2 GB devices, 2.6 on 4 GB, 4.2 above that. A very large page may then render below the dpi we want, and long documents simply take longer. The UI accepts files up to 300 MB, but big files are slow on weak devices. That is the cost of not uploading.

Three steps, stopping as soon as the target fits

The engine (compress.js) tries three steps in order, from least to most destructive. It returns the first result that is at or under the target.

Step 1: lossless repack

First we try to shrink the file without changing a single pixel or glyph. Two things run in parallel: qpdf with object streams, Flate recompressed at level 9 (--recompress-flate --compression-level=9, which also re-encodes some older filters), unused resources and the XMP metadata stream removed; and a plain pdf-lib re-save. The smaller one wins.

// 1. lossless
let base = { bytes: orig, size: orig.length };
const tries = await Promise.allSettled([qpdfOptimize(orig.slice()), run('resave', [orig.slice()])]);
for (const t of tries) if (t.status === 'fulfilled' && t.value.length < base.size) base = { bytes: t.value, size: t.value.length };
check();
if (base.size <= target) return { bytes: base.bytes, size: base.size, step: 'lossless', floor: false };
const candidates = [{ bytes: base.bytes, size: base.size, step: 'lossless' }]; // legible results, for the floor case

Promise.allSettled matters: if one tool chokes on a strange file, the other can still win. Note the candidates list too. Every readable result is kept, because we may need the smallest of them later.

Step 2: re-encode only the embedded images

If the file is still too big, the weight is almost always in images: a phone photo of a certificate, or a scan saved at 600 dpi. Step 2 touches only those images. Text, fonts and vector drawings stay exactly as they were.

pdf-lib lists the image objects we can safely rewrite: 8-bit RGB or grey, JPEG or Flate (with PNG predictors undone in the browser), and no masks or Decode arrays. Anything unusual is left alone.

For each image we need to know how big it is actually drawn on the page. A 4000-pixel-wide photo shown 3 inches wide does not need 4000 pixels. We walk pdf.js operator lists, track the transform matrix, and record the largest size each image is painted at. Then each image is offered at a few (dpi, JPEG quality) options:

export const IMAGE_OPTS = [[200, 0.82], [150, 0.82], [150, 0.68], [120, 0.78], [120, 0.65], [150, 0.55], [120, 0.5]];
// photographs (not scans of text) may go a little lower: no text to keep readable inside them
export const PHOTO_IMAGE_OPTS = [[100, 0.6], [100, 0.5]];
// scans of text stay sharp as JPEG only at >= 150 dpi, quality 0.7; below that, step 3's crisp 1-bit pages win
export const TEXT_IMAGE_OPTS = [[200, 0.82], [150, 0.82], [150, 0.7]];

A small probe of each image is classified as "text-like" (mostly white paper, very little colour) or not. Scanned text gets only the three conservative options; other images get the full list. Options larger than the original are dropped, and keeping the original is always allowed.

Picking one option per image so the total fits a budget is a small knapsack problem. We use a greedy chooser:

export function chooseOptions(sizes, budget) {
  const picks = sizes.map(() => 0);
  let total = sizes.reduce((a, s) => a + s[0], 0);
  while (total > budget) {
    let bi = -1, bs = -Infinity;
    for (let i = 0; i < sizes.length; i++) {
      const p = picks[i];
      if (p + 1 >= sizes[i].length) continue;
      const save = sizes[i][p] - sizes[i][p + 1];
      if (save > bs) { bs = save; bi = i; }
    }
    if (bi < 0) break;
    total += sizes[bi][picks[bi] + 1] - sizes[bi][picks[bi]];
    picks[bi]++;
  }
  // ... (if nothing fits, return the smallest option everywhere with fits: false)
  // fill: upgrade the cheapest steps while the budget allows

In words: start with the best option everywhere. While over budget, step down on whichever image saves the most bytes. Once it fits, go back and upgrade the cheapest steps while there is still room. That last pass is what gets the result close to the target instead of far under it.

The budget is the target minus the bytes that are not images, minus a rough 160 bytes of overhead per replaced image. The new JPEGs are swapped into the existing objects in place. Because our overhead guess can be off, the engine builds the real file, measures it, and if it is still over, shrinks the budget by the overshoot plus 512 bytes and tries again, up to four times.

Step 3: rasterise pages, only as a last resort

If text, fonts or vectors themselves are too heavy for the target, the only way down is to turn pages into images. This is the step that loses selectable text, so it runs last, and the result screen says plainly that the pages became images.

Each page is rendered with pdf.js at 150 dpi and classified with the same text-like test. Text pages are rendered again at 200 dpi and converted to 1-bit black and white: an Otsu threshold picks the ink/paper cut-off, pixels are packed eight to a byte, and the result is Flate-compressed with the browser's CompressionStream. A crisp 1-bit page is often much smaller than a blurry JPEG of the same page, and much easier to read. Text pages get 200, 150 and 120 dpi options.

Photo pages become JPEGs at 150 to 100 dpi, quality 0.8 down to 0.5, converted to greyscale if they had almost no colour anyway. The same chooseOptions picks one option per page:

let budget = target - (900 + 380 * n);
for (let attempt = 0; attempt < 4; attempt++) {
  check();
  const ch = chooseOptions(pages.map((p) => p.opts.map((o) => o.size)), budget);
  const out = await buildR(ch.picks);
  const minDpi = Math.min(...pages.map((p, i) => p.opts[ch.picks[i]].dpi));
  if (out.length <= target) return { bytes: out, size: out.length, step: 'raster', kinds, floor: false, minDpi };
  if (!ch.fits) { candidates.push({ bytes: out, size: out.length, step: 'raster', kinds, minDpi }); break; }
  budget -= out.length - target + 512;
}
// Below the legibility floor: the smallest readable file we made.
const best = candidates.reduce((a, b) => (b.size < a.size ? b : a));
return { ...best, floor: true };

Floors: smallest readable size beats any size at all

Notice that every option list above ends somewhere. There is no "50 dpi, quality 0.2" option. The hard floors are:

  • text pages: 120 dpi, 1-bit
  • photo pages: 100 dpi, JPEG quality 0.5
  • embedded scans of text: 150 dpi, quality 0.7
  • other embedded images: 120 dpi, quality 0.5 (photographs 100 dpi)

If even the smallest options do not fit, the engine returns the smallest readable file it made in any step, flagged with floor: true. The UI then says "Smallest readable size is X" and names the download ..._smallest_readable.pdf.

We learned this the hard way: an earlier version always hit the number, and a text form compressed to 50 KB came out unreadable. Such a file is worse than useless. The portal accepts it, and the reviewer rejects the application later. An honest floor lets the user decide: ask for a higher limit, drop a page, or rescan.

How readability is tested. A headless Chrome test drives the real UI. It compresses five generated fixtures (a dense one-page form with an embedded font, a 5-page report, a 300 dpi scan, a photo brochure, a 20-page photo PDF) to 50, 100, 200 and 500 KB. A run passes only if:

  • the output is at or under the target, or the tool showed the floor message;
  • qpdf --check accepts the file;
  • the text stays legible. Original and output are both rendered with pdf.js at 150 dpi and compared only on text windows (small blocks where the original shows dark strokes on light paper). Mean SSIM must be at least 0.7, and edge sharpness (output relative to original) at least 0.75.

Those thresholds were calibrated against the old engine: its unreadable outputs scored 0.18 to 0.55 SSIM, while crisp results score 0.8 or more. Unit tests in Node cover the pure parts: chooseOptions (including the "nothing fits" case), the Otsu threshold and bit packing, image listing and replacement, and the 1-bit page builder, whose output qpdf must accept.

Units: what is a KB?

Portals disagree: some mean 1000 bytes, some 1024. We take the safer reading. A maximum KB is 1000 bytes, so a "200 KB" target means 200,000 bytes, which also passes a portal that allows 204,800. (Our photo tools also have minimum sizes; there a KB is 1024 bytes, for the same reason in reverse.) The result screen shows the exact byte count, so nobody has to guess.

What we learned, and the trade-offs

  • "Exact size" really means "at or under". The greedy fill gets close to the target, but we never pad a PDF upward. The point is passing the limit, not hitting it to the byte.
  • Order matters more than clever encoding. Steps 1 and 2 keep the text selectable. Rasterising is easy and tempting for everything; we kept it last on purpose.
  • We skip images we do not fully understand. Masks, Decode arrays and odd bit depths are left alone. Some files then need step 3, but none get a broken image.
  • Size estimates are guesses, so measure. Both the image and raster steps build the real file, check its length, and retry.
  • Client-side has real limits. Speed and render size depend on the device. We chose slow and private.
  • Readability needs a number. "Looks fine to me" did not catch the bad 50 KB form. A metric on text regions, with a threshold, did.

Try it

The tool is live at mirzagames.com/pdf/compress-pdf-to-kb/, and the other PDF tools (merge, split, photo to KB and more) are at mirzagames.com/pdf/. It runs fully in your browser, so you can test it on a real document. If you find a PDF that comes out unreadable, or that should have reached a target and did not, we would like to hear about it in the comments.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.