{"slug": "pdf-lib-is-silently-deleting-characters-from-your-users-data", "title": "pdf-lib is silently deleting characters from your users' data", "summary": "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.", "body_md": "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.\n\nThe name was `Łódź Sp. z o.o.`\n\nThe invoice said `ód Sp. z o.o.`\n\nHere is what is going on, because it will hit anyone generating documents from user-supplied text.\n\nPDF has fourteen fonts every reader is guaranteed to have, so you can use them without embedding anything. pdf-lib exposes them as `StandardFonts`\n\n:\n\n``` js\nimport { PDFDocument, StandardFonts } from 'pdf-lib'\n\nconst doc = await PDFDocument.create()\nconst font = await doc.embedFont(StandardFonts.Helvetica)\n```\n\nThat 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.\n\n`Ł`\n\nand `ź`\n\nare not in it. I checked the boundary rather than guessing, and it is narrower than people assume:\n\n| Character | Standard font |\n|---|---|\n`€` `£` `’` `–` `—`\n|\ndraws fine |\n`ř` (Czech) |\nthrows |\n`ı` (Turkish) |\nthrows |\n`Москва` (Cyrillic) |\nthrows |\n`株式会社` (CJK) |\nthrows |\n\nSo 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.\n\nTry to draw a character outside the set and pdf-lib throws:\n\n```\nError: WinAnsi cannot encode \"Ł\" (0x0141)\n```\n\nWhich is correct and helpful. So people do the obvious thing to stop the crash:\n\n``` js\n// don't do this\nconst safe = (s) => s.replace(/[^\\x20-\\xFF]/g, '')\npage.drawText(safe(customerName), { x, y, size, font })\n```\n\nI 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.\n\nThere 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.\n\nEmbed a TrueType or OpenType face and the problem disappears, because you are no longer restricted to a 1990s encoding table.\n\n``` python\nimport fontkit from '@pdf-lib/fontkit'\nimport { readFile } from 'fs/promises'\nimport { PDFDocument } from 'pdf-lib'\n\nconst doc = await PDFDocument.create()\ndoc.registerFontkit(fontkit)   // required, and easy to forget\n\nconst bytes = await readFile('assets/fonts/Archivo-Regular.ttf')\nconst font = await doc.embedFont(bytes, { subset: true })\n\npage.drawText('Łódź Sp. z o.o.', { x: 40, y: 700, size: 12, font })\n```\n\nThree things worth knowing:\n\n** registerFontkit is mandatory.** Without it\n\n`embedFont`\n\non 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.\n\n**Cache the file reads.** In a serverless function, `readFile`\n\non every invocation is wasted latency. Read once into a module-level variable and reuse it across warm invocations:\n\n``` js\nlet cache = null\n\nasync function loadFonts() {\n  if (cache) return cache\n  const dir = path.join(process.cwd(), 'assets', 'fonts')\n  const [regular, semi, black] = await Promise.all([\n    readFile(path.join(dir, 'Archivo-Regular.ttf')),\n    readFile(path.join(dir, 'Archivo-SemiBold.ttf')),\n    readFile(path.join(dir, 'Archivo-Black.ttf')),\n  ])\n  cache = { regular, semi, black }\n  return cache\n}\n```\n\nAlso check the licence before you vendor a font. Archivo is SIL OFL, which permits embedding; plenty of commercial faces do not.\n\nEmbedding solves the encoding problem, not the coverage problem. Archivo has no CJK. If a user types a Japanese company name, `widthOfTextAtSize`\n\nthrows on that character and you are back where you started, just further along.\n\nSo keep a fallback, but make it visible rather than silent:\n\n``` js\nfunction drawable(font, s) {\n  let out = ''\n  for (const ch of s) {\n    try {\n      font.widthOfTextAtSize(ch, 10)\n      out += ch\n    } catch {\n      out += ' '          // a gap you can see, not a deletion you cannot\n    }\n  }\n  return out\n}\n```\n\nA 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.\n\nWhile I had a rasteriser pointed at the output, two layout bugs turned up that no amount of reading the code would have found.\n\n**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`\n\nit was fine. At `86,832.81 PLN`\n\nit overlapped the words `TOTAL DUE`\n\nsitting to its left. Text does not wrap or complain when it is drawn at an absolute coordinate; it just draws.\n\n**A long company name ran off the right edge of the page.** Same cause. `drawText`\n\nhas no concept of a container, so anything longer than you imagined simply continues past the paper.\n\nBoth are obvious in a rendered image and invisible in a diff. If you generate PDFs, put a rasteriser in your test loop:\n\n``` python\n# PyMuPDF, no system dependencies\npython3 -c \"\nimport pymupdf\npymupdf.open('out.pdf')[0].get_pixmap(dpi=120).save('out.png')\"\n```\n\nThen actually open the PNG. It takes ten seconds and it is the only test that catches this class of bug.\n\n`@pdf-lib/fontkit`\n\n, 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ź`\n\n.", "url": "https://wpnews.pro/news/pdf-lib-is-silently-deleting-characters-from-your-users-data", "canonical_source": "https://dev.to/stackedboost/pdf-lib-is-silently-deleting-characters-from-your-users-data-51ld", "published_at": "2026-08-30 08:26:50+00:00", "updated_at": "2026-08-30 08:52:30.326692+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["pdf-lib", "WinAnsi", "Archivo", "SIL OFL"], "alternates": {"html": "https://wpnews.pro/news/pdf-lib-is-silently-deleting-characters-from-your-users-data", "markdown": "https://wpnews.pro/news/pdf-lib-is-silently-deleting-characters-from-your-users-data.md", "text": "https://wpnews.pro/news/pdf-lib-is-silently-deleting-characters-from-your-users-data.txt", "jsonld": "https://wpnews.pro/news/pdf-lib-is-silently-deleting-characters-from-your-users-data.jsonld"}}