A 6-page text PDF went from 298.7 KB to 8.9 KB with every one of its 26,652 characters still selectable. The usual way to shrink a PDF gets to 30.5 KB and zero.
There are two ways to make a PDF smaller. Only one of them keeps the document a document.
The common way is to render every page to an image and rebuild the file from those images. It always works, it works on scanned documents, and it is what most compressors reach for when a file refuses to shrink. What it also does, quietly, is delete the text layer — the document still looks right on screen, but it is now a photograph of words.
The other way is to leave the pages alone and shrink the file around them: collapse duplicated objects, and compress the streams that hold the actual content. When that is enough, you get a much smaller file and the text survives.
On the document I tested this with, the difference is not subtle:
| Approach | Size | Extractable characters |
|---|---|---|
| Original | 298.7 KB | 26,652 |
| Lossless repack | 8.9 KB | 26,652 (100%) |
| Raster, 150 dpi | 30.5 KB | 0 |
| Raster, 120 dpi | 21.5 KB | 0 |
| Raster, 96 dpi | 15.8 KB | 0 |
| Raster, 72 dpi | 11.2 KB | 0 |
Lossless came out 3.4× smaller than the best raster result, and it was the only row where the text survived at all.
Why rasterizing costs you the text
A PDF page is a draw-operations list. A paragraph of text is not pixels — it is a font reference and a set of glyph positions, and a reader can select it, search it, copy it, index it, and hand it to an applicant tracking system that parses it.
Rasterizing replaces that list with a bitmap. The glyph positions are gone, because there is no longer any glyph — only dots. The document looks identical at 100% zoom. Everything a machine wanted to do with it is gone.
That matters more in some places than others:
- An ATS that counts words in an uploaded resume gets nothing.
- A portal that OCRs a transcript to check it is machine-readable has nothing to read.
- Search inside the document stops working.
- Copy-paste for a form that asks you to paste your details fails.
- Any later attempt to compress it again, properly, is now working on a scanned document.
None of this fails loudly. The upload succeeds. The failure shows up weeks later as a request for a fresh copy.
The bug that sent us down the raster path
The interesting part is why a compressor ends up rastering a text PDF in the first place, when the text was never the problem.
The repack is three steps: load the source, copy the pages into a fresh document, and save with useObjectStreams: true. Object streams pack the cross-reference table and the object headers into a compressed stream of their own, which is where a lot of the easy savings are.
What they do not do is touch the page content streams. Those are copied across with whatever encoding the source already had.
And some producers write content streams with no /Filter at all — raw, uncompressed. For those files, the repack came out at exactly the same size as the input. A text document that had already been through a "compression" tool, repacked and saved again, would sit there unchanged, miss its target, and get pushed down the raster path — where its text became a 45 dpi image.
The file was not too big because of its images. It was too big because nobody had compressed the streams that held the text.
The fix is about twenty lines
Walk every indirect object in the document. For each raw stream whose dictionary has no /Filter, deflate it and label it:
async function flateUnfilteredStreams(doc) {
const { PDFRawStream, PDFName, PDFNumber } = PDFLib;
const entries = Array.from(doc.context.enumerateIndirectObjects());
for (const [ref, obj] of entries) {
if (!(obj instanceof PDFRawStream)) continue;
if (obj.dict.get(PDFName.of('Filter'))) continue; // already encoded
const raw = obj.contents;
if (!raw || !raw.length || raw.length > MAX_DEFLATE) continue;
const z = await deflateBytes(raw);
if (!z || z.length >= raw.length) continue; // deflate made it worse
const dict = obj.dict.clone(doc.context);
dict.set(PDFName.of('Filter'), PDFName.of('FlateDecode'));
dict.delete(PDFName.of('DecodeParms')); // filters no longer apply
dict.set(PDFName.of('Length'), PDFNumber.of(z.length));
doc.context.assign(ref, PDFRawStream.of(dict, z));
}
}
CompressionStream('deflate') gives you the deflate in the browser, so this costs nothing to ship:
function deflateBytes(bytes) {
const stream = new Blob([bytes]).stream()
.pipeThrough(new CompressionStream('deflate'));
return new Response(stream).arrayBuffer().then(b => new Uint8Array(b));
}
Three details in that snippet are load-bearing:
-
Skip anything that already has a
/Filter. Deflating an already-deflated stream grows it, andDecodeParms(predictors, for the ASCII-hex and LZW-family filters) would no longer describe the data. -
Keep the original when deflate does not help.
if (!z || z.length >= raw.length) continue;— compression is not guaranteed to be an improvement. -
Snapshot the object list first.
context.assign()replaces entries while you are iterating them.
On the test document this is the entire difference between 298.7 KB and 8.9 KB.
If you do have to rasterize, put a floor under it
Rasterizing is sometimes the only option left: the page is a scan, or the streams are already deflated at maximum, or the file is full of enormous images. Then the question is how badly you are allowed to render it.
The failure mode is a scale ladder that walks quality down until the file fits. On a page with 9 or 10 pt body text, the bottom of that ladder turns the document into a picture of text that nobody can comfortably read — a submission that fits the limit and gets rejected by a human.
So pages that carry text get a floor:
// 9-10 pt body text is a blur at that size. 1.3 is the ~94 dpi rung - still
// legible, and still a document rather than a photograph.
var MIN_TEXT_SCALE = 1.3;
var SCALES = [2.0, 1.6, 1.3, 1.0, 0.8, 0.62];
ladders[i - 1] = scaleLadder(baseVp, keepTextReadable ? MIN_TEXT_SCALE : 0);
Below 1.3 the ladder is simply not available for a text page. An image-heavy page can still go to 0.62, because that is what image-heavy pages are for.
The rule that matters most: a bigger file is not a result
Here is the case that changed how I think about it. A 124 KB text PDF, asked for a 100 KB target:
- Rasterizing it at a readable 94 dpi produced 190 KB — it overshot the target by 90%.
- The lossless repack produced 115 KB. It also missed the target.
The repack missed. But it was smaller and it kept the text, so it wins. And if neither path gets under the target and the raster came out larger than the file you started with, hand back the original:
// A 124 KB text PDF at a 100 KB target can rasterize to 190 KB at a readable
// 94 dpi; the 115 KB lossless repack is smaller *and* keeps the text, so it
// wins even though it misses the target. A result that grows the file is not
// a result.
if (best.length >= original) {
best = buf;
method = 'original';
}
Missing a size limit with a readable, selectable document beats hitting it with an unreadable one. A rejected upload can be retried. An unreadable one usually just fails quietly.
How to check any tool, including this one
- Compress a PDF that contains real selectable text — not a scan.
- Open the result and drag across a sentence. If it selects, the text layer survived.
- Or open a terminal:
pdftotext out.pdf - | wc -c. Zero means it is a picture. - If the tool claims nothing is uploaded, open DevTools, drop a file, and watch the network tab. A client-side tool makes no request carrying the document.
That third step is the only one that is hard to fake.
Method note: sizes above are from one 6-page text PDF (298.7 KB, 26,652 extractable characters), measured on the same file in the same session. Raster figures are JPEG at PyMuPDF's default quality — I could not vary the quality setting through that API, so treat the dpi rows as the variable that was actually controlled. Character counts come from get_text() on each output.
The tool I built does this in the browser, with no server involved: millfile.com. The source is open — github.com/funkyzx26-dot/millfile.
Top comments (1)
This explains a bug I kept blaming on the viewer. The file looked fine, search returned nothing, and the compressor had flattened the pages into images. The size table helped too, since the raster versions weren't even smaller than the lossless one.