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.
--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.
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"
| Symptom | Cause | Fix |
|---|---|---|
| All CJK field values blank, WeasyPrint 57 or older | fonts used in fields are subsetted like any other | upgrade to 58+; nothing else needed |
| Some scripts render in fields, others blank, labels all fine | page text got per-glyph fallback; the field's /DA font did not | name a face that covers every script that field accepts |
| One character missing from an otherwise correct value | the font file you supplied has no glyph for it | check coverage at build time; widen the font or the coverage target |
| PDF is tens of megabytes | a system font was named, so the whole collection was embedded | @font-face with a file you control |
| Everything looks right and users still report blanks | you tested with pdftotext | assert 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:
| Coverage | Hanzi | Font file | Our test form |
|---|---|---|---|
| GB2312 level 1 | 3,755 | 1.20 MB | 808 KB |
| GB2312 full | 6,763 | 2.31 MB | — |
| Big5 (Traditional) | 13,061 | 4.91 MB | — |
| Noto Sans SC, unmodified | 20,976 of 20,992 in URO | 10.60 MB | 6.44 MB |
| PingFang SC, system, on macOS | — | 78.22 MB collection | 63.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.