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.
| Platform | Trailing bytes after deflate data |
|---|---|
Node zlib.inflateSync | ignored |
Browser DecompressionStream | throws |
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
- "Same code on both platforms" is a claim about your code. Not about the platform APIs under it.
- A synthetic corpus tests what you thought to generate. Real files test what the world writes. You need both.
- If a bug can only appear in environment X, only a test that runs in X can catch it. Coverage in Y is not partial protection. It is zero.
- Silent tolerance hides bugs. Node's forgiving zlib let a parser defect ship for weeks. The strict browser found it in one request.
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