Frontend
A PDF Exporter With No PDF Library
Seth Wheeler DEV Community
1 views
Longshot, a Firefox screenshot extension I have been building, exports to PDF. The exporter is 336 lines and pulls in nothing. That is not a boast about writing things from scratch, which is usually a bad reason to do anything; it is that the two jobs a PDF library would do here are both already done by the browser, and once you notice that, the remaining work is smaller than the dependency would have been. The code is not public, so this is a description rather than an invitation to read it.
The two coincidences
A PDF stores image data in a stream with a named filter, and the filters that matter here are /DCTDecode and /FlateDecode.
/DCTDecode means "this stream is a JPEG bitstream". The viewer decodes it; a JPEG goes into the file verbatim, byte for byte, with no re-encoding and no image codec to ship. The browser already produced the JPEG.
/FlateDecode means "this stream is zlib-wrapped deflate". CompressionStream('deflate') is native, and what it emits is zlib-wrapped deflate. Not deflate that needs a header bolted on, and not raw deflate: precisely the thing FlateDecode names. So there is no zlib to bundle either.
Those two facts are why the exporter is small, and they are worth stating as facts about the formats rather than as cleverness. PDF's filters were specified around image and compression formats that were already standard in 1993, and the browser platform, decades later, exposes those same two formats through canvas.toBlob and CompressionStream. Nobody coordinated that. It just means an exporter written today can hand both straight through.
It also means the honest claim here is narrow. A general PDF library does typography, fonts, vector graphics, encryption, forms, incremental updates and a hundred other things. This one puts images on pages and adds an invisible text layer. Saying "no dependency" is only meaningful alongside saying what the dependency would have been for.
Where it can go quietly wrong
Every PDF ends with a cross-reference table: one entry per object, each entry a byte offset into the file. A viewer reads the table first and seeks. One wrong offset makes the whole document unreadable, and it does so in the worst possible way, because all the bytes are present and correct and simply unfindable. Nothing higher up notices; the image is there, and so is the text layer. The file is 1.4 MB of perfectly good data with a broken index.
The failure mode you get from a mistake like that is not a crash during export. It is a file that a user saves, keeps, and opens three weeks later.
Two things follow. The file is assembled as bytes and measured, never estimated from string lengths, because the moment an offset comes from a .length on something that has not been encoded yet, the number is a guess that happens to be right for ASCII.
And the test does not check that a cross-reference table exists, or that it has the right number of entries, or that the offsets are plausible. It follows them: for each entry, seek to the offset it claims and confirm the object it points at is the one it is supposed to be. There are 21 assertions in that suite, and this is the one that would have caught the mistake worth catching.
The design decision that made that testable
pdf.js deliberately touches no canvas APIs. Everything to do with a canvas lives in a separate 115-line bridge.
That split is the only reason the byte-level testing above can happen in Node at all. A PDF writer that reached for canvas.toBlob internally would need a browser to run, which would put it behind the same wall as the print pipeline and the OCR chain, both of which need a real DOM and are covered by a browser harness instead. Keeping the byte assembly pure was not a purity exercise; it moved the component that can silently corrupt a file into the environment where it is cheapest to check.
That is a decision I would make again and did not make for the right reason the first time. It started as tidiness.
Two smaller things worth writing down
Tall captures split at page height rather than becoming one enormous page. A 30,000-pixel single page is valid PDF and practically useless: it cannot be printed, and most viewers scale it to illegibility. Valid and useless is a combination worth watching for in any format with generous limits.
The invisible OCR layer uses text render mode 3, which draws nothing while remaining selectable and searchable, with Tz horizontal scaling so a selection tracks the pixels underneath it. The font is base-14 Helvetica, WinAnsi-encoded, so Latin scripts only. Characters it cannot represent are dropped rather than written out as mojibake, on the grounds that a searchable layer containing wrong characters is worse than one containing fewer.
That last choice pairs with a related one in the OCR stage: words recognised below 60% confidence are discarded. A searchable layer is only useful if searching it finds the right thing, and OCR noise makes a search land on words that were never on the page. Both decisions trade completeness for the property people actually rely on.
What generalises
"No dependencies" is a description of effort until you can say what the dependency would have done, and both halves of that sentence have to be checkable. Here the answer is that a PDF library would have shipped a JPEG encoder and a zlib implementation, and the browser exposes both formats natively, so the honest version of the claim is that the platform already had the two pieces and the rest is 336 lines of file assembly.
The corollary is the more useful part. When a dependency turns out to be replaceable by something already present, the risk does not disappear, it moves. Here it moved into the cross-reference table, which is the one structure in the file with no redundancy and no way to self-check. That is where the test needed to be, and finding it meant asking which part of the output could be wrong in a way that nothing downstream would report.
Read original: https://dev.to/megapixel99/a-pdf-exporter-with-no-pdf-library-2hb9
← Previous
From a Synthetic AHP Event to a Real Base Sepolia Anchor
Next →
Translating 300-Page Books with Claude: Taming Token Limits and Context Windows
Related
React Context Is Not State Management: Stop Using It
Frontend
1
DEV Community
Why Does My Qualcomm Wi-Fi Throughput Fall Below the Datasheet?
Frontend
2
Dev.to (EN Zone)
Beyond APIs: Building a Privacy-First Drug Interaction Tool with WebGPU and WebLLM
Frontend
2
DEV Community
Why Amazon Deprecated MOBI for Kindle and How In-Browser EPUB Conversion Works
Frontend
3
DEV Community
Comments0
No comments yet — be the first