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.
// 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
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:
| field | value set | in the text layer | on 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.
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)
/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
| Check | How | A bad answer looks like |
|---|---|---|
| Is a real font embedded? | pdffonts out.pdf | only Helvetica / ZapfDingbats, or emb = no |
| Is it subsetted? | pdffonts out.pdf | sub = yes — for a fillable field this is the bug in §3 |
| Is it in there twice? | pdffonts out.pdf | the same face listed at two object IDs — you are paying twice |
| Does it actually draw? | pdftoppm -r 100 -gray, count ink | ink over a filled field equal to ink over an empty one |
| Does the font have the glyphs? | fontkit hasGlyphForCodePoint | any character a user can type coming back false |
| Are you fooling yourself? | pdftotext | correct 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.