General
I hand-rolled a PDF engine in vanilla JS so our invoice app needs zero dependencies
Monkee Tools DEV Community 周榜
4 views
This is a build-in-public deep dive from MonkeeTools, a free invoice generator that runs entirely in your browser. No account, no server, no tracking. This post is about the weirdest part of that promise: the PDF engine, written by hand, with no libraries.
Why would anyone do this
Most "download as PDF" buttons in web apps work one of two ways. Either the browser's print-to-PDF does the job (inconsistent, ugly, zero control over layout), or a library like jsPDF or pdf-lib builds the document. Those libraries are fine. They are also 300KB+ of dependency surface for what is, at its core, a pretty simple file format.
We had a harder constraint than most: our entire product thesis is "nothing leaves your browser." No backend, no npm install, no build step. The site is static HTML, CSS, and vanilla JS on a static host. Pulling in a PDF library would not violate the privacy story, but it would violate the spirit: a single self-contained page you could save and run offline. So I wrote the PDF generator by hand. Every byte.
It is about 130 lines of actual PDF assembly, plus helpers. Let me show you how it works.
A PDF is just numbered objects and a phone book
Strip away the mystique and a PDF is: a header, a list of numbered objects, a cross-reference table (the "phone book" that says object N lives at byte offset X), and a trailer that points at the phone book. That is the whole format, at least for the simple documents we generate.
Our buildPDF() function assembles the file in three phases. First, small helper closures turn invoice data into PDF content-stream commands:
const cmds = [];
const txt = (x, y, size, str, bold = false, color = '0.09 0.14 0.17') =>
cmds.push(`BT /F${bold ? 2 : 1} ${size} Tf ${color} rg ${x} ${y} Td (${pdfStr(str)}) Tj ET`);
const line = (x1, y1, x2, y2, w = .5, color = '.82 .85 .83') =>
cmds.push(`${color} RG ${w} w ${x1} ${y1} m ${x2} ${y2} l S`);
txt() emits a text-drawing operation (begin text, select font, set color, move, show string, end text). line() strokes a rule. The template renderers push hundreds of these into cmds, and the content stream becomes one PDF object.
Second, the objects themselves: catalog, page tree, page, content stream, two fonts, font descriptors, font files, and annotations. Each is serialized with its byte offset recorded:
const N = 14 + (logoImg ? 1 : 0) + (payURL ? 1 : 0);
The object count is computed dynamically because the logo image and the Pay now link annotation are optional. Get this number wrong and the file is corrupt, so it is derived, never hardcoded.
Third, the xref table and trailer, built from the recorded offsets:
let trailer = 'xref\n0 ' + N + '\n0000000000 65535 f \n';
for (let i = 1; i < N; i++)
trailer += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
trailer += 'trailer\n<< /Size ' + N + ' /Root 1 0 R >>\nstartxref\n' + xref + '\n%%EOF';
That is the entire file format. Header, objects, phone book, done. The first time a real PDF reader opened the output without complaining, I celebrated more than I should admit.
Fonts: subsetted Inter with WinAnsiEncoding
Text in a PDF needs a font, and we wanted our brand font (Inter) to render identically everywhere, not fall back to Helvetica on systems that lack it. So we embed two subsetted Inter files, regular and bold, as FontFile2 streams, with a hand-written font descriptor each:
<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular
/FirstChar 32 /LastChar 255 /Widths 8 0 R
/Encoding /WinAnsiEncoding /FontDescriptor 9 0 R >>
WinAnsiEncoding is the honest tradeoff at the heart of this engine. It covers Latin-1 plus a slice of typographic characters, which handles every Western European language, but it cannot do emoji, CJK, Arabic, or Cyrillic. Rather than fail silently, our pdfStr() escaper handles the boundary explicitly:
const WINANSI = {'€':128,'…':133,'‘':145,'’':146,'“':147,'”':148,'–':150,'—':151,'™':153 /* ... */};
function pdfStr(s) {
let out = '';
for (const ch of String(s || '')) {
const c = ch.codePointAt(0);
if (c >= 32 && c < 127) {
out += (ch === '\\' || ch === '(' || ch === ')') ? '\\' + ch : ch;
} else if (c < 256) {
out += '\\' + c.toString(8).padStart(3, '0');
} else {
const w = WINANSI[ch];
if (w != null) out += '\\' + w.toString(8).padStart(3, '0');
else {
// Last resort: strip diacritics, else '?'. Never emit a broken byte.
const d = ch.normalize('NFKD').replace(/[^\x20-\x7E]/g, '');
out += d || '?';
}
}
}
return out;
}
ASCII passes through (with PDF string escapes for backslash and parens), Latin-1 becomes octal escapes, known typographic characters map through the WINANSI table, and anything else gets NFKD-normalized so "é" degrades to "e" rather than corrupting the file. A "?" is ugly, but a corrupt PDF is worse. This is the kind of decision a library hides from you; hand-rolling forces you to make it consciously.
Clickable links need geometry, not just text
A PDF link is not part of the text. It is an annotation: a rectangle on the page plus an action. The critical detail is that the rectangle must match the rendered text, or users click empty space. Our footer tagline is a link back to the site, and its annotation rect is measured against the actual laid-out text:
<< /Type /Annot /Subtype /Link /Rect [160 27 435 41]
/Border [0 0 0]
/A << /S /URI /URI (https://monkeetools.surge.sh/) >> >>
The Pay now button is fancier: the button is drawn as a filled rectangle with text, and the annotation rect is computed from the button geometry with a small padding margin, so the whole button is clickable:
const payBtn = (pbx, pby, fill, fg) => {
const pbw = 104, pbh = 24;
cmds.push(`${fill} rg ${pbx} ${pby} ${pbw} ${pbh} re f`);
txt(pbx + 22, pby + 7, 11, 'Pay now', true, fg);
return `<< /Type /Annot /Subtype /Link ` +
`/Rect [${pbx - 2} ${pby - 2} ${pbx + pbw + 2} ${pby + pbh + 2}] ` +
`/Border [0 0 0] /A << /S /URI /URI (${pdfStr(payURL)}) >> >>`;
};
One annotation object per link, referenced in the page's /Annots array. The object count math from earlier has to account for these, which is why N is computed, not constant.
Three templates, one core
The invoice comes in three designs (Classic, Minimal, Modern), but there is exactly one buildPDF(). The shared core handles data, fonts, xref, annotations, and the logo; the templates are just three renderer functions that push different drawing commands into the same cmds array:
const payAnnot = tpl === 'minimal' ? drawMinimal()
: tpl === 'modern' ? drawModern()
: drawClassic();
This separation fell out naturally from hand-rolling: when you own the content stream, "a different template" just means "different commands in the array." No template engine, no layout framework. The preview on screen and the PDF share the same data model, so what you see is what the bytes say.
Logos: from file picker to DCTDecode stream
Users can upload a logo, which travels a fun path: FileReader.readAsDataURL() gives us a data URL, we decode the base64 back to bytes, and if it is a JPEG those bytes go straight into the PDF as an image XObject with DCTDecode (JPEG) compression, no re-encoding, no quality loss:
<< /Type /XObject /Subtype /Image /Width 480 /Height 120
/ColorSpace /DeviceRGB /BitsPerComponent 8
/Filter /DCTDecode /Length 24576 >>
stream
...raw JPEG bytes...
endstream
It is drawn with a transformation matrix that scales it to fit the header box while preserving aspect ratio. The neat part: since JPEG is already the PDF's native compressed image format, embedding is essentially zero-copy. The bytes the user uploaded are the bytes in the PDF.
The honest tradeoffs
Build-in-public means showing the rough edges, so here are ours:
Verified on Poppler only. Our claim is that the PDFs render correctly, and what we have actually verified is Poppler (the open-source renderer). Acrobat, macOS Preview, and mobile readers are untested from our Linux environment. The PDFs use only baseline PDF 1.4 features, so breakage is unlikely, but "unlikely" is not "verified," and we say so on the site.
WinAnsi, not Unicode. As shown above, anything outside Latin-1 plus our typographic table degrades. For an invoicing tool aimed at freelancers in Western markets this is a fine trade today, but full Unicode (ToUnicode CMaps, embedded CID fonts) is the obvious future work if we ever need it.
Single page, fixed layout. We cap invoices at 12 line items, which fits one A4 page at our layout metrics. Multi-page support means page-breaking logic we have not needed yet. The cap is enforced in the UI, so the engine never has to handle overflow.
No text reflow. Long descriptions do not wrap; they are clipped by layout. Again, a UI-level constraint (field lengths) keeps the engine simple.
Every one of these is a conscious scope decision, not an accident. The engine does exactly what the product needs and nothing more.
Why not just use a library
Honestly? For most projects, use pdf-lib. It is excellent. We hand-rolled because our constraints were unusual: zero dependencies, zero build step, a single HTML file that works offline, and a privacy story that is easier to tell when there is literally no third-party code. The engine is ~130 lines of assembly plus helpers, and every behavior in it is one we chose deliberately.
There is also a less rational reason: writing a PDF by hand teaches you what a PDF actually is, and that understanding paid for itself ten times over in debugging. When a user reports a rendering quirk, I do not file an issue against a library. I open the bytes.
If you want to see the output, the generator is free and needs no account: monkeetools.surge.sh. Make an invoice, download the PDF, and know that every byte of it was placed there on purpose.
MonkeeTools is a free invoice generator that remembers you without an account. Everything runs in your browser, including the PDF engine described here. Built in the open, one changelog entry at a time.
Read original: https://dev.to/monkeetools/i-hand-rolled-a-pdf-engine-in-vanilla-js-so-our-invoice-app-needs-zero-dependencies-3mii
← Previous
I built a chat app that forgets 🔥
Next →
Hands-On with Amazon S3: Buckets, Permissions, Lifecycle Rules and Static Hosting published: true tags: aws, devops, s3 cover
Related
Semantic tag vs Non-Semantic tag
General
1
Dev.to (EN Zone)
How LinkedIn "Bold" Text Actually Works (It's Not Bold At All)
General
1
Dev.to (EN Zone)
Google ADK Callbacks Are a Policy Plane, Not Just Hooks
General
4
DEV Community 周榜
How I Modelled My Power BI Data — Data Modelling, Relationships & Joins (Kenya Crops Dataset)
General
2
DEV Community 周榜
Comments0
No comments yet — be the first