The Problem
Chinese characters display as empty squares (tofu) on systems without CJK language packs installed. This is a common issue for multilingual websites with Chinese content that have international visitors. Many Chinese websites don’t handle this properly, resulting in a poor experience for users on English or other non-Chinese operating systems.
How to reproduce: Install a fresh OS without Chinese language support, then visit a Chinese website. On macOS, this happens in Safari when CJK fonts are removed. On Windows, this occurs on English-language installations without the East Asian language pack. On Linux, Chinese fonts are often not installed by default.
Root Cause Analysis
Why It Happens
When a browser encounters a Chinese character (CJK Unified Ideograph) and the CSS font-family does not specify a font containing CJK glyphs, the browser cannot find a matching glyph in any available font. It renders a fallback square or blank space instead — a phenomenon known as “tofu” (□).
The browser font resolution algorithm works as follows:
1. Check CSS font-family stack (e.g., 'Noto Sans SC', Arial, sans-serif)
2. Look for first font family with the required glyph
3. If no font in stack has the glyph → check system default fonts
4. If no system font has the glyph → render .notdef glyph (□)
Why Russian Works but Chinese Does Not
Cyrillic characters are included in many common Western fonts because they are part of the Unicode blocks covered by fonts like Arial, Times New Roman, and Verdana. These fonts contain 2,000+ characters covering Latin, Cyrillic, and Greek scripts.
Chinese (CJK) characters require fonts with 20,000+ ideographs, ranging across several Unicode blocks:
| Script | Unicode Range | Character Count | Font Size |
|---|---|---|---|
| Latin (basic) | U+0000-007F | 128 | Included in all fonts |
| Cyrillic | U+0400-04FF | 256 | Included in Arial, etc. |
| CJK Unified | U+4E00-9FFF | 20,992 | ~8-15 MB |
| CJK Extension A | U+3400-4DBF | 6,592 | ~2-3 MB |
| CJK Extension B | U+20000-2A6DF | 42,711 | ~5-8 MB |
Because CJK fonts are 5-15 MB, they are not bundled with most Western OS installations. This is the fundamental reason for tofu rendering on non-Chinese systems.
Font Size Comparison
| Font | Approximate Size | Characters Covered |
|---|---|---|
| Arial (Latin only) | ~350 KB | 2,000+ |
| Times New Roman | ~300 KB | 2,000+ |
| Noto Sans SC (full) | ~8-15 MB | 30,000+ |
| Noto Sans SC (subset, common chars) | ~500 KB - 2 MB | ~4,000 |
| Google Fonts (auto-subsetted per page) | ~50-200 KB per page | Varies |
| PingFang SC | ~12 MB | 30,000+ |
| Microsoft YaHei | ~10 MB | 30,000+ |
CSS Font-Family Stack Configuration
Proper Font Stack Design
A well-designed CSS font stack for Chinese content ensures graceful degradation across different OS environments:
/* Optimal Chinese font stack with system fonts */
body {
font-family:
/* 1. System UI font on Apple devices (macOS, iOS — has Chinese support) */
-apple-system, BlinkMacSystemFont,
/* 2. Segoe UI on Windows systems with East Asian pack */
'Segoe UI',
/* 3. Dedicated Chinese fonts (will be used if installed) */
'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei',
/* 4. Latin fallback (most systems have these) */
Arial, Helvetica,
/* 5. Generic fallback */
sans-serif;
}
Platform-Specific Behavior
Each operating system handles Chinese font fallback differently:
| OS | Chinese Fonts (if installed) | Behavior Without CJK |
|---|---|---|
| macOS | PingFang SC, Heiti SC | Included in all language variants |
| iOS | PingFang SC | Included in all language variants |
| Windows | Microsoft YaHei, SimSun (with East Asian pack) | Not present on English-only installs |
| Linux | Noto CJK, WenQuanYi (varies by distribution) | Usually not installed by default |
| Android | Noto Sans CJK | Included by default |
| ChromeOS | Noto Sans CJK | Included by default |
Solution 1: Google Fonts Noto Sans SC (Easiest)
Google Fonts provides automated subsetting via the Noto Sans SC font family. It downloads only the character subsets actually used on your page, keeping file sizes manageable:
<!-- In <head> — with preconnect optimization -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
body {
font-family: 'Noto Sans SC', -apple-system, Arial, sans-serif;
}
Pros: Simple implementation, automatic subsetting, CDN delivery, multiple weights Cons: Requires internet access, blocked in mainland China, adds HTTP request, potential FOUT
Performance Optimization for Google Fonts
<!-- Optimized Google Fonts loading with preload and font-display -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet">
</noscript>
/* Prevent FOUT with font-display */
body {
font-family: 'Noto Sans SC', -apple-system, Arial, sans-serif;
font-display: swap; /* Fallback to system font until CJK loads */
}
Solution 2: JavaScript Font Loading with Language Detection
Load the Chinese font only on non-Chinese systems to avoid unnecessary downloads for users who already have Chinese fonts installed:
// Load Chinese font conditionally based on browser language
(function() {
const lang = navigator.language || navigator.userLanguage || '';
const isChineseSystem = lang === 'zh-CN' || lang === 'zh' ||
lang.startsWith('zh-');
if (!isChineseSystem) {
// Detect if CJK fonts are already available on this system
const hasCjkFont = document.fonts.check('12px "PingFang SC"') ||
document.fonts.check('12px "Microsoft YaHei"') ||
document.fonts.check('12px "Noto Sans SC"');
if (!hasCjkFont) {
// Dynamically load Google Fonts for Chinese
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap';
link.onload = () => {
document.documentElement.classList.add('cjk-fonts-loaded');
};
document.head.appendChild(link);
}
}
})();
/* Global CSS with fallback strategy */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
}
/* Class added by JS when CJK font loads */
.cjk-fonts-loaded body,
.cjk-fonts-loaded [lang="zh"] {
font-family: 'Noto Sans SC', -apple-system, 'Segoe UI', Arial, sans-serif;
}
Advanced Detection with Canvas Font Check
For more reliable font presence detection, use a canvas-based approach:
function checkCjkFontAvailable(fontName) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// Measure text width with Latin font
context.font = `48px ${fontName}`;
const baseline = context.measureText('Hello, World!');
// Measure text that contains CJK characters
const cjkWidth = context.measureText('你好世界');
// If the CJK text is wider, the font doesn't have CJK glyphs
// (browser falls back to a different font, which has different metrics)
return Math.abs(baseline.width - cjkWidth) > 2 ||
(cjkWidth.width !== baseline.width);
}
// Usage
if (!checkCjkFontAvailable('PingFang SC') &&
!checkCjkFontAvailable('Microsoft YaHei')) {
loadChineseFont();
}
Solution 3: Self-Hosted Subsetted Font with @font-face
For environments where Google Fonts is blocked or unreliable, self-host a subsetted Chinese font:
Step 1: Download and Subset Using pyftsubset
# Install fonttools with brotli compression
pip install fonttools brotli zopfli
# Download Noto Sans SC from Google Fonts or use google-webfonts-helper
# Then subset to common characters
pyftsubset NotoSansSC-Regular.ttf \
--unicodes="U+4E00-9FFF,U+3400-4DBF,U+20000-2A6DF" \
--flavor=woff2 \
--output-file=NotoSansSC-subset.woff2 \
--layout-features=""
# For CJK Extension B (rare characters), omit for smaller size
pyftsubset NotoSansSC-Bold.ttf \
--unicodes="U+4E00-9FFF,U+3400-4DBF" \
--flavor=woff2 \
--output-file=NotoSansSC-Bold-subset.woff2
Step 2: Host and Reference
/* Self-hosted Chinese font with unicode-range descriptor */
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/NotoSansSC-subset.woff2') format('woff2');
unicode-range: U+4E00-9FFF, U+3400-4DBF, U+F900-FAFF;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/NotoSansSC-Bold-subset.woff2') format('woff2');
unicode-range: U+4E00-9FFF, U+3400-4DBF;
}
body {
font-family: -apple-system, 'Noto Sans SC', Arial, sans-serif;
}
The unicode-range descriptor tells the browser to download the font only when the page actually contains characters in those Unicode ranges. English-only pages will not request the CJK font at all.
Step 3: Generate Multiple Weights
#!/bin/bash
# Batch subset Chinese fonts for multiple weights
WEIGHTS=("Regular" "Medium" "Bold" "Light")
for weight in "${WEIGHTS[@]}"; do
pyftsubset "NotoSansSC-${weight}.ttf" \
--unicodes="U+4E00-9FFF,U+3400-4DBF" \
--flavor=woff2 \
--output-file="static/fonts/NotoSansSC-${weight}-subset.woff2"
done
System Font Fallback Strategy
Building the Complete Font Stack
The most robust approach combines multiple fallback layers:
/* Comprehensive Chinese font stack */
:lang(zh) {
font-family:
/* macOS/iOS */
'PingFang SC',
/* Windows */
'Microsoft YaHei',
/* Android */
'Noto Sans CJK SC',
/* Web font loaded conditionally */
'Noto Sans SC',
/* Linux */
'WenQuanYi Micro Hei',
/* Generic */
sans-serif;
}
/* English/Latin fallback for non-Chinese text */
:lang(en) {
font-family:
-apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Arial, sans-serif;
}
Performance Budgets
| Scenario | Font Strategy | File Size | FOUT Risk |
|---|---|---|---|
| Chinese-only audience | System fonts only | 0 KB (system) | None |
| Global audience, small CJK content | Google Fonts auto-subset | ~50-200 KB | Low |
| Global audience, heavy CJK content | Self-hosted subset | ~100-500 KB | Medium |
| Offline-capable app | Full Noto Sans SC | ~8-15 MB | None |
Testing Methodology
Test on Systems Without Chinese Fonts
Option 1: Docker Container
# Run a clean Ubuntu container without Chinese fonts
docker run -it --rm ubuntu:22.04 bash
# Install a browser for testing
apt update && apt install -y firefox-esr xvfb
# Test your website
firefox --headless https://your-website.com 2>/dev/null
# Check for tofu rendering
# Or use a screenshot comparison tool
Option 2: Browser DevTools
# Chrome DevTools method
# 1. Open Chrome DevTools (F12)
# 2. Go to Rendering tab (more tools → Rendering)
# 3. Check "Emulate CSS media feature prefers-color-scheme" (not directly relevant)
# 4. Check the Network tab for font requests
# 5. Look for Chinese fonts loading successfully
# For testing without local fonts:
# Open: chrome://settings/fonts
# Remove/disable any Chinese fonts from the list
Option 3: Temporarily Remove Chinese Fonts
# macOS — disable Chinese fonts via Font Book
# Open Font Book → select all Chinese fonts → Edit → Disable
# Linux — check available Chinese fonts
fc-list :lang=zh
# Temporarily remove them:
mv /usr/share/fonts/opentype/noto-cjk ~/noto-cjk-backup
fc-cache -f
# Windows — go to Settings → Time & Language → Language
# Remove East Asian language pack
Automated Testing Script
#!/bin/bash
# Automated Chinese font rendering test
# Usage: ./test-cjk-fonts.sh https://your-website.com
URL=$1
OUTPUT_DIR="./cjk-test-output"
mkdir -p $OUTPUT_DIR
echo "Testing CJK font rendering for: $URL"
# Use Puppeteer or Playwright for headless testing
node -e "
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// Console log font rendering info
page.on('console', msg => console.log('PAGE:', msg.text()));
await page.goto('$URL', { waitUntil: 'networkidle0' });
// Check which fonts are actually used for Chinese text
const fontInfo = await page.evaluate(() => {
const elements = document.querySelectorAll('[lang=\"zh\"], :lang(zh)');
const fonts = new Set();
elements.forEach(el => {
const style = window.getComputedStyle(el);
fonts.add(style.fontFamily);
});
return Array.from(fonts);
});
console.log('Fonts used for Chinese content:', fontInfo);
await page.screenshot({ path: '$OUTPUT_DIR/screenshot.png', fullPage: true });
await browser.close();
})();
"
echo "Screenshot saved to $OUTPUT_DIR/screenshot.png"
Testing Checklist
- Check Chinese characters render correctly on macOS (no Chinese fonts)
- Check on Windows English installation without East Asian pack
- Check on Linux without CJK fonts installed
- Verify font files load from network (check Network tab)
- Confirm no uncached subsequent visits (font caching works)
- Test with slow network (FOUT/font-display behavior)
- Test screen reader pronunciation of Chinese text
- Verify Japanese and Korean characters if applicable
- Test mixed Chinese-English text rendering
- Measure page load impact with and without Chinese fonts
Recommended CSS unicode-range Approach
/* Google Fonts handles unicode-range internally,
but you can also define explicit @font-face rules */
/* This font-face only loads when CJK characters are on the page */
@font-face {
font-family: 'CJK Fallback';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC&display=swap');
unicode-range: U+4E00-9FFF, U+3400-4DBF, U+F900-FAFF;
}
body {
font-family: -apple-system, 'CJK Fallback', Arial, sans-serif;
}
Recommended Approach
For most websites with Chinese content and international visitors, a layered approach works best:
<!-- Step 1: System font stack in CSS (instant, no network) -->
<!-- Step 2: Google Fonts as progressive enhancement -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
/* Final recommended font stack */
body {
font-family:
-apple-system, BlinkMacSystemFont,
'Segoe UI', Arial,
'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei',
sans-serif;
}
This stack:
- Uses system fonts on Apple devices (already have Chinese support)
- Uses Segoe UI on Windows
- Falls back to Arial for Latin text
- Uses Noto Sans SC (from Google Fonts) for systems without Chinese fonts
- Includes common Chinese system fonts (PingFang SC, Microsoft YaHei) for native rendering when available
Comments