Web Development Bug Report

Invoice PDFs showed garbage for accented customer names: jsPDF standard fonts and embedding TTF

Invoices generated in the browser with jsPDF printed Polish, Vietnamese and Japanese names as random symbols. The 14 standard PDF fonts only cover a Latin-1-style character set. How we embedded a Unicode TTF font, subset it to keep files small, and tested names from every market.

Hannah Okafor Hannah Okafor @hannahokafor · June 23, 2026 · 7 min read · 28 Views
Banner: invoice PDFs showed garbage for accented customer names, jsPDF standard fonts and embedding TTF

Our B2B portal generates invoices in the browser with jsPDF, so customers can download them instantly without a server round trip. A Polish customer sent a screenshot: their company name, "Gdańsk Łukasz Logistyka Sp. z o.o.", appeared on the invoice with odd symbols where ń and Ł should be. A Vietnamese customer's contact name, written with letters from the Latin Extended Additional block, was worse, with several letters replaced by unrelated characters.

The HTML invoice preview on the same page displayed both names correctly.

Reproducing

import { jsPDF } from 'jspdf';

const doc = new jsPDF();
doc.setFont('helvetica');
doc.text('Müller GmbH', 20, 20);         // fine
doc.text('Gdańsk Łukasz', 20, 30);       // ń and Ł broken
doc.text('Nguy\u1ec5n V\u0103n B\u00ecnh', 20, 40);   // Vietnamese name: several characters broken
doc.text('東京 Trading', 20, 50);          // unreadable
doc.save('test.pdf');

German umlauts rendered correctly; Polish, Vietnamese and Japanese did not.

What we ruled out

  • The data. The API returned correct UTF-8; the HTML preview proved it.
  • String encoding in JavaScript. JavaScript strings are UTF-16; 'Ł'.codePointAt(0) was 321, as expected.
  • The PDF viewer. The same file looked broken in three different viewers.

Root cause

We used jsPDF's built-in Helvetica, one of the 14 standard PDF fonts. jsPDF writes text in those fonts with a single-byte encoding covering Western European characters (similar to Windows-1252). Characters outside that set, such as Ł, ń, Vietnamese letters like U+1EC5 and Japanese ideographs, have no glyph and no code in that encoding, so they come out as wrong characters or nothing. Supporting them requires embedding a font that contains the glyphs and writing text with it.

Standard fonts versus embedded fonts

Font kind Embedded in PDF? Character coverage File size impact
Standard 14 (Helvetica, Times, Courier, Symbol, ZapfDingbats) No, viewer supplies them Latin-1-like set only None
Embedded TrueType font Yes Whatever the font contains Font size, unless subset

The standard fonts are convenient precisely because they are not embedded, which also means the PDF can only rely on the characters every viewer's built-in version has.

How jsPDF handles custom fonts

jsPDF can embed TrueType fonts. You add the font file's data to its virtual file system, register it, and select it. Text in that font is written with Unicode-aware encoding (Identity-H with a ToUnicode map), so any character present in the font renders, and copy-paste from the PDF produces the correct text.

Who is affected

Any application generating PDFs with jsPDF's built-in fonts from user-provided text: names, addresses, product descriptions, comments. Libraries with the same design, such as other client-side PDF generators that default to the standard 14 fonts, have the same limitation. Testing only with English and German data hides the problem until the first customer from Central Europe or Asia.

Environment

Component Version / value
Library jsPDF 3.x, in a React app
Font before Built-in helvetica
Customers EU, Vietnam, Japan
Invoices 1-3 pages, generated client-side

Step 1: choose a font with the coverage you need

We used Noto Sans for Latin, Latin Extended and Vietnamese, and Noto Sans JP for Japanese names. Both are licensed under the SIL Open Font License, which allows embedding.

Step 2: embed the font

The font file is fetched once and cached, converted to Base64 and registered:

import { jsPDF } from 'jspdf';

let fontCache;

async function loadFont(url) {
  const buf = await (await fetch(url)).arrayBuffer();
  let binary = '';
  const bytes = new Uint8Array(buf);
  for (let i = 0; i < bytes.length; i += 0x8000) {
    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
  }
  return btoa(binary);
}

export async function createInvoicePdf(invoice) {
  fontCache ??= {
    regular: await loadFont('/fonts/NotoSans-Regular-subset.ttf'),
    bold: await loadFont('/fonts/NotoSans-Bold-subset.ttf'),
  };

  const doc = new jsPDF();
  doc.addFileToVFS('NotoSans-Regular.ttf', fontCache.regular);
  doc.addFont('NotoSans-Regular.ttf', 'NotoSans', 'normal');
  doc.addFileToVFS('NotoSans-Bold.ttf', fontCache.bold);
  doc.addFont('NotoSans-Bold.ttf', 'NotoSans', 'bold');

  doc.setFont('NotoSans', 'bold');
  doc.text(invoice.customerName, 20, 30);
  doc.setFont('NotoSans', 'normal');
  doc.text(invoice.addressLines, 20, 38);
  // ...
  return doc;
}

The chunked conversion avoids "Maximum call stack size exceeded" when spreading a large byte array into String.fromCharCode at once.

Step 3: keep the files small with subsetting

Noto Sans Regular is several hundred kilobytes, and Noto Sans JP is several megabytes. jsPDF embeds the whole font file. We created subsets at build time with pyftsubset from fontTools:

pyftsubset NotoSans-Regular.ttf \
  --unicodes="U+0000-024F,U+0300-036F,U+1E00-1EFF,U+20AC,U+2013-2014,U+2018-201D" \
  --layout-features='*' --output-file=NotoSans-Regular-subset.ttf

That covers Basic Latin, Latin-1, Latin Extended-A and B, combining marks, Latin Extended Additional (where Vietnamese letters live), the euro sign and typographic quotes and dashes. The subset was about 80 KB. For Japanese, a full-coverage subset is still large, so the app loads the JP font only when the invoice actually contains CJK characters:

const needsCjk = /[぀-ヿ㐀-鿿]/.test(JSON.stringify(invoice));

Step 4: font fallback per text run

jsPDF does not fall back between fonts automatically. For mixed strings like "東京 Trading", we split text into runs by script and switch fonts per run. A small helper measures each run with getTextWidth and draws them one after another.

Alternatives and trade-offs

  • Generate PDFs on the server with a renderer that handles fonts and fallback (for example a headless browser printing HTML). Better typography and fallback for free, at the cost of a server round trip and infrastructure.
  • Render text to an image on a canvas and place it in the PDF. Looks right, but text cannot be selected, searched or read by screen readers.

How to test the fix

A test generates an invoice with names from every market and extracts the text again with pdf.js to check it round-trips:

const names = ['Müller GmbH', 'Gdańsk Łukasz Sp. z o.o.', 'Nguy\u1ec5n V\u0103n B\u00ecnh', 'Ørsted A/S', '東京トレーディング'];
const doc = await createInvoicePdf({ ...fixture, customerName: names.join(' / ') });
const pdf = await pdfjs.getDocument({ data: doc.output('arraybuffer') }).promise;
const text = (await (await pdf.getPage(1)).getTextContent()).items.map(i => i.str).join('');
for (const n of names) expect(text).toContain(n);

Confirming in production

Invoices for Polish, Vietnamese and Japanese customers rendered names correctly, and copying text from the PDF produced the right characters. Typical invoice size grew from 18 KB to about 100 KB with the Latin subset, and to about 1.1 MB for invoices that needed the Japanese font.

Regression guard

  • The round-trip test above runs in CI with a list of names covering every script we have customers in.
  • A check fails the build if the subset font is missing any character that appears in the test names.

Security implications

Fonts are parsed by PDF viewers, so only embed fonts from trusted sources with a clear license, and serve them from your own origin with the usual integrity controls. Invoice data stays in the browser, which avoids sending personal data to a server-side PDF service.

Prevention checklist

  • Do not use the standard PDF fonts for user-provided names and addresses.
  • Embed a Unicode TrueType font that covers your customers' scripts.
  • Subset fonts to keep PDF size reasonable, and load large CJK fonts only when needed.
  • Handle mixed scripts with per-run font switching or server-side rendering.
  • Test with real names from every market, including round-trip text extraction.

References

Add a label

#javascript #unicode #jspdf #pdf #fonts
Written by
Hannah Okafor @hannahokafor

I am a frontend engineer who has worked on dashboards, booking flows and design systems, mostly in React and TypeScript. I care about the parts of the web platform that frameworks hide: forms, cookies, rendering cost and what the browser actually does with our code. The bugs I write about usually passed every review and only appeared with production data: a map with a million points, a name with an accent in a PDF, a cookie that one subdomain never sees. Each post shows a minimal reproduction, why the browser or framework behaves that way, and a fix with a test.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.