2
1 Comment

Two font bugs that silently corrupted every Chinese PDF my app exported

I'm building a resume builder that has to export both English and Chinese resumes as PDF. The AI parts were the easy half. The half that ate two weeks was getting Chinese text to survive the trip into a PDF file.

Two bugs, both silent. Neither throws. You only find them by reading the output character by character.

Bug 1: the subsetter drops glyphs

Noto Sans SC is 10.5 MB per weight. Embedding it whole makes every PDF around 10 MB, so you subset it down to the characters the document actually uses. pdf-lib has this built in:

await doc.embedFont(fontBytes, { subset: true })

That path uses fontkit's TrueType subsetter, and on Noto Sans SC it drops glyphs. Not all of them, and not deterministically. A section heading reading 教育经历 (Education) came out as 历 — three of the four characters simply gone. Latin letters went missing too, in the middle of otherwise fine English lines.

It doesn't error. The PDF opens. The text is just wrong, and wrong differently on different documents, which is why it took so long to pin down.

The fix was to subset with harfbuzz instead, via the subset-font package, then hand pdf-lib the already-subset bytes with its own subsetting turned off:

const sub = await subsetFont(Buffer.from(bytes), chars, { targetFormat: 'truetype' })

await doc.embedFont(sub, { subset: false })

About 150 ms per document. Output drops from ~10 MB to tens of KB. No dropped glyphs on any template since.

Bug 2: GSUB rewrites your numbers

With glyphs finally surviving, a different corruption showed up: P99 rendered as P盭盭, and the digits in 800ms were all wrong.

That's the font's GSUB table — OpenType substitution rules that fire when digits sit next to letters. Perfectly correct behaviour for the font. Useless for a resume, where nobody wants ligature substitutions, and actively harmful when it turns a latency number into a Chinese character.

Resume typesetting needs zero OpenType substitutions, so the whole table gets stripped out of the font bytes before embedding: walk the sfnt table directory, drop the GSUB entry, leave the original checksums alone. No PDF reader verifies them, and neither does fontkit.

Why not just print from a headless browser

The usual answer to "render a document as PDF" is Puppeteer and page.pdf(). It would have skipped both of these bugs entirely.

I didn't, for two reasons. Cold-starting Chromium per export is slow and expensive on serverless, and the layout you get is whatever the browser decided that day. Drawing directly with pdf-lib means every layout primitive — text wrapping, clipping, alignment, rich-text runs — is code I had to write, but the output is identical on every run and the whole export takes a few hundred ms.

Whether that trade was worth it is genuinely arguable. If your documents are English-only, I'd probably tell you to use Puppeteer and go build something else. The calculus changes once CJK is involved, because that's exactly where font handling stops being someone else's problem.

Where the project is

Public and free to try. It launched in December 2025 and is still at $0/mo — I have users but not paying ones yet, which is the part I'm much worse at than the font debugging.

Happy to go deeper on any of the above if it's useful to someone.

posted toAvatar for product ResuHive
ResuHive
  1. 1
    The CJK export problem sounds like a real differentiator, but the $0 revenue is the interesting part. Do the users who need Chinese resumes behave differently—e.g. return or ask for paid features—or is the font work mostly invisible value?