When a page becomes a PDF, the Chinese text is drawn with whatever fonts the Chromium doing the printing has at that moment. On my own Mac that question never comes up, because PingFang is always there. It comes up the day the PDF is made by a server someone else built. I wanted a font setup that gives the same PDF no matter which machine prints it, so I wrote a one-page placeholder statement to test it, with a made-up company and invented amounts. The body is Noto Sans SC and the title is ZCOOL KuaiLe, a rounded display face you can spot from across the room. Both are OFL fonts that were already on my disk. Local prints came from an open-source Chromium 149 build on an M4 Mac, and I ran most of this after my daughter was in bed.
The server swaps in its own CJK font
For the other machine I used ImgIng (https://imging.ai/) HTML to PDF, with the same placeholder statement imported once as a single file and once as a whole folder. I work on ImgIng's on-device codecs and model loading. The HTML-to-PDF service isn't my code, so everything below about it comes from the requests the page sent and the PDFs it returned. Import and preview stay in the browser. When you click convert, the page sends one POST with a script-free snapshot to a server-side Chromium 151 on Linux, and it's the only document feature in ImgIng that runs on a server. I counted 0 non-GET requests before the click and exactly 1 after. The page that asked for "PingFang SC" and "Microsoft YaHei" came back drawn in Noto Sans CJK SC, with nothing on screen saying the fonts were swapped. Common characters were fine. Two rare ones, 𠀀 and 𪚥, came out as crossed boxes because the server's font has no glyph for them.
Remote fonts never reach the generator
Linking Google Fonts didn't help. With the "allow network resources" toggle off, which is the default, the quality report said the remote resource was blocked. With it on, the preview downloaded the fonts, but the snapshot kept the <link> tag as it was, and the server PDF still used Noto Sans CJK SC. ImgIng's docs say the conversion service can't resolve outside domains, which fits. I'd build a PDF worker the same way. One that fetches any URL a user page asks for is not something I want to run, so I assume the font has to travel inside the HTML.
Full font files did not survive the trip
So I shipped the fonts with the page. With the folder import, the relative fonts/ URLs were inlined as data URLs, and the snapshot came to 17.65 MB, 4 bytes off the version I had inlined by hand. The upload worked. The fonts didn't show up.
Look at the shape of the strokes in each row. The top two carried the full ZCOOL KuaiLe (3.27 MB) and the full Smiley Sans (2.64 MB), and both came out in the server's Noto Sans CJK SC. The bottom two carried a 48 KB KuaiLe subset holding only the glyphs on the page, and that same subset padded with a dummy table to the full file's byte size. Both rendered in KuaiLe. A full 2.05 MB pixel font also worked. The padded subset rules out upload size as the trigger. I can't see the real cause from outside, so I treat it as behaviour to design around. The preview showed KuaiLe the whole time, and the report said it had waited for document.fonts and the page images.
Orange bars are fonts the server PDF didn't use and blue bars are fonts it did. The two full-font runs each sent about 18.5 MB for nothing. Two subsets inlined together sent 501.2 KB, both fonts made it, and the final PDF was 44,084 bytes.
Send a subset of the glyphs on the page
Chromium already subsets fonts when it prints. My folder version loaded 13,880,960 bytes of font and produced a 71,102-byte PDF, because only the glyphs on the page go in. A six-letter prefix like AAAAAA+ marks a subset. My build step reuses that work. It prints the page once locally, lifts the embedded subset out of the PDF and writes it back as an inline @font-face. It also lists any characters the subset can't draw.
def lift_subset(printed_pdf, family):
doc = fitz.open(printed_pdf)
for xref, _, ftype, basefont, *_ in doc[0].get_fonts(full=True):
if ftype == "Type0" and basefont.split("+")[-1].startswith(family):
return basefont, doc.extract_font(xref)[3]
raise SystemExit(f"{family} not embedded in {printed_pdf}")
def missing_glyphs(font_bytes, text):
face = fitz.Font(fontbuffer=font_bytes)
return [ch for ch in dict.fromkeys(text) if not face.has_glyph(ord(ch))]
basefont, blob = lift_subset(sys.argv[1], "ZCOOLKuaiLe")
rule = ("@font-face{font-family:'Title Face';font-display:block;"
"src:url(data:font/ttf;base64," + base64.b64encode(blob).decode() + ") format('truetype')}")
print(basefont, len(blob), "B font,", len(rule), "B css")
for title in sys.argv[2:]:
print(f"{title}: missing {''.join(missing_glyphs(blob, title)) or 'none'}")
AAAAAA+ZCOOLKuaiLe-Regular 48088 B font, 64225 B css
对账单(示例): missing none
月度对账单(示例): missing 月度
Statement 2026: missing Staemn 206
Code notes: the script reads the PDF with PyMuPDF and runs on the local print of the folder version. extract_font returns the font program, and has_glyph asks whether the subset can draw each character. The 3.27 MB KuaiLe shrinks to 48,088 bytes, and base64 makes it 64,225 bytes of CSS. The other two lines are the catch. A subset only knows the page it came from. Add two characters to the title and they're gone, and the Latin letters were never in it at all. So the subset has to be rebuilt every time the text changes, and the job should fail when the missing list isn't empty. I still prefer this to shipping the full file. A stale subset shows up as an error in my build log, and a swapped font shows up weeks later in someone's PDF.
Check the font is loaded before printing
The load event doesn't wait for web fonts. In my folder version, a print fired at DOMContentLoaded in a cold browser lost the body text in 4 of 8 runs. One more run came out fully blank.
On the left the KuaiLe title made it, and the body text and every table cell are missing, in a 6,476-byte PDF that raised no error. On the right is the same page printed after load, which was correct every time at 71,102 bytes. My print step waits for the font set and then checks the title face by name, and it won't print if the check fails.
await sheet.setContent(html, { waitUntil: 'domcontentloaded' });
const titleReady = await sheet.evaluate(() =>
document.fonts.ready.then(set => set.check("400 32px 'Title Face'", '对账单')));
if (!titleReady) throw new Error('title face never loaded, refusing to print');
const bytes = await sheet.pdf({ format: 'A4', printBackground: true });
Code notes: html is a small test page with the generated CSS inlined and the title set in 'Title Face'. document.fonts.ready resolves to the font set itself, so the check runs on it once loading is over. With a data URL, the face already reported loaded at DOMContentLoaded in this run. I keep the wait anyway, because the same step also prints pages whose fonts sit in a folder. The check passed, and the 13,309-byte PDF carried the same 48,088-byte KuaiLe subset. This step only covers printers I run. On ImgIng's server, the page that worked was simply the one that arrived holding a subset.
What I have not tested
I haven't run a Linux box with no CJK fonts at all, so I only know what a missing glyph looks like on a server that has Noto Sans CJK SC. WOFF2 fonts shipped with the page are untested. So are Firefox and WebKit printing, the real Chrome print dialog and the local() form of @font-face. I don't know where the line is between a full font the server uses and one it ignores, and the server's build may change after the 2026-09-30 run. For your own pipeline, subset fonts to each page at build time, fail the build when a character is missing, and wait for document.fonts.ready before you call page.pdf(). Then open the PDF and look at the title.



Top comments (0)