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:

  1. If /DA names 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.
  2. If /DA names 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.
The tension worth naming out loud. Subsetting a font and making a form fillable pull in opposite directions. A subset contains the glyphs the producer already drew; a field exists so a reader can later supply a glyph the producer never drew. For Latin this is invisible — nobody subsets 96 printable ASCII characters. For CJK the face is 20,000+ glyphs and several megabytes, so everybody subsets, and the conflict becomes the default. This is not a bug in any one tool. It is two correct optimisations meeting.

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.
Corrected 2026-08-17. This block first said that writing /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.

Corrected 2026-08-17, later the same day. This section originally said that --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

CheckCommandWhat a bad answer looks like
Which font will the value use?read the field's /DAa standard-14 name (Helv, Arial) — CJK cannot render, full stop
Is that font embedded?pdffonts f.pdfemb = no — you are relying on the reader's machine
Is it a subset?pdffonts f.pdfsub = yes on the font named by /DA
Does it have the glyphs?the fontkit script aboveany character your users can type coming back missing
Does it actually draw?pdftoppm -gray + count inkink count near zero over a field that has a value
Are you fooling yourself?pdftotextcorrect 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.