Skip to main content

Fixing Chinese Characters Displaying as Squares on Non-Chinese Systems

Published: October 7, 2023 Updated: May 8, 2026 Larry Qu 9 min read

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
/* 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;
}

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:

  1. Uses system fonts on Apple devices (already have Chinese support)
  2. Uses Segoe UI on Windows
  3. Falls back to Arial for Latin text
  4. Uses Noto Sans SC (from Google Fonts) for systems without Chinese fonts
  5. Includes common Chinese system fonts (PingFang SC, Microsoft YaHei) for native rendering when available

Resources

Comments

👍 Was this article helpful?