Generating real PDFs in the browser with pdf-lib — and why Japanese turned into tofu
I run a free invoice generator. The server never sees your document — the PDF is built entirely in the browser with pdf-lib. That keeps my fixed cost near zero and means I store nothing. But "render a PDF on the client"
I run a free invoice generator. The server never sees your document — the PDF is built entirely in the browser with pdf-lib. That keeps my fixed cost near zero and means I store nothing. But "render a PDF on the client" hides a pile of sharp edges, and the sharpest one is text: the moment a user typed 請求書 instead of "Invoice," my beautiful PDF filled up with tofu — □□□.
Here's what actually goes wrong and how to fix each part.
The baseline: pdf-lib can't draw what it can't embed
A fresh PDFDocument only has the 14 standard PDF fonts (Helvetica, Times, Courier…). Those are Latin-1. Draw a Japanese, Chinese, Korean, Greek or even a fancy-quotes string with the built-in Helvetica and you get boxes or an outright WinAnsi cannot encode error.
The fix is to embed a real font with a wide glyph set, which needs fontkit:
import { PDFDocument, rgb } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
const pdf = await PDFDocument.create();
pdf.registerFontkit(fontkit);
const bytes = await fetch('/fonts/noto-sans-jp.ttf').then(r => r.arrayBuffer());
const font = await pdf.embedFont(bytes);
const page = pdf.addPage();
page.drawText('請求書 / Invoice', { x: 50, y: 760, size: 20, font });
Pass font to every drawText. Miss one call and that one line silently falls back to Helvetica and tofus out.
Gotcha 1: the full CJK font is huge — subset it
A complete Noto Sans JP is ~5 MB. Shipping that to every visitor to draw a 10-line invoice is absurd. Two levers:
- Embed a subset. pdf-lib embeds the whole font by default; turn on subsetting so only the glyphs you actually drew get written into the PDF:
const font = await pdf.embedFont(bytes, { subset: true });
That alone can take a 5 MB font down to tens of KB in the output file.
-
Don't ship the 5 MB download either. I keep a subset
.ttfbuilt for the character ranges invoices use (kana, common kanji, Latin, digits, punctuation) and load that. Tools likefonttools/pyftsubsetorsubset-fontbuild it in a step. The browser downloads ~hundreds of KB once and caches it.
Gotcha 2: keep the font same-origin
My first version loaded the font from a CDN. It worked on my machine and broke on other people's networks with CORS and ArrayBuffer errors that only showed up intermittently. fetch(...).arrayBuffer() is subject to CORS, and a font CDN that doesn't send the right Access-Control-Allow-Origin will hand you an opaque response that explodes when pdf-lib tries to parse it.
Serve the font from your own origin (/fonts/...). You control the headers, there's no preflight surprise, and it caches with the rest of your assets. One afternoon lost to this; don't repeat it.
Gotcha 3: PDF y-coordinates are upside down
pdf-lib's origin is the bottom-left of the page, y grows upward — the opposite of the DOM. If you port layout math from HTML thinking in "top down," everything lands mirrored. I keep one helper and never think about it again:
const PAGE_H = 842; // A4 height in pt
const top = y => PAGE_H - y; // convert "distance from top" to pdf-lib y
page.drawText('Bill to:', { x: 50, y: top(120), size: 11, font });
A4 is 595×842 pt, US Letter is 612×792. Which one you want depends on the user's region — which is a whole other rabbit hole (my invoices switch paper size by locale), but at minimum don't hard-code A4 for a US user: their printer will complain.
Gotcha 4: measuring text for right-alignment and wrapping
Numbers on an invoice must be right-aligned, and addresses wrap. pdf-lib gives you the metrics to do both without guessing:
function drawRight(page, text, rightX, y, size, font) {
const w = font.widthOfTextAtSize(text, size);
page.drawText(text, { x: rightX - w, y, size, font });
}
// naive word-wrap to a max width
function wrap(text, font, size, maxW) {
const words = text.split(' ');
const lines = [];
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
if (font.widthOfTextAtSize(test, size) > maxW && line) {
lines.push(line); line = word;
} else {
line = test;
}
}
if (line) lines.push(line);
return lines;
}
widthOfTextAtSize uses the embedded font's real metrics, so the measurement matches what gets drawn — including for CJK glyphs, which are not monospace and not the same width as Latin.
Why bother instead of a server or a print-to-PDF?
-
window.print()to PDF gives you whatever the browser and the user's margins decide. You can't guarantee the layout, headers repeat unpredictably, and you inherit the print dialog. For a document, that's not good enough. - A server renderer (Puppeteer, a PDF API) means I'd have to receive the invoice data — names, amounts, bank details — which is exactly the liability a free throwaway tool shouldn't take on, plus a running cost per render.
Drawing it with pdf-lib keeps the data on the client, the bill flat, and the output byte-for-byte identical for every user. The cost is that you're doing typesetting by hand — but it's a few helpers (top(), drawRight(), wrap()), not a framework.
If you want to see the result, the generator is here: free-bizdocs.com/en/invoice — make one in English or Japanese, hit download, and open the PDF: no tofu, no server.
What's the worst client-side PDF bug you've hit? I'm still chasing subtle line-height differences across viewers.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.