cd /news/developer-tools/pdf-lib-is-silently-deleting-charact… · home topics developer-tools article
[ARTICLE · art-115619] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer discovered that the pdf-lib library silently deletes characters from user-supplied text when using its standard fonts, which are limited to WinAnsi encoding. The issue caused a client's company name 'Łódź Sp. z o.o.' to be rendered as 'ód Sp. z o.o.' without any error. The developer recommends embedding TrueType or OpenType fonts with subsetting to preserve all characters and avoid data loss.

read5 min views1 publishedAug 30, 2026

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.

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)

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.

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

Error: WinAnsi cannot encode "Ł" (0x0141)

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 })

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.

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 })

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
}

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

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
}

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.

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:

python3 -c "
import pymupdf
pymupdf.open('out.pdf')[0].get_pixmap(dpi=120).save('out.png')"

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

@pdf-lib/fontkit

, cache the bytes, and check the licence.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ź

.

── more in #developer-tools 4 stories · sorted by recency
── more on @pdf-lib 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/pdf-lib-is-silently-…] indexed:0 read:5min 2026-08-30 ·