DEV Community

invitebatch
invitebatch

Posted on

Generating hundreds of personalized PDFs in the browser with pdf-lib

We run InviteBatch, a small site where you pick an invitation design, paste your guest list, and download one invitation per guest with that guest's name on it. A wedding can mean 150 guests. A school event can mean 400.

The backend is a single Cloudflare Worker on the free plan. That plan gives you about 10 ms of CPU per request. Rendering even one PDF page with embedded fonts and a high-resolution background would blow through that. So the rule from day one was: the server never renders a PDF. The customer's browser does.

This post walks through how that works, the bugs it caused, and the bits of code that ended up mattering.

What does the browser actually build?

Three outputs from the same data:

  1. One combined PDF: one 5×7 inch card per page, for print shops.
  2. Print sheets: two cards per landscape Letter or A4 page, with crop marks, for home printers.
  3. A ZIP: one PDF per guest, named Invitation - Jane Smith.pdf, for emailing or sharing.

All three use pdf-lib plus @pdf-lib/fontkit, and JSZip for the archive.

Why rasterize the background but keep the text as vectors?

A card is two layers:

  • The background (artwork, borders, photo, flourishes). It's the same for every guest.
  • The text (guest name, couple names, date, venue). Only the guest name changes.

So we draw the background once to a <canvas>, export it as a JPEG, and embed it once per document. pdf-lib lets you reuse an embedded image on as many pages as you want, so a 300-page PDF holds the image bytes once, not 300 times.

const base = await doc.embedJpg(await renderBaseJpeg(layout, design, assets, 1500));

for (let i = 0; i < guests.length; i++) {
  const page = doc.addPage([360, 504]); // 5x7 in at 72 pt/in
  page.drawImage(base, { x: 0, y: 0, width: 360, height: 504 });
  drawText(page, layoutCard(layout, design, guests[i], measure));
  if (i % 25 === 0) await tick(); // let the UI breathe
}
Enter fullscreen mode Exit fullscreen mode

The text stays real vector text with embedded, subset fonts. That keeps names sharp at any print size, and the file stays selectable and searchable.

For the ZIP we drop the background to a lower resolution (1200 px wide instead of 1500). Each guest's PDF has its own copy of the image, so that saving gets multiplied by the number of guests.

How do you stop the tab from freezing?

pdf-lib is synchronous for most drawing calls. A tight loop over 400 guests locks the main thread and the progress bar never moves. The fix is boring:

export const tick = () => new Promise((r) => setTimeout(r, 0));
Enter fullscreen mode Exit fullscreen mode

Await it every 25 pages for the combined PDF, and after every file for the ZIP. That's enough for the browser to repaint the progress bar and stay responsive. A Web Worker with OffscreenCanvas is the "proper" answer, but yielding was simpler and good enough here.

Why did "Łukasz" come out as boxes?

We self-host Google Fonts via @fontsource, which splits each font into Unicode subsets: latin, latin-ext, and so on. Embedding only the latin file works until a guest list contains a Polish or Czech name: the missing glyphs come out as empty boxes.

You can't merge font files in pdf-lib, so we embed both subsets and split each string into runs by which file actually contains the glyph:

export function textRuns(pair, text) {
  const runs = [];
  for (const ch of text) {
    const cp = ch.codePointAt(0) ?? 0;
    const prev = runs[runs.length - 1];
    const font =
      cp <= 32 || pair.latinChars.has(cp)
        ? (cp <= 32 && prev ? prev.font : pair.latin) // spaces stick to the current run
        : pair.ext;
    if (prev && prev.font === font) prev.text += ch;
    else runs.push({ text: ch, font });
  }
  return runs;
}
Enter fullscreen mode Exit fullscreen mode

latinChars comes from font.getCharacterSet() on the embedded latin font. We then draw the runs left to right, adding each run's width. Centered text measures the total width of all runs first. "Søren Łukasz" now renders as two runs from two font files, and nobody can tell.

How do long names fit on the card?

"Al" and "Dr. and Mrs. Bartholomew Featherstonehaugh-Smythe" share the same text box. Each field in a layout has a size, a box width, an optional maxLines, and a minSize. We shrink by 6% until it fits:

for (;;) {
  lines = maxLines === 1 ? [text] : wrap(measure, text, font, size, f.w);
  const widest = Math.max(...lines.map((l) => measure(l, font, size)));
  if ((lines.length <= maxLines && widest <= f.w) || size <= minSize) break;
  size = Math.max(minSize, size * 0.94);
}
Enter fullscreen mode Exit fullscreen mode

The important part is that measure is injected. The on-screen preview passes a canvas measureText. The PDF export passes pdf-lib's widthOfTextAtSize. The same layout function decides line breaks for both. So what you see in the editor is what prints. Canvas and pdf-lib measure text slightly differently, so keeping the layout logic in one place matters more than which measurer you trust.

What about duplicate guest names in the ZIP?

Two guests called "The Smith Family" would overwrite each other in the archive. We normalize the file name (strip accents and characters that break on Windows), then count:

let name = `Invitation - ${safeFileName(label)}`;
const n = (names.get(name) ?? 0) + 1;
names.set(name, n);
if (n > 1) name += ` (${n})`;
Enter fullscreen mode Exit fullscreen mode

We also zip with compression: 'STORE'. PDFs with JPEG images barely compress, and skipping DEFLATE saves CPU time for almost no size cost.

What's left on the server?

Very little. The Worker serves the editor, stores the design JSON and guest list in D1, handles Stripe checkout, and serves fonts and backgrounds as static assets. Rendering costs it nothing, so the free plan has held up so far.

If you want to see the output side of this, the mail merge for wedding invitations page shows the guest-list-to-PDF flow end to end.

What are the takeaways if you build something similar?

  • Plan for font subsets up front. If you self-host split fonts, every text drawing path needs to handle more than one font file.
  • Inject text measurement. One layout function with a pluggable measure keeps the preview and the PDF in agreement.
  • Yield early. Add the tick() before someone exports 300 guests on a mid-range Android phone.

Happy to answer questions about pdf-lib, fontkit subsetting or running a product on the Workers free plan in the comments.

Top comments (0)