Build1 publisher3 min readPublished
MonkeeTools' hand-written PDF engine assembles files in about 130 lines, with WinAnsiEncoding as its honest tradeoff
A browser-only invoice generator assembles the header, objects, cross-reference table and trailer itself, skipping libraries its author puts at 300KB-plus, and its escaper drops anything unmappable to a question mark.
The Engineer · Build desk
What happened
- MonkeeTools, a browser-only invoice generator, hand-wrote its PDF output in about 130 lines of assembly code plus helpers, on a static site with no backend, no npm install and no build step.
- The post's stated reason for skipping jsPDF and pdf-lib is that they are 300KB-plus of dependency surface for what it calls a pretty simple file format.
- buildPDF() runs in three phases: helper closures turn invoice data into content-stream commands, the objects are serialized with each byte offset recorded, then the xref table and trailer are built from those offsets.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The encoding choice decides who can be invoiced correctly. A customer name in Cyrillic or Japanese comes out stripped or as a question mark, so the engine fits a Western European billing list and stops there.
- cost Maintenance moves from a version bump to owning the cross-reference table. Whoever edits an earlier object owns every byte offset after it, and a wrong object count produces a corrupt file rather than a caught exception.
- decision Teams weighing a PDF library can now ask the narrower question: is the 300KB buying font and encoding coverage, or familiarity? Text, rules, a logo and one link annotation are demonstrably a 130-line job.
The cross-reference table is where this design gets expensive. Each object is serialized with its byte offset recorded, and the trailer is written out of those recorded numbers [7][10]. Edit a string in the content stream and every offset after it moves. The object count is one line, `const N = 14 + (logoImg ? 1 : 0) + (payURL ? 1 : 0)`, because the logo image and the Pay now link annotation are optional, and the post says getting that number wrong corrupts the file, so it is derived and never hardcoded [9].
Work the loop out and the base document is thirteen numbered objects. N is written into the `xref 0 N` header and into `/Size`, and the fill loop runs from 1 to N minus 1, so 14 covers the free object 0 plus objects 1 through 13; with both optional objects present, N is 16 and there are 15 [10][11]. The post enumerates them: catalog, page tree, page, content stream, two fonts, font descriptors, font files, annotations [12].
Drawing is two closures. `txt()` pushes a begin-text, font select, size and color, move, show-string, end-text sequence; `line()` strokes a rule; the template renderers push hundreds of those, and the whole array becomes one content-stream object [8].
"The first time a real PDF reader opened the output without complaining, I celebrated more than I should admit," the author wrote [18].
Two subsetted Inter files, regular and bold, are embedded as FontFile2 streams with a hand-written font descriptor each, so the brand font does not fall back to Helvetica on systems that lack it [13]. The font dictionary declares `/FirstChar 32 /LastChar 255` and `/Encoding /WinAnsiEncoding` [14]. That line sets the product's language coverage. The post calls WinAnsiEncoding "the honest tradeoff at the heart of this engine": Latin-1 plus a slice of typographic characters, enough for every Western European language, and no emoji, CJK, Arabic or Cyrillic [15].
The escaper has four branches. Printable ASCII passes through, with backslash and parentheses escaped for PDF string syntax; anything under code point 256 becomes a three-digit octal escape; a lookup table maps the euro sign, ellipsis, curly quotes, en and em dashes and the trademark sign to their WinAnsi byte values; anything else is normalized NFKD with non-ASCII stripped, and if nothing survives it emits a question mark [16]. The code comment reads "Last resort: strip diacritics, else '?'. Never emit a broken byte." [17]
For the 300KB comparison to transfer to your app, your documents have to look like these: one page, text and stroked rules, an optional raster logo, one link annotation, Latin script [6][9][15]. The post does not state the size of its own generator, and it does not test what jsPDF or pdf-lib would do with a Cyrillic customer name [20]. Given that population, I would take the 130 lines. The day a name arrives in Japanese, the invoice prints a question mark [15][16].
What to watch
- Whether a customer needing Cyrillic or CJK forces a CID font path, which pulls real subsetting and encoding logic into the 130 lines.
- Whether multi-page invoices arrive, since each added page grows the page tree, the object count and the offsets table the engine hand-writes.
- Whether MonkeeTools publishes a byte count for buildPDF(), which would make the comparison against 300KB of library checkable.