RetypePDF

ยท RetypePDF ยท 4 min read

The PDF Bug That Only Existed in the Browser

A whole class of defects passed every test I had and broke for every real user. Here is why no Node-only test suite could have caught it.

The report

A user ran a batch of PDFs through RetypePDF and got one line back:

Could not decompress stream

I could not reproduce it. Twelve synthetic test documents passed. Nine real PDFs from my own disk passed. The engine is ~600 lines of dependency-free JavaScript. It runs the same code in Node and in the browser. That was the whole point of the design.

So I did what I should have done first. I loaded the same real files in a headless Chromium.

ok    methodology_onepager.pdf
ok    trustwatch_deck_fr.pdf
FAIL  factsheet2024.pdf       Could not decompress stream

Same file. Same code. Passes in Node, fails in the browser.

Where the bytes went wrong

PDF stores compressed data in streams. A stream has a dictionary that says how long the data is, then the bytes, then the keyword endstream:

<</Type/ObjStm/N 500/First 4838/Filter/FlateDecode/Length 8235>>
stream
...8235 bytes of deflate data...\r\n
endstream

Note the \r\n before endstream. It is part of the file format, not part of the data. The /Length tells you where the data stops. If you honour it, you never see those two bytes.

My dictionary parser did not honour it. Not for this file.

The parser bug

The parser scanned a value until it hit the next /Key. That works when /Length is followed by another key:

<< /Length 8235 /Filter /FlateDecode >>   โ†’  "8235"   โœ“

It fails when /Length is the last key. There is no next /. The scan runs into the closing >> and swallows it:

<< /Filter /FlateDecode /Length 8235>>      โ†’  "8235>>"  โœ—

"8235>>" is not a number. So the length check silently did nothing. So the slice kept its trailing \r\n. So the inflater received 8,237 bytes instead of 8,235.

Why Node did not care

Node's zlib.inflateSync reads a complete deflate stream and stops. Bytes after the end are ignored. No error.

The browser's DecompressionStream reads a complete deflate stream and then sees two more bytes. It throws:

TypeError: Trailing junk found after the end of the compressed stream

Both behaviours are defensible. They are simply different. And my engine, "identical in Node and the browser", was not identical at all. It was identical plus a platform-dependent zlib.

PlatformTrailing bytes after deflate data
Node zlib.inflateSyncignored
Browser DecompressionStreamthrows

Why my tests could not see it

Two reasons, and the second is the one worth remembering.

First: my synthetic corpus was written by my own generator. It emitted /Length 914 >> โ€” with a space before the >>. The scanner stopped at the space. Real PDF writers pack dictionaries tight: /Length 8235>>. My test files never had the shape that broke.

Second: even with the right shape, a Node test would have passed. I built a fixture with the exact bug and checked:

old engine, Node:     ok pages 1 runs 5
old engine, browser:  FAIL No pages found in this PDF.

The Node suite is structurally blind to this class of bug. Not under-tested. Blind. No amount of Node coverage would ever have caught it.

The fix, in three parts

1. The parser. At depth zero, >> and ] close the enclosing container. The value ended before them. Stop, do not consume.

This turned out to be broader than /Length. Every last-key lookup was affected. Six real files had their fonts reported as unknown font. After the fix: CID/Type0 font โ€” not editable yet. Same refusal, correct diagnosis. The bug had been degrading font identification the whole time.

2. Parity. The browser inflater now trims trailing PDF whitespace and retries. Only whitespace โ€” a truncated stream must still fail, not yield half a page. Both platforms now accept the same inputs.

3. The test that can actually see it. A suite that runs the engine in a real Chromium and diffs page count, run count and extracted text against Node. It starts its own server. It fails loudly if Chromium is missing, because a skipped browser test that reports green is worse than no test.

ok   13-tight-dict.pdf   pages 1  runs 5
all 13 agree between Node and the browser

What I took from it

The corpus now has a fixture in the shape real writers emit. The CI runs the browser suite on every push. The bug is closed. The class of bug is closed.

RetypePDF edits the text inside a PDF in your browser. Nothing is uploaded. No account, no price.

Try it