My side project exports a monthly statement page to PDF, so I wrote a fake one to test with: a Chinese title, a short mixed Chinese and English intro, a three-row table and a "Party A (seal): Some Company" line, all placeholder text. When I searched for "Chinese boxes in HTML to PDF", half the answers said fix the charset and the other half said install a font. Those are different bugs, and I didn't want to change things at random. So I made both of them happen on purpose and looked at what each one does to the page.
The top block is a simulation, and I want to be upfront about that. My Mac has PingFang, so Chromium will always find some Chinese font. To fake a machine without one, I wrote a tiny font where every CJK code point points at the same box glyph. The boxes are neat, all the same size, and "Statement No. 2026-0930-001" stays perfect. The middle block is a file saved as GBK but declared as UTF-8, and you get black diamond question marks with stray letters like õ and λ. The bottom block is the reverse, UTF-8 declared as GBK. It turns into a line of real but wrong Chinese characters, and "Statement" quietly loses its S. So now I check the shape first. Tidy boxes with clean English means a missing glyph. Diamonds, wrong characters or damaged English means the bytes were decoded wrong.
The text layer agrees, mostly. Out of 16 strings I check for, the GBK-as-UTF-8 PDF gave back 1 and the UTF-8-as-GBK one gave back 0. My fake box PDF still returned 15, but only because my font maps every character to one glyph, so I wouldn't assume a real missing font keeps the text.
Simulated boxes only get you so far, so I wanted a real server. I used ImgIng (https://imging.ai/) HTML to PDF with the same placeholder statement, importing it once as a single file and once as a whole folder. Import and preview stay in the browser. When you click convert, it sends one POST with a script-free snapshot of the page to a server-side Chromium 151 on Linux. I counted 0 non-GET requests before the click and exactly 1 after. The fonts inside its PDF were Noto Sans CJK SC Regular and Bold plus Noto Color Emoji. The "PingFang SC", "Microsoft YaHei" list I had copied into my CSS wasn't on that machine, and it was swapped out with no warning. Common characters looked fine.
My test page has one line of seven rare characters, and that's where the real boxes showed up. On the server, 𠀀 (U+20000) and 𪚥 (U+2A6A5) became crossed boxes, the other five were fine, and the emoji switched from Apple's to Noto's. My Mac printed 𪚥 through PingFang HK and left 𠀀 blank. What surprised me is that text extraction from the server PDF still returns 𪚥, so a "compare the extracted text" test passes while the page shows a box. This is the check I run now. Type3 fonts have an empty name, so it reads FontName from the FontDescriptor instead, and then it renders the page so I actually look at it.
doc = fitz.open("server.pdf")
seen = collections.Counter()
for xref, ext, kind, base, *_ in doc[0].get_fonts(full=True):
desc = doc.xref_get_key(xref, "FontDescriptor")[1]
if kind == "Type3" and desc != "null":
base = doc.xref_get_key(int(desc.split()[0]), "FontName")[1]
seen[(kind, base.lstrip("/"))] += 1
for (kind, name), n in seen.most_common():
print(kind, name, n)
doc[0].get_pixmap(dpi=150).save("page1.png") # look at this, don't diff the text
On the server PDF it prints Type3 BAAAAA+NotoSansCJKsc-Regular 40, then the Bold with 10 and the emoji font with 2.
The fix is to bring the font with the page, but it has to be loaded before you print. In my local Chromium 149 build I called page.pdf() right after the load event, three times each. With a remote font and font-display swap, the title fell back to the system font 3 out of 3 times. Without swap, I got a blank page of 1,073 bytes 3 out of 3 times. Waiting for document.fonts.ready fixed both. On ImgIng's server, the full 3.27 MB title font I embedded wasn't used, but a 48 KB subset of the same font was. I don't know why, so for now I subset first.
So when a PDF looks wrong, I go in this order: the shape of the broken text, then the embedded font list, then a rendered page, and only after that the charset. The one case I haven't tested is a real Linux machine with no Chinese fonts at all.

Top comments (0)