Guides · reference
How many characters does your "Chinese font" actually have? 3,755 is a very popular answer — and it cannot spell 鑫
Published 2026-08-17 · every number below measured that day with fontkit, fontTools 4.63.0, pdf-lib 1.17.1, poppler 26.06.0, Node 24.14.0 and Python 3.14 on macOS 15, Apple Silicon. The script is in the page; run it against your own font.
A fillable PDF has to carry, inside itself, every glyph somebody might type into it next year. Not the glyphs you drew — the ones they will. That makes font coverage an engineering decision with a price tag, and it is one of the few decisions in this area that no tool will make or check for you.
It used to be masked by a bigger problem. Font subsetting and fillable fields are in
direct conflict — a subset holds the glyphs the producer already drew, a field exists so
somebody can later type one nobody drew — and for years that conflict swallowed
everything else. It is now largely handled: WeasyPrint has exempted form fonts from
subsetting since version 58, and in pdf-lib
you pass subset: false. Which promotes coverage from a footnote to
the remaining variable. The engine will faithfully embed your font whole. It
cannot embed characters your font does not have.
1. Four instruments, all of them reassuring, all of them wrong
Start here, because it is why this page exists rather than being one line in a changelog. Two PDFs, identical but for which font went in, both asked to hold the name 王鑫:
// One field, one embedded font, one value: 王鑫. Two builds, differing only in
// which font went in. pdf-lib 1.17.1, subset: false, appearances updated.
font glyphs for 王鑫 PDF bytes
sc-l1.ttf (3,755 hanzi) 王 only 790,150
sc-gb2312.ttf (6,763 hanzi) both 1,433,848
// Everything you would reach for says the first one is fine:
$ pdftotext sc-l1.pdf - -> 王鑫 <- complete. it is not on the paper
$ pdftotext sc-gb2312.pdf - -> 王鑫 <- identical output, different document
$ pdffonts sc-l1.pdf
name type encoding emb sub uni
NotoSansSC-Thin-7098 CID TrueType Identity-H yes no yes
^^^ 'not a subset'
$ pdftoppm -r 100 sc-l1.pdf out 2>&1 >/dev/null -> (no output at all)
Every check passed on a document that is missing half of somebody's name. Taking them one at a time:
pdftotext reads the text layer, which is written from the
value you set, not from what was drawn. On this topic those two are populated by different
code paths, so the extracted string is complete for a blank box. This one is at least
reasonably well known.
pdffonts' sub column is inferred from the six-letter tag on
the font name — ABCDEF+Name — and not from the font program at all.
Producers write that tag on their own terms, so the column is unreliable in both
directions: here pdf-lib embedded a deliberately narrow font and the column says
sub no; WeasyPrint embeds a font byte-for-byte whole and
the same column says sub yes. It is
reporting a naming convention.
Poppler's couldn't find a font for character errors did not fire,
and that is the subtle one. They report a viewer failing to resolve a character it
was asked to draw. When the producer already dropped the glyph while generating the
appearance stream, there is nothing left for the viewer to fail at. Control, so this is not
just a quiet machine:
// Is poppler's stderr just quiet on this machine? Positive control: same font,
// same characters, but the value written straight into /V so the *viewer* has to
// draw it (NeedAppearances, no appearance stream generated).
$ pdftoppm -r 100 rawv.pdf out 2>&1 >/dev/null
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+738B
Syntax Error: HorizontalTextLayouter, couldn't find a font for character U+946B
// The channel works. It stays silent for the file above because there the
// PRODUCER dropped the glyph: the appearance stream was written without it and
// the viewer has nothing left to fail at. Those errors report a viewer's
// resolution failure, not a producer's omission.
And any test that asserts on extracted text inherits the first problem. That is not hypothetical: this line of work started because a suite with CJK assertions was passing green against PDFs that were dropping characters.
2. The measurement that does not lie: count the ink
// pdftoppm -r 100 -gray, dark pixels (< 200) over the same 320x30 field.
// The empty-field baseline is measured in the same geometry — ink without it
// is a number you cannot read.
empty field, no value 1,015 px <- baseline
'王' with sc-l1.ttf 1,139 px +124
'王鑫' with sc-l1.ttf 1,139 px +124 <- 鑫 drew NOTHING
'王鑫' with sc-gb2312.ttf 1,378 px +363
// 1,139 and 1,139. Not close — equal. The second character contributed exactly
// zero pixels, and every other instrument called the file correct.
This is the ground truth for "did it render", and it is worth wiring into CI once. But it is also the expensive way to find out — it needs a built PDF and a rasteriser. The cheap check runs earlier.
3. Ask the font file, at build time
Every glyph question above is answerable before a PDF exists, from the font alone. About forty lines, no network, and the character sets are enumerated from the standards rather than pasted from a list somebody maintained once:
// font-coverage.mjs — ask the font file, before it ever reaches a PDF.
import fontkit from '@pdf-lib/fontkit/dist/fontkit.es.js'
import { readFileSync, statSync } from 'node:fs'
// Enumerate the standard rather than trusting a list: decode its own byte
// space. GB 2312 hanzi are rows 16-87 of the 94x94 plane = lead 0xB0-0xF7,
// trail 0xA1-0xFE. Level 1 is rows 16-55 (lead 0xB0-0xD7), by pinyin.
const decodeRegion = (lo, hi, tLo, tHi, enc = 'gbk') => {
const dec = new TextDecoder(enc), out = new Set()
for (let l = lo; l <= hi; l++) for (let t = tLo; t <= tHi; t++) {
const s = dec.decode(new Uint8Array([l, t]))
if (s.length === 1 && s.codePointAt(0) !== 0xfffd) out.add(s.codePointAt(0))
}
return out
}
const range = (lo, hi) => new Set(
Array.from({ length: hi - lo + 1 }, (_, i) => lo + i))
const font = fontkit.create(readFileSync(process.argv[2]))
const tiers = {
'GB 2312 L1': decodeRegion(0xb0, 0xd7, 0xa1, 0xfe),
'GB 2312 all': decodeRegion(0xb0, 0xf7, 0xa1, 0xfe),
'Unicode URO': range(0x4e00, 0x9fff),
'Hangul': range(0xac00, 0xd7a3),
'Kana': range(0x3040, 0x30ff),
}
for (const [name, set] of Object.entries(tiers)) {
let n = 0
for (const cp of set) if (font.hasGlyphForCodePoint(cp)) n++
console.log(name.padEnd(14), n, '/', set.size)
}
// And the part that belongs in CI — a gate, not a report:
const required = [...'王鑫김한수']
const missing = required.filter(c => !font.hasGlyphForCodePoint(c.codePointAt(0)))
if (missing.length) {
console.error('no glyph for', missing.join(''))
process.exit(1)
}
Pointed at the font this site used to embed:
$ node font-coverage.mjs the-font-we-used-to-ship.ttf
Noto Sans SC · 4,535 glyphs · 1,204,384 bytes
GB 2312 level 1 3755 / 3755 100.0% <- exactly, to the character
GB 2312 (L1+L2) 3755 / 6763 55.5%
Unicode URO 3755 / 20992 17.9%
Hangul syllables 0 / 11172 0.0%
top-100 surnames 98 / 100 missing: 闫 覃
given-name chars 3 / 26 missing: 鑫 淼 焱 垚 昇 喆 玥 珺 璟
宸 曦 煜 昊 婷 萱 怡 梓 …
// 3,755 is not a coincidence and it is not a rounding: it is GB 2312 level 1,
// the 1980 standard's first tier, exactly. Somebody chose a defensible target
// years ago and nothing since has re-examined it.
That 3,755 is worth sitting with. It is GB 2312 level 1 — rows 16 to 55 of the 1980 standard, ordered by pinyin, the tier a Chinese school leaver knows. As a coverage target it is entirely defensible, and the standard is documented as covering "over 99.99% contemporary Chinese text usage". The same source finishes that sentence: "historical texts and many names fall outside its scope." Forms collect names. That is the entire failure, in the standard's own words, decades before anyone put it in a PDF.
4. What each tier costs
One source font — Noto Sans SC, 400 weight, 10,595,948 bytes — subsetted to each target with fontTools 4.63.0, then embedded unsubsetted into the same one-field form with pdf-lib. So the two right-hand columns are the same document each time, differing only in coverage:
| Target | Hanzi | Font file | Draws 鑫? | |
|---|---|---|---|---|
| GB 2312 level 1 | 3,755 | 1,199,600 | 790,150 | no |
| GB 2312 complete | 6,763 | 2,209,144 | 1,433,848 | yes |
| GBK | 20,902 | 7,398,520 | 4,558,022 | yes |
| Unicode URO | 20,976 | 7,426,352 | 4,575,951 | yes |
| Noto Sans SC, unmodified | 20,976 + Ext A | 10,595,948 | 6,453,844 | yes |
The shape of that curve is the useful part, and it is not linear. Going from level 1 to complete GB 2312 costs about 1 MB and buys the characters people are actually called — 鑫, 婷, 怡, 宸, 曦, 煜 all arrive there. Going on to GBK costs another 5 MB for roughly 14,000 characters that a registration form will not see this decade. And the last two rows are nearly the same font: URO over GBK is 90 more characters for 28 KB, so if you are already paying for GBK there is no reason to stop short.
Two practical notes on the right-hand column. The PDF is smaller than the font because font programs compress; and it is a per-copy cost, so a 4.5 MB form emailed to 20,000 people is 90 GB of egress that a 1.4 MB one would not have been.
You cannot subset in what is not there
// Requested Unicode URO (20,992 code points) from Noto Sans SC. Got:
requested 21,087 code points (URO + ASCII)
delivered 20,976 of URO <- 16 short, silently
// The subsetter cannot add what the source does not have, and it does not
// complain about the difference. The request is not the result. Measure the
// output file, never the build config.
5. “GB 2312 or GB 18030?” is the wrong question
It is a common way to frame the decision and it does not survive contact, because the two things are not the same kind of thing. GB 2312 is a character set: a fixed repertoire of 6,763 hanzi, so "cover GB 2312" is a target you can hit exactly and verify. GB 18030 is an encoding — a transformation format for Unicode. Its repertoire is, near enough, all of Unicode, and the 2022 edition frames its requirements as implementation levels rather than a glyph count. There is no font that "supports GB 18030" in the sense the question implies, and if there were, you would not want to embed it in a form.
What you are actually choosing is a set of Unicode blocks, and the honest ladder is the table above: GB 2312 complete, then GBK, then the URO, then Ext A and beyond. Naming it that way also makes the assertion writable, which the encoding framing does not.
And pin your enumerator
// Two enumerators on the same machine, asked the same question:
// 'which hanzi does GBK contain?'
Python 3.14, codecs.encode(ch, 'gbk') 20,902 hanzi
Node 24, new TextDecoder('gbk') 20,962 hanzi
--------
in Node's answer and not Python's 60
in Python's answer and not Node's 0
// e.g. 0xFE59: Python raises, Node returns 龴 (U+9FB4).
// The cause is not a bug, it is in the spec, in five words:
// 'gbk's decoder is gb18030's decoder' — WHATWG Encoding Standard
//
// So asking Node for GBK gets you GB 18030's repertoire, which carries the
// mappings GBK 1.0 had parked in the Private Use Area. Python's gbk codec is
// GBK proper and leaves those byte pairs undefined.
// Neither is wrong. But 'covers GBK' is quoting one of the two, and the other
// disagrees by 60 characters. Name the enumerator in the assertion.
That five-word line in the WHATWG Encoding Standard is the whole explanation, and it is a small illustration of the section above: GB 18030 is broad enough that the web platform decodes GBK with it. If a build gate has to say which repertoire it enforced, have it name the tool and the version, not the standard.
6. A font for one script is not a superset of another
The instinct that "a bigger CJK font covers the smaller ones" is wrong in both directions, and the numbers are worse than most people expect. The four Noto CJK faces this site builds, each measured against every tier:
| Face | GB 2312 L1 | URO | Hangul | Kana | Top-100 surnames |
|---|---|---|---|---|---|
| Noto Sans SC subset | 3,755 (100%) | 6,763 | 0 | 0 | 100 |
| Noto Sans TC subset | 2,551 (67.9%) | 13,061 | 0 | 0 | 65 |
| Noto Sans JP subset | 2,370 (63.1%) | 6,356 | 0 | 189/192 | 67 |
| Noto Sans KR subset | 2,085 (55.5%) | 4,619 | 11,172 | 0 | 64 |
Read the Traditional row. It carries 13,061 hanzi — three and a half times the Simplified subset — and it cannot draw 35 of the hundred commonest Chinese surnames: 张, 刘, 陈, 杨, 黄, 赵, 吴 and 28 more. They are simplified forms, and a Traditional face has no reason to carry them. "Bigger" is not "superset"; a font covers a repertoire, and repertoires overlap rather than nest.
This matters more for forms than for pages, because
a form field gets exactly one font and no
per-glyph fallback. Page text that hits a missing glyph quietly borrows another face;
a field draws nothing. So the rule is per field, not per document: the face named in
a field's /DA must cover every script that field can receive, and if one
face cannot, the field must be split or the face widened.
7. What this site does, and what it costs you
Handing the problem to somebody else is a legitimate answer, and there are several places
to hand it — Nutrient, PDFCrowd, DocRaptor, IronPDF and DocuSeal all render fillable PDFs
from HTML. What snapdok.io does with coverage specifically, since that is
this page's subject: the field font is chosen per document from the code points actually
present, not from 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 only the bucket a document needs is embedded, so a Latin-only render carries
zero bytes of CJK font.
The costs, stated plainly, because this page is about not being lied to:
- A document carries at most two of those faces. A page genuinely mixing
three scripts loses one; the response says which in an
X-Form-Unrenderableheader rather than printing an empty box. It is a real limit. - The Simplified face stops at complete GB 2312 — row two of the table in section 4, not row three. So 王堃 (U+5803) is reported, not drawn. We picked that point on the curve deliberately and this page is the argument for it, but it is a ceiling and you should know where it is.
- A CJK document is measured in megabytes here exactly as it is anywhere else. There is no trick; the font is in the file.
100.0% row in section 3 is that font, measured. It could not draw ordinary given names and had no Hangul at all. The per-script buckets replaced it after these measurements, which is the honest order of events: the article is what the fix was argued from, not a victory lap after it.If CJK forms are the centre of your product rather than a feature of it, owning this is not much work and gives you control a hosted API will not: a font you subsetted on purpose, WeasyPrint 58 or newer, the coverage gate above in CI, and one ink assertion. This page has the whole method in it.
The short version
Coverage is now the variable that decides whether a CJK form field renders, because the
engines stopped subsetting the fonts your fields name. Measure it from the font file at build
time — hasGlyphForCodePoint over an enumerated standard, with the characters you
cannot afford to lose as a hard gate — because every instrument downstream of that point will
tell you a document is fine when it is not: pdftotext returns the value you set,
pdffonts' sub column reports a naming convention, poppler's missing-font
errors only fire when a viewer had to draw, and any assertion built on extracted text
inherits the first of those. When you do need to check a finished PDF, count the ink against
an empty-field baseline in the same geometry. And pick the tier on purpose: complete
GB 2312 is about 1 MB more than level 1 and is where the characters people are named
actually live.
The rest of this line of work:
why the label prints and the value
under it does not is the hub and covers Chromium, WeasyPrint and pdf-lib together;
pdf-lib's
WinAnsi cannot encode is the same conflict arriving as a runtime exception,
including a subset: true that blanks fields without raising anything; and
WeasyPrint --pdf-forms with
Chinese covers the engine that solved the subsetting half of this in 2023.
Sources and versions, all read
or run 2026-08-17:
GB 2312 for the level 1 /
level 2 split (3,755 and 3,008, rows 16-55 and 56-87) and the "many names fall outside its
scope" quotation ·
GB 18030 for the
encoding's scope, the PUA reassignments and the 2022 implementation levels ·
the Unicode
block list for the ranges quoted — 4E00..9FFF; CJK Unified Ideographs is the
20,992 of them, AC00..D7AF; Hangul Syllables the 11,172 ·
the WHATWG Encoding Standard,
which is the index behind TextDecoder('gbk') ·
Noto Sans
SC and its siblings (SIL Open Font License 1.1), the faces in every measurement ·
fontTools
subset 4.63.0, which produced the tier files ·
poppler 26.06.0 for
pdftoppm, pdftotext and pdffonts. The measurements are
ours and repeatable with the script above.