CJK Fonts in PDFs: Chinese, Japanese, and Korean Rendering Pitfalls

Generating PDFs with Chinese, Japanese, or Korean text looks simple until you ship to production and see boxes instead of characters. Here's what actually breaks and how to fix it.

pdffontsi18ncjkunicodenode.js

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:

js
import { 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 Chinese
  • NotoSansTC — Traditional Chinese
  • NotoSansJP — Japanese
  • NotoSansKR — 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:

js
function 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:

js
function 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.

Pit