DEV Community

Peter Hallander
Peter Hallander

Posted on

pdf-lib is silently deleting characters from your users' data

I generate invoices in a Node serverless function with pdf-lib. It is a good library. It also quietly destroyed a client's company name, threw nothing, logged nothing, and produced a PDF that opened perfectly.

The name was Łódź Sp. z o.o.

The invoice said ód Sp. z o.o.

Here is what is going on, because it will hit anyone generating documents from user-supplied text.

The standard 14 fonts are Latin-1 only

PDF has fourteen fonts every reader is guaranteed to have, so you can use them without embedding anything. pdf-lib exposes them as StandardFonts:

import { PDFDocument, StandardFonts } from 'pdf-lib'

const doc = await PDFDocument.create()
const font = await doc.embedFont(StandardFonts.Helvetica)
Enter fullscreen mode Exit fullscreen mode

That is the default path in every tutorial, and it is fine until it is not. Those fonts are encoded as WinAnsi (roughly Windows-1252). Their glyph set covers Western European Latin and nothing else.

Ł and ź are not in it. I checked the boundary rather than guessing, and it is narrower than people assume:

Character Standard font
£ draws fine
ř (Czech) throws
ı (Turkish) throws
Москва (Cyrillic) throws
株式会社 (CJK) throws

So the euro sign and curly quotes are safe, which is the part most people worry about. Names are not, which is the part that matters.

The part that makes it dangerous

Try to draw a character outside the set and pdf-lib throws:

Error: WinAnsi cannot encode "Ł" (0x0141)
Enter fullscreen mode Exit fullscreen mode

Which is correct and helpful. So people do the obvious thing to stop the crash:

// don't do this
const safe = (s) => s.replace(/[^\x20-\xFF]/g, '')
page.drawText(safe(customerName), { x, y, size, font })
Enter fullscreen mode Exit fullscreen mode

I did exactly this. It stops the exception, the code goes green, and every test passes. What it actually does is delete parts of your users' data from a document they are about to send to their own customer.

There is no error to notice. The PDF is valid. Nothing in your logs suggests anything happened. The only way to find out is to render a page with non-Latin-1 text in it and look at it with your eyes.

The fix: embed a real font

Embed a TrueType or OpenType face and the problem disappears, because you are no longer restricted to a 1990s encoding table.

import fontkit from '@pdf-lib/fontkit'
import { readFile } from 'fs/promises'
import { PDFDocument } from 'pdf-lib'

const doc = await PDFDocument.create()
doc.registerFontkit(fontkit)   // required, and easy to forget

const bytes = await readFile('assets/fonts/Archivo-Regular.ttf')
const font = await doc.embedFont(bytes, { subset: true })

page.drawText('Łódź Sp. z o.o.', { x: 40, y: 700, size: 12, font })
Enter fullscreen mode Exit fullscreen mode

Three things worth knowing:

registerFontkit is mandatory. Without it embedFont on a byte array throws a message about fontkit that does not obviously connect to your problem.

subset: true matters more than you think. A full Archivo weight is about 180 KB. Embedding three weights unsubsetted adds half a megabyte to every single document. With subsetting, my four-page invoice with three weights comes out at 35 KB, because only the glyphs actually used get embedded.

Cache the file reads. In a serverless function, readFile on every invocation is wasted latency. Read once into a module-level variable and reuse it across warm invocations:

let cache = null

async function loadFonts() {
  if (cache) return cache
  const dir = path.join(process.cwd(), 'assets', 'fonts')
  const [regular, semi, black] = await Promise.all([
    readFile(path.join(dir, 'Archivo-Regular.ttf')),
    readFile(path.join(dir, 'Archivo-SemiBold.ttf')),
    readFile(path.join(dir, 'Archivo-Black.ttf')),
  ])
  cache = { regular, semi, black }
  return cache
}
Enter fullscreen mode Exit fullscreen mode

Also check the licence before you vendor a font. Archivo is SIL OFL, which permits embedding; plenty of commercial faces do not.

An embedded font still is not every glyph

Embedding solves the encoding problem, not the coverage problem. Archivo has no CJK. If a user types a Japanese company name, widthOfTextAtSize throws on that character and you are back where you started, just further along.

So keep a fallback, but make it visible rather than silent:

function drawable(font, s) {
  let out = ''
  for (const ch of s) {
    try {
      font.widthOfTextAtSize(ch, 10)
      out += ch
    } catch {
      out += ' '          // a gap you can see, not a deletion you cannot
    }
  }
  return out
}
Enter fullscreen mode Exit fullscreen mode

A space is not a great outcome. It is a much better outcome than a name silently closing up, because a human proofreading the document has a chance of spotting it.

Two more things only rendering will tell you

While I had a rasteriser pointed at the output, two layout bugs turned up that no amount of reading the code would have found.

A six-figure total ran backwards over its own label. The totals block right-aligned the figure at 20pt Black in a column sized for four digits. At $2,160.00 it was fine. At 86,832.81 PLN it overlapped the words TOTAL DUE sitting to its left. Text does not wrap or complain when it is drawn at an absolute coordinate; it just draws.

A long company name ran off the right edge of the page. Same cause. drawText has no concept of a container, so anything longer than you imagined simply continues past the paper.

Both are obvious in a rendered image and invisible in a diff. If you generate PDFs, put a rasteriser in your test loop:

# PyMuPDF, no system dependencies
python3 -c "
import pymupdf
pymupdf.open('out.pdf')[0].get_pixmap(dpi=120).save('out.png')"
Enter fullscreen mode Exit fullscreen mode

Then actually open the PNG. It takes ten seconds and it is the only test that catches this class of bug.

The short version

  • The standard 14 fonts are WinAnsi. They cannot draw most of the world's names.
  • Stripping the characters to stop the exception silently corrupts user data in a document they will send to a third party.
  • Embed a subsetted TrueType face with @pdf-lib/fontkit, cache the bytes, and check the licence.
  • Keep a fallback that leaves a visible gap rather than a clean deletion.
  • Render your output to an image in tests and look at it. Absolute-positioned text overlaps and overflows without any error.

I found all of this building HourToBill, where the invoice is the entire product, so a mangled client name is not a cosmetic bug. If you generate documents from anything a user typed, it is worth spending twenty minutes checking what yours does with Łódź.

Top comments (0)