PDFPipe

East Asian scripts / Chinese

Chinese text in a PDF

Simplified and Traditional are different character sets rather than different fonts, and a face covering one may genuinely not contain the other.

A font with the characters is not enough

The two writing systems overlap heavily and are not interchangeable. A font built for Simplified Chinese covers a set of a few thousand common characters plus extensions; a Traditional document uses characters outside that set, and those come out as tofu while the rest of the paragraph renders. Because the failure is partial it is easy to miss in review. The second issue is size: a CJK font covering a useful range carries tens of thousands of glyphs and runs to several megabytes even as WOFF2, which is a real cost on every render if the font is fetched rather than cached. Subsetting helps far less than it does for Latin, because you cannot predict which characters a customer name will use.

What the failure looks like

A paragraph that is mostly correct with a scattering of empty boxes, which is the wrong regional variant. Or full-width punctuation rendered from the Latin font in the stack, which produces a comma with a large gap after it because the Latin glyph does not fill its em box the way the CJK one does.

Line breaking

Chinese breaks between characters with no word spaces, so lines fill neatly. The rules that matter are which punctuation may not start a line, and that a run of Latin text or a number embedded in Chinese should be treated as one unbreakable unit rather than broken between digits.

The markup and the CSS

The language attribute is doing real typographic work in every one of these examples. It is what lets the engine choose a regional glyph form, a case mapping or a hyphenation dictionary, and leaving it off is the most common single omission in a multilingual document.

html
<p lang="zh-Hans" class="chinese">您的发票已准备好。</p>
<p lang="zh-Hant" class="chinese">您的發票已準備好。</p>

<style>
/* Two faces, because these are two character sets. Declaring one font
   for "Chinese" is the mistake this page exists to prevent. */
@font-face {
  font-family: "Noto Sans SC";
  src: url("https://assets.example.com/fonts/noto-sans-sc.woff2") format("woff2");
}
@font-face {
  font-family: "Noto Sans TC";
  src: url("https://assets.example.com/fonts/noto-sans-tc.woff2") format("woff2");
}

:lang(zh-Hans) { font-family: "Brand Sans", "Noto Sans SC", sans-serif; }
:lang(zh-Hant) { font-family: "Brand Sans", "Noto Sans TC", sans-serif; }

.chinese {
  line-height: 1.75;
  /* Full-width punctuation must come from the CJK font. If the Latin
     face earlier in the stack claims those code points, the spacing
     after a comma comes out visibly wrong. */
  line-break: strict;
}</style>

The mistake people make

Subsetting the CJK font against the text you have today. Latin subsetting is safe because the alphabet is closed; Chinese is not. The first customer whose name uses a character outside the subset gets a document with a box where their name should be, and it happens in production because it cannot happen in testing. Ship the full face, or subset against a published frequency list with a generous margin, and accept the file size.

How to test this without reading the language

Render a string you know is correct, and compare it against a reference rendering from a system that definitely handles the script, such as a browser on a machine with the language installed. Comparing against nothing tells you only that characters appeared, and for most of the scripts on this site characters appearing is not the same as text being right. Where you can, get one native reader to look at one page once; it is faster than any amount of specification reading.

Frequently asked

Why does Chinese look wrong when every character is there?

A paragraph that is mostly correct with a scattering of empty boxes, which is the wrong regional variant. Or full-width punctuation rendered from the Latin font in the stack, which produces a comma with a large gap after it because the Latin glyph does not fill its em box the way the CJK one does. The underlying reason is that a font is two things: a set of glyphs and a set of layout tables that say how those glyphs combine and position. Character coverage is the first half. Most of the failures on this page are the second half.

Does the lang attribute actually change the rendering?

Yes, and it is not an accessibility nicety here. It selects regional glyph forms where a code point has more than one correct shape, it picks the case mapping for a text transform, it chooses a hyphenation dictionary, and it triggers the locl OpenType feature. A document with no lang attribute gets whatever the font defaults to, which is usually right for one language and wrong for the others.

Can I subset the font to keep the file small?

Carefully, and not with a tool configured for Latin. Subsetters routinely drop the GSUB and GPOS tables that shape and position glyphs, because for Latin those are largely optional. The subset renders without error and every complex feature in it is broken. Test a subset by rendering real text and looking at it, never by checking that the file got smaller.

Related writing systems

Scripts with a related mechanism, and one from each of the other families.

Paste a real string in this script into the playground and compare the output against a reference. It is the only test that means anything if you do not read the language.