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. I generate invoices in a Node serverless function with pdf-lib https://pdf-lib.js.org/ . 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 : js 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: js // 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. python 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: js 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: js 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: python PyMuPDF, no system dependencies 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 https://hourtobill.com , 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ź .