DEV Community

Free BizDoc
Free BizDoc

Posted on

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" 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 });
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 .ttf built for the character ranges invoices use (kana, common kanji, Latin, digits, punctuation) and load that. Tools like fonttools/pyftsubset or subset-font build 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 });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)