Guides · debugging
Your PDF form field shows 王 where somebody typed 王鑫 — CJK text, font subsets, and the glyph that was never embedded
Published 2026-08-17 · measured on the day of writing with pdf-lib 1.17.1, @pdf-lib/fontkit 1.1.1, WeasyPrint 69.0, Chromium 151.0.7922.34 via Playwright 1.62.1, and poppler 26.06.0. Every command is in the article so you can repeat it.
Here is the shape of the bug, from a real document produced by a well-behaved tool:
// the value you wrote what the reader sees
// 姓名 张伟 -> 张伟 fine
// 備註 繁體中文 -> 繁體中 文 is gone
// お名前 山田太郎 -> 太郎 山田 is gone
// 이름 김민준 -> (empty) all three gone
// The LABEL to the left of each box renders perfectly.
// Same page. Same font. Same document.
Nothing errored. The file opens. The Chinese heading at the top of the page is crisp. It is only inside the boxes that characters go missing — and not all of them, which is what makes people spend an afternoon looking in the wrong place.
The reason is structural, and once you can see it the whole family of symptoms collapses into one sentence: the text inside a form field is not page content, and the font rules that govern page content do not reach it.
Where a form field's text actually comes from
A filled AcroForm field is drawn by an appearance stream — a little content
stream attached to the field's widget as /AP /N. That stream picks its font by
name, and the name is resolved through two places: the field's /DA (default
appearance) string, and the AcroForm's /DR /Font resource dictionary. So the
question "which font will this value be drawn in?" is answered by
/DA → /DR /Font, and by nothing else. Not by your CSS, not by
the @font-face you shipped, not by what the surrounding page text happens to use.
Two consequences follow immediately, and between them they cause most of what you will read below:
- If
/DAnames one of the standard 14 fonts — Helvetica and its cousins — the field cannot hold CJK. Those fonts carry a Latin-1 encoding and roughly 200 glyphs. There is no Chinese in them to find. - If
/DAnames an embedded subset, the field can only display the characters that were already in the document when the subset was cut. Which is a problem, because the entire purpose of a fillable field is that somebody types something that was not there before.
Cause 1 — the field's font is Helvetica, and pdf-lib says so at the wrong moment
If you build the form with pdf-lib, the first CJK value produces a message people paste into search engines verbatim:
const f = form.createTextField('name');
f.addToPage(page, { x: 40, y: 120, width: 300, height: 28 });
f.setText('张伟'); // returns cleanly. no warning.
const bytes = await doc.save(); // <- Error: WinAnsi cannot encode "张" (0x5f20)
// at Encoding.encodeUnicodeCodePoint (…/standard-fonts/lib/Encoding.js:23:23)
// at StandardFontEmbedder.encodeTextAsGlyphs
// at PDFTextField.updateWidgetAppearance
// at PDFForm.updateFieldAppearances <- runs during save(), not during setText()
Read the stack. The exception comes out of updateFieldAppearances, which runs
during save() — not during the setText() that caused it. On a form with
forty fields built across three modules, the trace points at your save call and tells you
nothing about which value was the problem. Worth knowing before you go bisecting.
Two dodges that look promising and are not:
setText() save()
f.setText('张伟') ok THREW WinAnsi cannot encode "张"
f.setText('张伟') + /NeedAppearances = true ok THREW WinAnsi cannot encode "张"
f.setText('Zhang Wei') ok ok, 1556 bytes
// Writing /V through the low-level dictionary instead of setText() does NOT
// throw — but it does not render either: the field comes out blank, because
// nothing marked it dirty and no appearance was ever generated. Measured in
// the pdf-lib article linked below.
/V by hand throws as well. Re-run in isolation, it does not — it saves, and produces a blank field, because pdf-lib only regenerates appearances for fields marked dirty and the low-level write marks nothing. The original probe had called setText() first. The pdf-lib article has the corrected measurement, including what save({ updateFieldAppearances: false }) does and why it is not the fix it looks like.NeedAppearances is the interesting failure of the two. It is a flag on the
AcroForm meaning "viewer, please generate the appearance streams yourself" — which sounds
exactly like the escape hatch you want, since Acrobat has CJK font packs and your producer
does not. It does not help here because pdf-lib draws the appearance anyway. It also would
not have saved you: see cause 2, where a tool that does set it still ends up with
blank fields.
The fix is to embed a font that has the glyphs and hand it to the field explicitly —
doc.registerFontkit(fontkit), doc.embedFont(bytes),
field.updateAppearances(font). Which brings us to the part that costs money.
Cause 2 — the font is embedded, but only the glyphs the page already used
This is the one that produced the table at the top, and it comes from a tool doing
everything right. WeasyPrint 69.0 with --pdf-forms is, as far as we have measured,
the most capable open-source route from HTML to a genuinely fillable PDF — it emits text
fields, checkboxes, radio groups, multi-selects and pushbuttons
(we counted them). Give it a
Chinese, Japanese and Korean form and the AcroForm it writes looks like this:
$ python3 - wp-cjk.pdf # WeasyPrint 69.0, --pdf-forms, six CJK fields
AcroForm keys /Fields /DR /NeedAppearances (no /DA of its own)
NeedAppearances true <- the viewer is told to draw the values itself
widget /AP ABSENT <- and no appearance was baked in for it to fall back on
every field's /DA (/a1.0 gs 0 0 0 rg /EIATWD 10.5 Tf)
^^^^^^ the font the value will be drawn in
$ pdffonts wp-cjk.pdf
name type encoding emb sub
EIATWD+PingFang-SC CID TrueType Identity-H yes yes <- sub: YES
MQZJZV+PingFang-SC-… CID Type 0C Identity-H yes yes
BPNVUT+Nanum-Gothic CID TrueType Identity-H yes yes
Every piece of that is defensible on its own. The widget has no baked appearance, so
NeedAppearances is set and the viewer is asked to draw the values — the correct
choice, and the one that lets a viewer with better fonts do better. The font is embedded
rather than merely referenced. It is subsetted, as it should be, because the alternative is
shipping a megabyte for a paragraph of text.
But the values in those fields were never drawn as page text, so their glyphs were never
added to the subset. When a viewer takes up the NeedAppearances invitation, it
looks up /EIATWD, finds a font, and finds no glyph:
$ pdftoppm -r 100 -png wp-cjk.pdf out
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+6587 文
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+5C71 山
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+7530 田
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+AE40 김
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+BBFC 민
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+C900 준
On paper, the result is the table this article opened with: 繁體中文 renders as 繁體中, 山田太郎 renders as 太郎, 김민준 renders as nothing at all — and 张伟 renders perfectly, because those two glyphs happened to appear in page text elsewhere in the document. The label prints and the value under it does not. That inconsistency is the tell: if the missing characters were a font-installation problem, the labels would be gone too.
--pdf-forms changes nothing about subsetting. Re-measured against a controlled variable — one document, one named font file, the flag as the only difference — that is wrong: the same HTML gives an 8,913-byte PDF holding a 302,076-byte subset without the flag, and a 6,441,931-byte PDF holding the font file whole and checksum-identical with it. WeasyPrint has exempted fonts used in form fields from subsetting since 58.0 (used_in_forms in pdf/fonts.py). Our earlier reading came from pdffonts, whose "sub" column says yes either way. The blanks in the table above are real, but their cause is per-glyph font fallback — which works on page text and cannot work inside a field — and the coverage of the font file itself. Both are measured in the WeasyPrint page. WeasyPrint is not broken here; it is ahead of where we described it.Cause 3 — the glyph is genuinely absent, and nothing anywhere says so
Suppose you do everything above correctly: full font, embedded unsubsetted, handed to the field. There is still one failure left, and it is the quietest of all.
// 鑫 (U+946B) is a perfectly ordinary character in Chinese given names.
// It is not in the font we embedded. Nothing tells us so:
f.setText('王鑫');
f.updateAppearances(font);
await doc.save(); // no throw. no warning. a valid PDF.
$ pdftotext out.pdf - -> 王鑫 the text layer is complete
$ pdftoppm out.pdf page -> 王 the page is not
There is no error path for "this font has no glyph for that character". The producer
writes a valid PDF, your test suite extracts the text and finds
王鑫 exactly as expected, and the human holding the printout
reads a name that is missing a character. On this topic extracted text is not
evidence — the text layer and the pixels disagree by construction, because the
Unicode value is stored in /V and the glyph is looked up separately.
If you assert on anything, assert on a raster. pdftoppm -r 100 -gray and a
non-white pixel count over the field's rectangle takes a few lines and catches this; nothing
cheaper does.
Cause 4 — the second tool in the pipeline loses the font the first one embedded
A pattern worth its own heading, because it looks impossible when you hit it: the file demonstrably contains a CID-keyed CJK font, and filling a field still throws WinAnsi.
// The file already contains a CID-keyed CJK font. Open it and fill the field:
const doc = await PDFDocument.load(fs.readFileSync('form-with-cjk-font.pdf'));
const f = doc.getForm().getTextField('name');
f.setText('李娜');
await doc.save();
// -> Error: WinAnsi cannot encode "李" (0x674e)
// The fix, and its price:
doc.registerFontkit(fontkit);
const font = await doc.embedFont(FONT, { subset: false }); // a SECOND copy
f.setText('李娜');
f.updateAppearances(font);
// input 806,806 bytes
// output 1,611,773 bytes <- the font is now in the file twice
pdf-lib does not resolve /DR /Font back into a usable default when it loads a
document, so the field falls back to Helvetica no matter what is sitting in the file. The
fix — re-embed and pass the font — works, and embeds a second copy: 806 KB in, 1.6 MB out.
pdf-lib does not deduplicate against the font already there. A form that passes through a
three-stage fill pipeline carries three copies of the same face.
If you own the pipeline, the cheap answer is to fill once, at the end, from the original font bytes, rather than reopening and rewriting at each stage.
So how big does this have to be?
Here is the honest trade, measured on a one-field form:
// one text field, Noto Sans SC (4,535 glyphs, a 1,204,384-byte .ttf)
const font = await doc.embedFont(FONT, { subset: /* ← the whole argument */ });
initial value output
subset: true 张伟 2,848 bytes
subset: false 张伟 806,806 bytes 283x
subset: true (empty field) "" 2,324 bytes
subset: false (empty field) "" 806,788 bytes
// subset: true is right for page text and wrong for a field somebody will type into.
// The empty-field rows are the point: you are paying 800 KB for glyphs that are
// not in the document yet, because that is exactly what a fillable field is for.
Note the empty-field rows. Eight hundred kilobytes buys you nothing that is in the document — it buys the ability to render something that is not there yet. That is uncomfortable and it is also just what a fillable CJK form costs, unless you can rely on every reader's viewer having its own CJK fonts, which for anything sent to the public you cannot.
Between "subset to nothing" and "ship the whole 20,000-glyph face" there is a middle path: embed a curated subset chosen by coverage target rather than by what the document happens to contain. GB2312 level 1 is 3,755 hanzi; the full GB2312 is 6,763; Unicode's CJK Unified Ideographs block is 20,992 before you add Traditional-only forms, kana and 11,172 Hangul syllables. Pick deliberately, then measure what you picked:
import fontkit from 'fontkit';
const f = fontkit.openSync('your-font.ttf');
let n = 0;
for (let cp = 0x4e00; cp <= 0x9fff; cp++) if (f.hasGlyphForCodePoint(cp)) n++;
console.log('CJK Unified Ideographs:', n, '/', 0x9fff - 0x4e00 + 1);
// and the characters you actually care about, by name:
for (const c of [...'鑫淼昊婷怡繁體臺灣한국어'])
if (!f.hasGlyphForCodePoint(c.codePointAt(0))) console.log('missing:', c);
Our own font, measured — because we are not clean here either
snapdok.io embeds a curated Noto Sans SC subset into the fields of any page we detect as containing CJK, unsubsetted, so a reader can type characters the document never held. That is the right architecture and it does not save us from the paragraph above. We ran the coverage script on our own file:
The single face snapdok.io used to embed (superseded 2026-08-17, see below)
-----------------------------------------------------------------
CJK Unified Ideographs U+4E00–U+9FFF 3,755 / 20,992 (17.9%)
Hangul syllables U+AC00–U+D7A3 0 / 11,172 (none)
Kana U+3040–U+30FF 170 / 192
3,755 is not a round number by accident: it is exactly GB2312 level 1,
the first tier of a standard published in 1980.
spot checks covered missing
common-usage sample 109/109 —
top-100 Chinese surnames 98/100 闫 覃
given-name characters in use 7/28 鑫 淼 焱 昊 婷 萱 怡 宸 曦 煜 玥 …
Traditional (zh-TW/HK) sample 8/33 體 臺 灣 國 語 學 習 電 腦 …
Japanese jōyō sample 21/33 語 漢 東 阪 電 話 請 見 積 様
Korean 0/27 all of it
So a Chinese name containing 鑫 — an entirely normal character, and there are a lot of people called it — comes out of our renderer with a hole in it, silently, for exactly the reason described in cause 3. Traditional Chinese is largely unusable. Korean is not there at all. That is a limitation of our current build, it is being looked at, and we would rather you read it here than discover it in a customer's document. If your users are in Taiwan or Hong Kong or Korea, this is a reason to do the embedding yourself today.
What Chromium does, in one paragraph
Nothing, and that is worth stating so you do not go looking. Chromium's print-to-PDF
emits zero AcroForm fields — measured again for this article on the same
CJK form, 0 fields in 113,986 bytes — so with Puppeteer or Playwright the question of a
field's font never arises. You get a picture of a form. Chromium's handling of CJK
page text is fine and needs nothing from you.
What replaces
--enable-forms covers the ways out of that, none of which are Chromium
options.
A checklist for the document in front of you
| Check | Command | What a bad answer looks like |
|---|---|---|
| Which font will the value use? | read the field's /DA | a standard-14 name (Helv, Arial) — CJK cannot render, full stop |
| Is that font embedded? | pdffonts f.pdf | emb = no — you are relying on the reader's machine |
| Is it a subset? | pdffonts f.pdf | sub = yes on the font named by /DA |
| Does it have the glyphs? | the fontkit script above | any character your users can type coming back missing |
| Does it actually draw? | pdftoppm -gray + count ink | ink count near zero over a field that has a value |
| Are you fooling yourself? | pdftotext | correct text out of a document whose pixels are wrong — never trust this alone |
If you would rather not own this
Owning it is genuinely feasible: a font file, subset: false, and a coverage test
in CI. The recurring costs are the file size and remembering not to reopen-and-refill.
If handing the render to a service suits you better, snapdok.io is one
option among several. What we do here is detect CJK on the page and embed a field font
unsubsetted, so typed characters survive, and report what we placed and skipped in
X-Form-Fields and X-Form-Skipped headers rather than failing quietly.
Updated 2026-08-17: the single 3,755-hanzi face measured above is no longer
what we ship. Four faces are now on the box — Simplified 6,763 hanzi, Traditional 13,061,
Japanese 6,356 plus kana, Korean 4,619 plus all 11,172 Hangul syllables — and the one chosen
per document is whichever can draw the most of the characters actually present, read from the
values and the page rather than from a lang attribute. So
鑫 and Hangul now render. The limit that replaced the old one: a
document carries at most two of those faces, so a page mixing three scripts loses one and the
response header says which. WeasyPrint will give you password and
multi-select field types we do not emit, and several commercial engines — Nutrient,
PDFCrowd, DocRaptor, IronPDF among them — expose font control we do not. If CJK form fields
are the centre of your product rather than a feature of it, do the embedding yourself and
keep the control.
The short version
Text inside a form field is drawn from /DA → /DR /Font, which your page
CSS never touches. If that font is a standard-14 face, CJK cannot render at all. If it is an
embedded subset, only the characters already drawn as page text can render — which
is why the label prints and the value under it does not. If the font is complete but missing
one glyph, nothing errors anywhere and pdftotext still returns the right string, so
only a raster will tell you. Embedding unsubsetted is the fix and it costs about 800 KB;
reopening the file to fill it later loses the font and re-embedding doubles the size. Pick a
coverage target on purpose, and measure the font you chose rather than trusting its
name.
Related, from the same line of work:
the pdf-lib side in detail — where
the WinAnsi cannot encode throw comes from, why
subset: true produces blank fields that pdftotext still reads
correctly, and what re-embedding costs per pipeline stage;
the WeasyPrint side, where
--pdf-forms turns out to embed field fonts whole and byte-identical, so the two
causes left are per-glyph fallback and the coverage of the file you supplied;
what replaces
wkhtmltopdf --enable-forms, where we put one form through every engine and
counted the fields, and
why outline: true produces
no bookmarks — which is also where CJK bookmark titles and the /Lang tag that
declares your Chinese document to be American English are measured.
Sources and versions, all read
or run 2026-08-17:
ISO
32000-1 (PDF 1.7), clause 12.7 "Interactive Forms", which defines the interactive form
dictionary (/DR, NeedAppearances) and variable text (/DA) ·
pdf-lib's
EmbedFontOptions.subset, measured at 1.17.1 with
@pdf-lib/fontkit 1.1.1 ·
WeasyPrint's
API reference for pdf_forms / --pdf-forms, measured at 69.0 ·
Noto Sans
SC (SIL Open Font License), the face we embed ·
poppler 26.06.0 for
pdffonts, pdftotext and pdftoppm. Every measurement
above is ours, taken on 2026-08-17, and repeatable with the commands shown.