CJK Fonts in PDFs: Chinese, Japanese, and Korean Rendering Pitfalls
You ship a PDF generator. It works perfectly for English invoices. Then a customer in Tokyo uploads their company name, and the output looks like □□□□ 株式会社 — except the 株式会社 part is also boxes. Welcome to CJK rendering in PDFs, where every assumption you have about fonts is probably wrong.
This post covers the concrete pitfalls you'll hit when generating PDFs containing Chinese, Japanese, and Korean text, and how to avoid them.
Pitfall 1: The standard PDF fonts don't cover CJK
PDF has 14 "standard" fonts (Helvetica, Times, Courier, etc.) that every reader supports without embedding. None of them contain CJK glyphs. If your PDF library defaults to Helvetica and you write こんにちは, you'll get tofu (□□□□□).
The fix is always the same: embed a font that actually contains the glyphs you need. With pdf-lib in Node.js:
jsimport { PDFDocument } from 'pdf-lib'; import fontkit from '@pdf-lib/fontkit'; import fs from '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('こんにちは、世界', { font: jpFont, size: 24, x: 50, y: 700 });
The subset: true flag is critical for CJK — see pitfall 3.
Pitfall 2: One font is not enough
There is no single "CJK font" that handles Chinese, Japanese, and Korean correctly. Han unification means many characters share codepoints across the three languages, but they're drawn differently. The character 直 looks subtly different in Japanese vs. Simplified Chinese, and users notice.
Noto CJK ships as four separate fonts for this exact reason:
NotoSansSC— Simplified ChineseNotoSansTC— Traditional ChineseNotoSansJP— JapaneseNotoSansKR— Korean
If you're generating documents for multiple locales, detect the language per text run and pick the right font. A naive but workable heuristic:
jsfunction pickFont(text, locale) { if (locale?.startsWith('ja')) return jpFont; if (locale?.startsWith('ko')) return krFont; if (locale === 'zh-TW' || locale === 'zh-HK') return tcFont; if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return jpFont; // hiragana/katakana if (/[\uAC00-\uD7AF]/.test(text)) return krFont; // hangul return scFont; // default }
Don't try to guess language from CJK characters alone — without locale context, 中国 could be either zh-CN or ja-JP.
Pitfall 3: File size explodes without subsetting
A full Noto CJK font is 15–20 MB. Embed four of them in every PDF and you're shipping 80 MB documents.
Subsetting solves this — only the glyphs actually used get embedded. Most modern PDF libraries support it (pdf-lib via fontkit, PDFKit via its built-in subsetter, ReportLab via subsetting=1). Always turn it on for CJK.
A subset font for a typical invoice with maybe 200 unique CJK characters is around 100–300 KB, not 20 MB.
Pitfall 4: Line breaking doesn't work like English
CJK text doesn't use spaces between words. If your layout engine breaks lines on whitespace, a Japanese paragraph becomes one infinite line that overflows the page.
You need character-level wrapping with awareness of kinsoku shori — rules that prevent certain characters from starting or ending a line (e.g., 、 and 。 should never start a line; 「 should never end one).
If you're rolling your own, at minimum implement character-by-character wrapping:
jsfunction wrapCJK(text, font, fontSize, maxWidth) { const lines = []; let line = ''; for (const ch of text) { const w = font.widthOfTextAtSize(line + ch, fontSize); if (w > maxWidth && line.length > 0) { lines.push(line); line = ch; } else { line += ch; } } if (line) lines.push(line); return lines; }
For mixed CJK/Latin text, this gets harder fast — Latin words shouldn't break mid-word, but CJK runs should break anywhere. ICU's line break iterator (@unicode-org/icu4x or similar) handles this properly.
Pitfall 5: Vertical text and ruby annotations
Japanese books and some legal documents use vertical writing (tate-gaki). PDF supports vertical text via the /V writing mode in CIDFonts, but most high-level PDF libraries don't expose it. If you need vertical layout, you'll typically rotate a horizontal text block 90° and reorder punctuation, or render via HTML/CSS with writing-mode: vertical-rl and convert to PDF.
Ruby (furigana) — small reading annotations above kanji — has no native PDF construct. Render it as a smaller text run positioned above the base text, or use HTML and let the renderer handle it.