My side project doesn't have PDF export yet, but I know the message I'll get once it does. Someone will write "the PDF has the wrong characters" and attach a blurry screenshot. That sentence covers at least four bugs, and most answers online jump to the charset. I didn't want to guess from a screenshot at 1 a.m., so I broke a test page on purpose first. It's a fake one-page statement I wrote myself, with a ZCOOL KuaiLe title, Noto Sans SC body text and a line of seven rare characters. I printed it in an open-source Chromium 149 build on my M4 Mac, then on someone else's server.
Start with what the wrong text looks like
The first thing I'd look at in the screenshot is the English. The top block is simulated. My Mac has PingFang, so I wrote a font whose only glyph is an empty box and mapped every CJK code point to it. The Chinese turns into rows of identical boxes, and "Statement No. 2026-0930-001" comes out intact. The middle block is a GBK file declared as UTF-8, mostly black diamond question marks. In the bottom block it's the other way round, and you get real but wrong Chinese characters, plus "Statement" loses its S. Tidy boxes with clean English mean a font is missing a glyph. Diamonds or damaged English mean the bytes were decoded wrong, and I'd go look at how the file was saved.
Then check whether the server has the character
Boxes send me to the machine that printed. For a machine with fonts I didn't choose I used ImgIng's HTML to PDF (https://imging.ai/), fed the same statement once as a single HTML file and once as a whole folder. Import and preview happen in the browser. Clicking convert sends one POST with a script-free snapshot to a server-side Chromium 151 on Linux, and I counted 0 non-GET requests before the click and exactly 1 after. It's the one ImgIng document tool that runs on a server. The fonts in its PDF were Noto Sans CJK SC and Noto Color Emoji. My CSS asked for PingFang SC and Microsoft YaHei, which weren't there, and nothing said so. Of my seven rare ones, 𠀀 (U+20000) and 𪚥 (U+2A6A5) came out as crossed boxes. Text extracted from that PDF still says 𪚥, so a text diff passes while the page shows a box.
So I wrote a cheap check that runs on the page before printing:
const triage = await tab.evaluate(() => {
const bodyText = document.body.innerText;
const badBytes = bodyText.split('\uFFFD').length - 1;
const astral = [...new Set(bodyText)]
.filter(c => c.codePointAt(0) > 0xFFFF && !/\p{Extended_Pictographic}/u.test(c))
.map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase());
return { badBytes, astral };
});
Code notes: tab is the loaded page. badBytes counts U+FFFD, the replacement character a decoder leaves behind. astral lists characters outside the Basic Multilingual Plane, minus emoji. On my clean page it returned five code points, U+2B5AF, U+2C317, U+30EDD, U+20000 and U+2A6A5. Only the last two were boxed on the server, so the list tells me which characters deserve a rendered look, not which ones will break.
A blank page means the font loaded too late
If the user says the PDF is empty, it probably looks like the left one, with table lines and no words. I loaded the fonts from a remote CSS link without swap and printed right after load. The result was 1,073 bytes, 3 times out of 3, with no error. With swap the title quietly fell back to the system font instead, also 3 out of 3. Waiting for document.fonts.ready gave the correct 117,799-byte PDF every time.
On ImgIng's server, inlining the full font files gave a KuaiLe title in the preview and Noto Sans CJK SC in the PDF, and the report showed no font warning. The full 3.27 MB KuaiLe alone wasn't used either, but a 48,088-byte subset of it was. I can't see why from outside. What I can see is the bandwidth. Both full fonts made a snapshot of 18,509,983 bytes that bought nothing, while both subsets came to 501,152 bytes, worked, and produced a 44,084-byte PDF. If my own endpoint ever accepts uploads like that, I'm sending subsets.
The charset comes last
Encoding comes last, and the same script already answers it. The GBK-declared-as-UTF-8 page had 139 replacement characters in its text, and the reverse case had 10. The clean page and the box page both had 0. Missing glyphs don't show up in this count at all, so I still look at the shape before anything else.
Server, my orange cat, slept through all of this on the warm corner of the laptop. I haven't tested a real Linux box with no Chinese fonts, and I don't know why the full fonts weren't used. My ImgIng runs are from the evening of 2026-09-30, and the server may change. For now my export logs the badBytes count and the astral list next to every PDF, so the next "wrong characters" message starts with numbers instead of a guess.


Top comments (0)