Guides · debugging

WinAnsi cannot encode "张" (0x5f20) — why pdf-lib throws inside save(), and why the smallest fix silently blanks the field

Published 2026-08-17 · every number below measured on the day of writing with pdf-lib 1.17.1, @pdf-lib/fontkit 1.1.1, Node 24.14.0 and poppler 26.06.0 on Apple Silicon. The scripts are short enough to be in the article; run them.

You set a Chinese, Japanese or Korean value on a form field. The call returns. Some time later, in a function you did not write, this comes out:

const form = doc.getForm();
const f = form.createTextField('name');
f.addToPage(page, { x: 40, y: 120, width: 300, height: 28 });
f.setText('张伟');                  // returns cleanly. no warning. no hint.

const bytes = await doc.save();    // <- Error: WinAnsi cannot encode "张" (0x5f20)

Three things are worth establishing before you start changing code, because two of the three obvious fixes make the document worse in a way no test will catch.

1. The throw is nowhere near the value that caused it

Read the stack from the bottom:

Error: WinAnsi cannot encode "张" (0x5f20)
    at Encoding.encodeUnicodeCodePoint      (@pdf-lib/standard-fonts/lib/Encoding.js:23:23)
    at StandardFontEmbedder.encodeTextAsGlyphs
    at StandardFontEmbedder.encodeText
    at PDFFont.encodeText
    at layoutSinglelineText                 (pdf-lib/cjs/api/text/layout.js:197:24)
    at defaultTextFieldAppearanceProvider   (pdf-lib/cjs/api/form/appearances.js:260:31)
    at PDFTextField.updateWidgetAppearance
    at PDFTextField.updateAppearances
    at PDFTextField.defaultUpdateAppearances
    at PDFForm.updateFieldAppearances       (pdf-lib/cjs/api/form/PDFForm.js:542:23)
                                             ^ called by save(), for every dirty field

save() calls PDFForm.updateFieldAppearances(), which walks every field marked dirty and regenerates its appearance stream — the little content stream that actually paints the value inside the box. That is where the text gets encoded, and that is where a character with no place in the encoding raises. Your setText() returned twenty minutes and three modules ago.

Practical consequence: on a form with forty fields, the message names the character but never the field. We checked — four fields, one bad value, and the error is the same string with no field name in it. If you are bisecting, bisect on values, not on call sites.

It is not "non-ASCII". WinAnsi is Windows-1252 plus a handful of extras, so a surprising amount of non-ASCII text goes through untouched. What fails is everything outside it — which is all of CJK, and also emoji.
// pdf-lib 1.17.1, text field, no font embedded, value set with setText()

  'Zhang Wei'      ok      1550 bytes
  'José Müñoz'     ok      1555 bytes      <- é ü ñ are in WinAnsi
  'O’Brien'        ok      1551 bytes      <- U+2019 is in WinAnsi too
  '张伟'            THREW   WinAnsi cannot encode "张" (0x5f20)
  '山田'            THREW   WinAnsi cannot encode "山" (0x5c71)
  '김민준'           THREW   WinAnsi cannot encode "김" (0xae40)
  'hi 🙂'          THREW   WinAnsi cannot encode "" (0x1f642)
                                            ^ the quotes are empty: pdf-lib prints
                                              the char with a UTF-16 unit, so
                                              astral code points come out invisible

And the timing is not the same for every control. A dropdown raises during addToPage(), not at save, because that is where pdf-lib lays out the option list:

// Same error, three different moments. Only the first is delayed.

text field    f.setText('张伟')       ok  ->  throws inside doc.save()
dropdown      d.addOptions(['中文'])  ok
              d.select('中文')        ok
              d.addToPage(page, …)    THROWS HERE, before any save
page text     page.drawText('张伟')   THROWS HERE, immediately

2. The actual fix: embed a font, and hand it to the field

The field's font is not chosen by your CSS or by anything on the page. It is named in the field's /DA string and resolved through the AcroForm's /DR /Font dictionary, and by default pdf-lib puts Helvetica there — a standard-14 face with a Latin-1 encoding and roughly 200 glyphs. There is no Chinese in it to find. The full path from /DA to a rendered glyph is worth reading if you want the spec-level version; the short one is three calls:

import fontkit from '@pdf-lib/fontkit';

const doc = await PDFDocument.create();
doc.registerFontkit(fontkit);                                   // 1. required, once
const font = await doc.embedFont(fs.readFileSync('NotoSansSC.ttf'), {
  subset: false,                                                // 2. see below. it matters.
});

const f = doc.getForm().createTextField('name');
f.addToPage(page, { x: 40, y: 120, width: 300, height: 28 });
f.setText('张伟');
f.updateAppearances(font);                                      // 3. hand it the font

await doc.save();                                               // no throw. 806,802 bytes.

That works. It also costs 806,802 bytes for one field containing two characters, and the subset: false on line 5 is the reason. Which brings us to the part that will cost you an afternoon if nobody tells you.

3. subset: true is 60x smaller, passes every test, and prints empty boxes

This is the finding to take away from this page. If you read nothing else here: with subset: true, a field appearance drawn by pdf-lib 1.17.1 can come out blank, no exception is raised anywhere, and pdftotext returns your value in full. pdf-lib documents subset as a boolean and says nothing about form-field appearances either way; we would be glad to be pointed at an existing report or a fix.

Subsetting is the right default almost everywhere: you ship the glyphs the document uses instead of a multi-megabyte face. So it is the first thing anybody reaches for after seeing an 800 KB PDF. Here is the same ten-field form built twice, differing in that one boolean:

// Ten text fields, ten ordinary two-character Chinese names,
// one embedded font, appearances updated, differing in one boolean.

                          bytes      what pdftotext returns   what the paper shows
  subset: false         813,749      all 10 names            all 10 names
  subset: true           13,639      all 10 names            八 boxes empty,
                                                             two showing 1 character

// 60x smaller. Every text-based assertion passes. Nine of the ten people
// on that form do not have their name on it.

Rendered at 100 dpi, the subset: true document looks like this — ten filled fields, of which eight are empty and two show a single character out of two:

fieldvalue setin the text layeron the page
n0张伟张伟(empty)
n1李娜李娜(empty)
n2王芳王芳(empty)
n3刘洋刘洋
n4陈静陈静(empty)
n5杨磊杨磊(empty)
n6赵敏赵敏
n7周涛周涛(empty)
n8吴迪吴迪(empty)
n9徐亮徐亮(empty)

We tried to find the rule for which glyphs survive, and there is not an obvious one from the outside. Ink over a single field, one value at a time:

// subset: true, one field, one value, 24pt, ink measured with
// pdftoppm -r 100 -gray and a count of pixels darker than 200.
// An empty field of this geometry is 1057 px of ink (the box outline).

  value    ink    verdict          subset:false control
  国       1564   drew             1564   same
  A        1250   drew             —
  C        1218   drew             —
  张       1057   BLANK            1453   drew
  伟       1057   BLANK            —
  B        1057   BLANK            —

  国国      2071   drew both
  国张      1564   drew 国 only
  张伟      1057   BLANK           1840   drew both
  伟张      1057   BLANK
  AB       1250   drew A only      1509   drew both
  ABC      1250   drew A only
  B国      1057   BLANK            <- 国 renders alone, and not here

Note the last three rows. AB draws only the A, and B on its own draws nothing — so it is not "the first character wins", it is per-glyph. And renders on its own but B国 renders nothing at all, so one unrenderable glyph can take the rest of the string with it. We did not chase this into pdf-lib's subsetter; for the purposes of getting your document right, the rule you need is the blunt one: do not subset a font you are going to draw form-field appearances with.

Flattening does not save you. form.flatten() bakes the appearance streams into page content — but they are already the broken ones, so the flattened file is 13,500 bytes of permanently empty boxes with a complete text layer. We measured it because it is the natural next idea.

There is a wider version of this trap that outlives pdf-lib: even with a complete, unsubsetted font, a character your font simply does not contain is written with no error anywhere and no glyph on the page. Extracted text is not evidence on this topic — the same failure measured across WeasyPrint and pdf-lib goes through it in detail. If you assert on anything in CI, assert on a raster: pdftoppm -r 100 -gray and a count of non-white pixels over the field rectangle is a dozen lines and catches every version of this.

4. Two ways to make the exception go away without fixing anything

Both of these are real, both are documented or obvious, and both are worth knowing precisely so that you recognise them when a stack-overflow answer suggests one:

// Route 1 — tell save() not to redraw the appearances at all.
f.setText('张伟');
const bytes = await doc.save({ updateFieldAppearances: false });   // ok. 1,197 bytes.

// Route 2 — write /V yourself and never mark the field dirty.
f.acroField.dict.set(PDFName.of('V'), PDFHexString.fromText('张伟'));
await doc.save();                                                 // ok. 1,177 bytes.

// Both files round-trip: PDFDocument.load(bytes).getForm()
//   .getTextField('name').getText()  ->  '张伟'

// And both render like this in poppler 26.06.0:
//   ink over the field   1015 px   (an empty field of that geometry is 1015 px)
//   pdftotext            ""
//   stderr               Syntax Error: HorizontalTextLayouter, couldn't find
//                        a font for character U+5F20
//                        Syntax Error: … U+4F1F

Read the bottom half of that block. The exception is gone, the value is genuinely in the file, and getText() returns it — and the field is blank on the page, because /DA still names Helvetica and Helvetica still has no . You have converted a loud failure into a quiet one.

The mechanism is worth one line, because it explains both routes at once — pdf-lib only regenerates appearances for fields it considers dirty, and writing /V through the low-level dictionary does not mark anything dirty. Mark it yourself and the throw comes straight back:

f.acroField.dict.set(PDFName.of('V'), PDFHexString.fromText('张伟'));
form.markFieldAsDirty(f.acroField.ref);
await doc.save();     // -> Error: WinAnsi cannot encode "张" (0x5f20)
A correction to our own earlier note. While researching the hub article we recorded that setting /V by hand still throws. Re-run in isolation for this page, it does not: it saves, and it produces the blank field above. The earlier probe had called setText() first, which had already marked the field dirty. The corrected version is what you see here, and the hub has been amended.

When is this the right move? When you are deliberately handing the drawing job to the reader's viewer, and you have also put a font in /DA that can do the job — Acrobat and several other viewers carry CJK font packs and will honour NeedAppearances. That is a legitimate architecture. But it is a decision about who owns the fonts, not a way to silence an exception, and if you take it you can no longer verify the document you shipped. We measured the rendering above with poppler 26.06.0 only; we did not test Acrobat, and the honest summary of that is that after this change you do not know what your reader sees — which is exactly the property you were trying to avoid.

5. Which update call to use: it makes no difference

A question worth closing because both forms are in circulation. On a ten-field document with an embedded unsubsetted font, per-field and form-level appearance updates produce byte-identical output:

// Ten CJK fields, one embedded font, subset: false.

for (const f of fields) f.updateAppearances(font);   ->  813,749 bytes
form.updateFieldAppearances(font);                   ->  813,749 bytes

// Byte-identical output, identical rendering. Use whichever reads better.
// The form-level call also covers fields you forgot about, which is a
// reason to prefer it on a document you did not build yourself.

6. The pipeline tax: reopening loses the font, re-embedding doubles the file

This is the one that looks impossible when you hit it. The file demonstrably contains a CID-keyed CJK font. Load it, fill a second field, and WinAnsi is back:

stage 1   build the form, embed the font, fill 'a'          807,199 bytes

stage 2   PDFDocument.load(...)  →  getTextField('b').setText('李娜')
          -> Error: WinAnsi cannot encode "李" (0x674e)
          …even though the file it just loaded contains a CID-keyed CJK font.

stage 2   re-embed the same font, then fill 'b'           1,612,169 bytes   +804,970
stage 3   re-embed the same font, then refill 'a'         2,417,120 bytes   +804,951

$ pdffonts stage3.pdf
name                    type           encoding     emb sub uni object ID
NotoSansSC-Thin-7098    CID TrueType   Identity-H   yes no  yes     26  0
NotoSansSC-Thin-7098    CID TrueType   Identity-H   yes no  yes     19  0

pdf-lib does not resolve /DR /Font back into a usable default when it loads a document — a loaded field falls back to Helvetica regardless of what is sitting in the file. The fix is to re-embed and pass the font, and re-embedding writes a second copy: 807 KB in, 1.61 MB out, 2.42 MB after a third stage. It is not a reopening problem either; two embedFont calls with the same bytes in the same document do it too:

const doc = await PDFDocument.create();
doc.registerFontkit(fontkit);
await doc.embedFont(FONT, { subset: false });   // save() ->   805,898 bytes
await doc.embedFont(FONT, { subset: false });   // save() -> 1,610,494 bytes
                                               //             +804,596
// Same bytes, same options, same document. Two copies.
// Embed once and pass the handle around.

So the shape that stays small is: embed once, fill once, at the end. If your architecture has three services each adding a field, have them pass values around and let the last one render, rather than each reopening and rewriting the PDF. If you cannot restructure it, budget for the megabytes — there is no deduplication to lean on in 1.17.1.

7. What to check before you ship it

CheckHowA bad answer looks like
Is a real font embedded?pdffonts out.pdfonly Helvetica / ZapfDingbats, or emb = no
Is it subsetted?pdffonts out.pdfsub = yes — for a fillable field this is the bug in §3
Is it in there twice?pdffonts out.pdfthe same face listed at two object IDs — you are paying twice
Does it actually draw?pdftoppm -r 100 -gray, count inkink over a filled field equal to ink over an empty one
Does the font have the glyphs?fontkit hasGlyphForCodePointany character a user can type coming back false
Are you fooling yourself?pdftotextcorrect text out of a document whose pixels are empty — never trust this alone

The build-time half of that, as code:

import fontkit from 'fontkit';
const f = fontkit.openSync('your-font.ttf');

const missing = [...new Set(everyCharacterYourUsersCanType)]
  .filter((c) => !f.hasGlyphForCodePoint(c.codePointAt(0)));
if (missing.length) throw new Error('no glyph for: ' + missing.join(' '));

// This is a build-time check, not a runtime one: at runtime you do not know
// what somebody will type. Which is the whole problem with subsetting a form.

If you would rather not own this

Owning it is completely reasonable: one font file, subset: false, one updateAppearances call, and a raster assertion in CI. The recurring costs are file size and the discipline not to reopen-and-refill.

If handing the render to a service suits you better, snapdok.io is one of several options — Nutrient, PDFCrowd, DocRaptor, IronPDF and DocuSeal all produce fillable PDFs from HTML, and WeasyPrint does it in the open-source world. What the Snapdok API does on this specific problem is embed a CJK field font unsubsetted so typed characters survive, and — since 2026-08-17 — refuse to fail quietly about the case in §3: if a value contains a character the embedded font has no glyph for, the response carries X-Form-Unrenderable, X-Form-Chars and X-Form-Fields headers naming the characters and the fields, and the JSON report carries the same thing structurally. The document is still returned; you are simply told.

Where we are not clean: coverage is still a budget, not a promise. Four faces are on the box — Simplified 6,763 hanzi, Traditional 13,061, Japanese 6,356 plus kana, Korean 4,619 plus all 11,172 Hangul syllables — chosen per document by what the characters need rather than by a declared language, and a document carries at most two of them, so a page mixing three scripts loses one. The Simplified face stops at GB 2312, so a given-name character outside it is reported in the headers rather than drawn. That is better than silence and it is not the same as unlimited coverage. If CJK form fields are the centre of your product rather than a feature of it, do the embedding yourself with a font you chose, and keep the control.

The short version

WinAnsi cannot encode means the field's font is a standard-14 face, and it is raised during save() by the appearance generator, not by your setText() — so the message names the character and never the field. Embed a real font, register fontkit, and pass the font to updateAppearances. Do not reach for subset: true to get the size down: it produces blank fields with a complete text layer and no error. Do not reach for save({ updateFieldAppearances: false }) or a hand-written /V either, unless you are deliberately delegating drawing to the reader's viewer — they remove the exception and leave the box empty. Embed once, fill once, at the end; re-embedding doubles the file every time. And assert on pixels, because on this topic the text layer will lie to you.

Related, from the same line of work: the same failure across WeasyPrint, pdf-lib and Chromium, which is where the /DA/DR /Font path and the font-coverage numbers live, the same problem in WeasyPrint, where --pdf-forms turns out to embed field fonts whole and the blanks come from fallback instead, measuring font coverage, since subset: false only gets you what the file already had, and what replaces wkhtmltopdf --enable-forms, where one form goes through every engine and the AcroForm fields are counted.

Sources and versions, all read or run 2026-08-17: pdf-lib's SaveOptions, which is where the updateFieldAppearances flag on save() is declared · EmbedFontOptions.subset · PDFForm.updateFieldAppearances, all measured at pdf-lib 1.17.1 with @pdf-lib/fontkit 1.1.1 · ISO 32000-1 (PDF 1.7) clause 12.7, for the interactive form dictionary (/DR, NeedAppearances) and variable text (/DA) · Noto Sans SC (SIL Open Font License), the face used in every measurement above · poppler 26.06.0 for pdffonts, pdftotext and pdftoppm. Every measurement on this page is ours, taken 2026-08-17, and repeatable with the code shown.