CJK fonts in PDFs: Chinese, Japanese, and Korean rendering pitfalls

Generating PDFs with Chinese, Japanese, or Korean text looks easy until you ship it. Here's what actually breaks — font subsetting, encoding, vertical text, and fallback chains — and how to fix each.

pdffontsi18ncjknode.js

CJK fonts in PDFs: Chinese, Japanese, and Korean rendering pitfalls

If you've ever generated a PDF that looked perfect in English and then rendered as a wall of □□□ boxes once a Japanese customer's name hit the template, you've met the first and friendliest CJK pitfall. There are several more waiting behind it. This post walks through what actually goes wrong when you render Chinese, Japanese, and Korean text to PDF, and what to do about it.

1. The default font doesn't have the glyphs

PDF generators like pdfkit, pdf-lib, and jsPDF ship with a small set of standard Latin fonts (Helvetica, Times, Courier). None of them contain CJK glyphs. When you pass a Chinese or Japanese string to one of these fonts, you get tofu boxes, or worse, a silent "missing glyph" that renders as whitespace.

The fix is to embed a font that actually covers the script you need. The go-to open-source choice is Google's Noto CJK family (Noto Sans SC, Noto Sans TC, Noto Sans JP, Noto Sans KR) or Source Han Sans. With pdf-lib:

ts
import { PDFDocument } from "pdf-lib"; import fontkit from "@pdf-lib/fontkit"; import fs from "node:fs"; const pdfDoc = await PDFDocument.create(); pdfDoc.registerFontkit(fontkit); const fontBytes = fs.readFileSync("NotoSansJP-Regular.otf"); const jpFont = await pdfDoc.embedFont(fontBytes, { subset: true }); const page = pdfDoc.addPage(); page.drawText("請求書 No. 001", { x: 50, y: 700, font: jpFont, size: 14 });

Two things matter here: registerFontkit (without it, pdf-lib can't parse OTF/TTF), and subset: true, which brings us to the next pitfall.

2. Full-font embedding bloats your PDFs

A Noto Sans JP OTF is roughly 5–10 MB. Noto Sans SC is similar. If you embed the full font in every PDF, a one-page invoice balloons to 10 MB. Multiply by thousands of documents and your storage and bandwidth costs get silly fast.

Font subsetting solves this. The embedded font only includes glyphs actually used in the document. A typical invoice with maybe 200 unique CJK characters subsets down to 50–150 KB.

Not every library subsets correctly for CJK. pdfkit historically had issues with CID-keyed fonts; pdf-lib with fontkit handles it well. If you roll your own pipeline, verify by opening the output in a PDF inspector (like qpdf --json) and checking the embedded font stream size.

3. Encoding and CID fonts

PDF has two font-encoding worlds: simple fonts (one byte per glyph, Latin-ish) and composite CID fonts (multi-byte, required for CJK). If a library tries to shove a Chinese string into a simple-font path, the output breaks in creative ways — mojibake, reversed byte order, or glyphs replaced with the wrong character.

When you embed a CJK font, make sure your library produces a Type0 composite font with an Identity-H encoding. Most modern libraries do this automatically when they detect wide characters, but older forks or hand-rolled generators often don't. If text looks correct on screen but copy-paste from the resulting PDF produces garbage, you almost certainly have a ToUnicode CMap problem. The glyphs render but the character map back to Unicode is missing or wrong.

4. Mixed scripts and font fallback

A single document often mixes Latin (product SKUs, prices), Japanese (customer names), and symbols (currency, bullets). No single font covers all of these well. Noto Sans JP has Latin glyphs, but they may not match your brand's Latin font.

Browsers handle fallback automatically. PDF generators generally don't. You usually need to either:

  • Pick one font that covers everything acceptably (Noto Sans CJK includes decent Latin), or
  • Implement per-run font selection: scan each string, segment by script, and apply the right font per segment.

A minimal script-based splitter looks like this:

ts
function splitByScript(text: string) { const runs: { text: string; script: "cjk" | "latin" }[] = []; let current = ""; let script: "cjk" | "latin" | null = null; for (const ch of text) { const code = ch.codePointAt(0)!; const isCjk = (code >= 0x3000 && code <= 0x9fff) || (code >= 0xac00 && code <= 0xd7af) || (code >= 0xf900 && code <= 0xfaff); const s = isCjk ? "cjk" : "latin"; if (s !== script && current) { runs.push({ text: current, script: script! }); current = ""; } script = s; current += ch; } if (current) runs.push({ text: current, script: script! }); return runs; }

Then draw each run with its own font and advance the cursor by the measured width.

5. Line breaking and justification

CJK text wraps per character, not per word. Most PDF layout engines were built around word-based wrapping, which means CJK lines often overflow their box or break at awkward points (right before a closing bracket, or splitting a numeric group like 2024年).

If your layout engine is HTML/CSS-based, set word-break: break-all or `line-break: strict