Guides · debugging

WeasyPrint --pdf-forms with Chinese: the subsetting problem is already solved, and the two that are left are yours

Published 2026-08-17 · every number below measured that day with WeasyPrint 69.0, poppler 26.06.0 and fontkit on macOS 15, Apple Silicon. Version boundaries checked against the WeasyPrint source at the tags named. Run the commands; they are all here.

The story that brings people to this page goes: a form renders beautifully in the browser, weasyprint --pdf-forms turns it into a real AcroForm, and then the Chinese values inside the boxes are missing while every label around them prints. It is a genuinely confusing failure, because the same CSS font-family is feeding both.

The usual explanation is font subsetting, and the reasoning is sound: a subset holds the glyphs the producer already drew, and a form field exists precisely so somebody can later type a glyph nobody has drawn yet. Those two things are in direct conflict. For Latin you never notice — 96 printable ASCII glyphs is not a subset worth making. For CJK the face is 20,000+ glyphs and several megabytes, so everything subsets, and the conflict becomes the default.

WeasyPrint has handled that since version 58. We went looking for the fix and found it already in the box. So this page is mostly about what is actually still broken, which is two different things wearing the same symptom.

1. What --pdf-forms does to your fonts

Four fields, a named font file, and one flag as the single difference between two runs:

<style>
  @font-face { font-family: ProbeCJK; src: url(file:///tmp/notosc-400.ttf); }
  body  { font-family: ProbeCJK, sans-serif; font-size: 12pt; }
  input { font-family: ProbeCJK; font-size: 12pt; width: 80mm; height: 8mm; }
</style>

<label>Name       <input name="n1" value="张伟"></label>
<label>Notes      <input name="n2" value="繁體字"></label>
<label>Empty      <input name="n3" value=""></label>   <- the one that matters
<label>Latin      <input name="n4" value="Zhang Wei"></label>
$ weasyprint             form.html out.pdf      ->      8,913 bytes
$ weasyprint --pdf-forms form.html out.pdf      ->  6,441,931 bytes

// Same HTML. Same font. One flag. 723x.
// Pulling the embedded font program back out of each file:

                    FontFile2 bytes   glyphs   has 鑫 (U+946B)?
  no flag                 302,076    29,681   no
  --pdf-forms          10,595,948    31,036   yes

  md5 of that 10,595,948-byte stream   dab4180edd3360927a94dbb0f04df8a0
  md5 of /tmp/notosc-400.ttf on disk   dab4180edd3360927a94dbb0f04df8a0

// Not 'roughly the whole font'. The file, byte for byte.

Without the flag you get a 302 KB subset of the glyphs the page drew and nothing else. With it you get the font file you supplied, complete, checksum-identical, sitting inside the PDF. Nobody typed anywhere in that document — it is there because someone might.

And it is targeted, not a blanket. Give the page one family and the inputs another:

@font-face { font-family: PageFont;  src: url(NotoSansSC-400.ttf); }   /* body text */
@font-face { font-family: FieldFont; src: url(NotoSansJP-VF.ttf);  }   /* inputs only */

$ weasyprint --pdf-forms two.html out.pdf     ->  3,398,614 bytes

  FieldFont  (used by the <input>)     5,766,900 bytes   17,936 glyphs   complete
  PageFont   (used by the <p>)           281,312 bytes   subset of what the page drew

// The exemption is per font, not per document. The face the page uses is
// still subsetted; only the face a field names is kept whole.

2. The mechanism, in eleven lines of Python

Worth reading directly, because it tells you exactly what qualifies:

# weasyprint/pdf/fonts.py  (69.0, lines 307 and 322)

  if subset and not font.used_in_forms:
      for file_font in file_fonts:
          to_unicode = {**to_unicode, **file_font.to_unicode}
  font.clean(to_unicode, options['hinting'])

#   ^ to_unicode is the keep-list handed to the subsetter.
#     Left empty, nothing is dropped.

# weasyprint/pdf/anchors.py  (lines 209 and 258)

  font_description = get_font_description(style)
  font = pango.pango_font_map_load_font(font_map, context, font_description)
  font, _ = stream.add_font(font)
  font.used_in_forms = True                # <- set for <select>, and for
  field['DA'] = pydyf.String(...)          #    text / password / textarea

font.used_in_forms is set at the moment a widget's /DA string is written — the string that names the font the viewer will use to draw the value. Fonts that are only page furniture never get the flag and stay subsetted. The exemption follows the /DA, which is the correct place for it to follow.

Related but not the same: --full-fonts. That flag turns subsetting off for every font in the document, form or not. On our four-field test it took the bold face — used by one heading and no field — from 2,712 bytes to 10,595,948, doubling the PDF to 12,878,211 bytes. The two rasters were md5-identical: it bought nothing visible. For fillable CJK forms you do not need it; --pdf-forms already covers the fonts that matter.

Which version

$ curl -s .../Kozea/WeasyPrint/v57.2/weasyprint/pdf/fonts.py | grep -c used_in_forms
0
$ curl -s .../Kozea/WeasyPrint/v58.0/weasyprint/pdf/fonts.py | grep -c used_in_forms
2

// commit 8242662 'Don’t optimize fonts used in forms', 2023-01-17
// first release containing it: 58.0b1 (2023-02-03), then 58.0 (2023-02-17)
// If you are on 57.x or older, this page's good news does not apply to you.

3. The trap that is left: naming a system font

"Embed the file unmodified" is a promise about a file. If your CSS names an installed system family rather than a @font-face you control, the file in question is whatever the OS has, and on macOS that is a font collection:

// The identical document, with font-family: "PingFang SC" — a macOS system face —
// instead of a @font-face file. WeasyPrint 69.0, macOS 15.

                        PDF bytes      largest embedded font program
  no flag                 139,212      160,072   (an OpenType subset)
  --pdf-forms          63,146,807   78,222,888   (PingFang.ttc, all 24 faces of it)

// 453x. 'Embed the file unmodified' is doing exactly what it says: PingFang SC
// ships as a TrueType *collection* — every weight of two families in one file —
// and the whole collection is what gets embedded.

// It does work. Setting a value on the empty field afterwards and rasterising:
//   ink over the field, empty   320 px      (the box outline)
//   ink after typing 鑫         477 px      +157, no poppler errors
// You are simply paying 63 MB per copy of the form for it.

This is the single biggest surprise in the area, and it is worth being precise about the cause: it is not a CJK cost and not a bug. It is the interaction of an unsubsetted embed with a source file that happens to contain 24 faces you did not ask for — PingFang SC, TC, HK and MO, six weights each. Name a font file. With @font-face pointing at a 10.6 MB Noto Sans SC the same document is 6.4 MB, and if you pre-subset that file yourself to a coverage you picked on purpose it drops again — 1.2 MB of font gave us an 807 KB form. Which leads directly to the next section, because that is a trade with a sharp edge on it.

4. What still breaks, #1: a field has one font, and no fallback

This is the one that produces the classic "the label prints, the value does not", and subsetting is not what is happening. Browsers resolve fonts per glyph: if the family you asked for has no , the engine quietly reaches for another face that does, and you never find out. A form field cannot do that. Its appearance is drawn from one /DA string naming exactly one font.

@font-face { font-family: ProbeCJK; src: url(NotoSansSC-400.ttf); }   /* no Hangul */
body, input { font-family: ProbeCJK, sans-serif; }

<p>page text, Korean: 김민준</p>
<label><input name="k" value="김민준"></label>
<p>page text, Simplified: 张伟</p>
<label><input name="s" value="张伟"></label>
// weasyprint --pdf-forms fb.html i.pdf   ->  6,444,201 bytes
// Two font programs come out of that file:

  ProbeCJK / NotoSansSC   10,595,948 bytes   complete   <- the /DA font
  NanumGothic                 20,992 bytes   subset     <- picked by fallback,
                                                            for the page text only

// pdftoppm -r 100 -gray, dark pixels inside each region:

  page text  김민준     1,756 px      renders
  FIELD      김민준         0 px      blank
  page text  张伟       1,897 px      renders
  FIELD      张伟         291 px      renders

  stderr: couldn't find a font for character U+AE40   김
          couldn't find a font for character U+BBFC   민
          couldn't find a font for character U+C900   준

Read that carefully, because it is the whole trap in one document. The Korean page text renders — WeasyPrint fell back to NanumGothic and embedded a small subset of it for that line. The Korean field value is zero pixels. The field's /DA names ProbeCJK, ProbeCJK has no Hangul, and there is no second chance: in the source above, pango_font_map_load_font() returns one font, and one font is what the field gets. The fallback face is sitting in the same PDF, three objects away, unreachable.

So the rule is: every field must name a font that covers every script that field can receive. Not the page — the field. If one form takes Simplified, Traditional, Japanese and Korean, either one face covers all four or you assign faces per field and accept that a field can only ever hold what its own face holds.

5. What still breaks, #2: the coverage of the file you handed over

WeasyPrint gives you the file whole. It cannot give you more than the file has. We pointed the same document at a font we had subsetted ourselves, months earlier, for a completely reasonable reason — size:

// Same document, but @font-face points at a font that was subsetted
// before WeasyPrint ever saw it — 1,204,384 bytes, 4,535 glyphs, GB2312 level 1.

$ weasyprint --pdf-forms form.html h.pdf   ->  807,642 bytes

  embedded font md5   be7270d93b632885639fd618cee6657f
  the file on disk    be7270d93b632885639fd618cee6657f      <- whole, again

// WeasyPrint did its job perfectly. And:
//   prefilled 繁體字      -> 繁字      stderr: couldn't find a font for U+9AD4  體
//   typed later 鑫        -> 0 px ink  stderr: couldn't find a font for U+946B  鑫

// The conflict did not go away. It moved into your font file.

Both failures are silent in every way that matters. Nothing raises. The PDF is valid. pdftotext returns the full string for the prefilled field, because the text layer and the drawn appearance come from different places. Only the raster knows.

That font was ours. It is the face this site used to embed in fillable PDFs: GB2312 level 1, 3,755 hanzi, chosen for size. It is why we went looking at this in the first place, and section 8 says where we got to.

6. Your diagnostics will lie to you, in two different directions

pdftotext reads the text layer, which is populated even when nothing was drawn — that one is well known. The less known one is pdffonts:

$ pdffonts out.pdf        # the --pdf-forms file, font embedded in full
name                type           encoding     emb sub uni object ID
EMHIMV+ProbeCJK     CID TrueType   Identity-H   yes yes yes     18  0
                                                    ^^^
// 'sub' is inferred from the six-letter tag on the font name, and WeasyPrint
// writes that tag either way. The column says yes for a font that is whole.
// Measure the stream, or measure the ink. Do not read this column.

Both of these fail in the reassuring direction. The three checks that do not:

# 1. is the field font whole? compare the embedded stream to the file you shipped
python3 - <<'PY'
import re, sys, zlib
raw = open('out.pdf','rb').read()
for m in re.finditer(rb'/Length1 (\d+)', raw):
    print('embedded font program:', int(m.group(1)), 'bytes')
PY

# 2. does it draw? rasterise and count ink, twice: empty field vs filled field
pdftoppm -r 100 -gray -f 1 -l 1 out.pdf page
#    equal ink means the box is empty, whatever pdftotext says

# 3. what did the viewer give up on?
pdftoppm -r 100 out.pdf /dev/null 2>&1 | grep "couldn't find a font"
SymptomCauseFix
All CJK field values blank, WeasyPrint 57 or olderfonts used in fields are subsetted like any otherupgrade to 58+; nothing else needed
Some scripts render in fields, others blank, labels all finepage text got per-glyph fallback; the field's /DA font did notname a face that covers every script that field accepts
One character missing from an otherwise correct valuethe font file you supplied has no glyph for itcheck coverage at build time; widen the font or the coverage target
PDF is tens of megabytesa system font was named, so the whole collection was embedded@font-face with a file you control
Everything looks right and users still report blanksyou tested with pdftotextassert on ink, not on extracted text

7. Sizing it on purpose

Because the font ships inside every copy of the form, coverage is a budget rather than a preference. Rough shape of the decision, in the numbers we measured:

CoverageHanziFont fileOur test form
GB2312 level 13,7551.20 MB808 KB
GB2312 full6,7632.31 MB
Big5 (Traditional)13,0614.91 MB
Noto Sans SC, unmodified20,976 of 20,992 in URO10.60 MB6.44 MB
PingFang SC, system, on macOS78.22 MB collection63.15 MB

A note on the middle rows: those are our own build, and the point of showing them is that the answer is rarely "the whole font". A registration form for mainland customers that will never see Traditional or Hangul does not need 10.6 MB. Pick the standard your users actually live in, verify the characters you know about — surnames and given-name characters are where this bites — and keep the measurement in CI so the next person cannot quietly narrow it.

8. If you would rather not own this

Owning it is a reasonable choice and not much work: a @font-face pointing at a file you subsetted deliberately, WeasyPrint 58 or newer, and one raster assertion in CI. The recurring cost is that every copy of the form carries the font, and that somebody has to notice when a new market brings a new script.

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. What the Snapdok API does with the two problems above is pick the field font per document by looking at the characters, not at a lang attribute: 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 — and the one that can draw the most of what is actually there wins. When a character has no glyph anywhere, the response says so in X-Form-Unrenderable and X-Form-Chars headers instead of returning a quietly empty box.

The costs, plainly. A document carries at most two of those faces, so a page mixing three scripts loses one, and the response header tells you which — that is a real limit, not a rounding error. The Simplified face stops at GB 2312, so a rare given-name character outside it is reported rather than drawn. And a CJK document is measured in megabytes here exactly as it is anywhere else, for exactly the reason this page is about. If CJK form fields are the centre of your product rather than a feature of it, WeasyPrint plus a font you chose gives you control we do not expose, and this page is enough to do it with.

The short version

weasyprint --pdf-forms stops subsetting any font a form field names — since 58.0, February 2023 — and embeds that file byte for byte, so characters nobody has typed yet still render. If your CJK fields are blank, check three things in this order: your WeasyPrint version; whether the field's font covers the script, because a field gets one font and no fallback even when the page text around it silently used a second face; and whether the file you supplied was already subsetted before WeasyPrint saw it. Name a font file rather than a system family unless a 63 MB PDF is acceptable. And verify with pixels — on this topic pdftotext returns the right answer for a blank box, and pdffonts reports "subset: yes" for a font that is whole.

Related, from the same line of work: why the label prints and the value under it does not, which is the hub for this and covers Chromium and pdf-lib as well, and pdf-lib's WinAnsi cannot encode, where the same conflict shows up as a runtime exception and where subset: true blanks fields without raising anything, and how to measure the coverage of the file you supply, which is the variable this page leaves you holding — what each tier costs, and the build-time gate that catches a missing glyph before a PDF exists.

Sources and versions, all read or run 2026-08-17: WeasyPrint's API reference for the command-line options quoted · weasyprint/pdf/fonts.py and anchors.py, where used_in_forms is set and read · the release list, for dating 58.0 · ISO 32000-1 (PDF 1.7) clause 12.7.3.3 for variable text and the /DA/DR /Font resolution · Noto Sans SC (SIL Open Font License), the face in every measurement above · poppler 26.06.0 for pdffonts and pdftoppm. Measurements are ours, taken 2026-08-17 on WeasyPrint 69.0, and repeatable with the files shown.