A while back, I found that Matplotlib couldn’t display Chinese characters properly. While solving the problem, I learned a fair bit about how font rendering works. Here are my notes.

1. Encoding and Fonts

We know that to store characters in a computer, we assign a numeric code to each character. This artificial convention is called character encoding. Common encodings include ASCII, GBK, Unicode, and so on. Character encoding is the data representation of characters.

However, encoding alone is not enough to display characters on a computer. A symbol is a graphic — if we can’t determine what shape a character should take, we can’t see it on screen. Similarly, this mapping from character to graphic is called a glyph, and a collection of glyphs is a font. A font is the visual representation of characters.

So we have two different mapping relationships here. One is from character to character encoding, which determines how characters are stored. The other is from character to font, which determines how characters are displayed.

In simple terms, the process of displaying characters on a computer is the process of converting encoded data into glyph images.

2. The Fragmentation and Unification of Encodings

But this process isn’t really that simple. One reason is the series of mutually incompatible encoding schemes left over from the early days of computing.

Before the internet existed, different countries and regions developed their own character encoding schemes to display their writing systems on computers — for example, GB2312 for Simplified Chinese, Big5 for Traditional Chinese, Shift_JIS for Japanese, and so on. However, in the internationalized context of the internet, different encoding schemes created communication difficulties.

This is where the Unicode standard came in. Driven by the Unicode Consortium, Unicode aims to replace existing character encodings with a single standard. It organizes and encodes most of the world’s writing systems, allowing computers to process and display text with a universal, unified character set.

However, the default encoding on Windows is still GBK.

Unicode’s encoding space spans $[0, 17 \times 2^{16})$, though only a small portion is currently used. The space is divided into 17 planes, of which planes 4–13 are unused. The main content of the other planes is:

  • Plane 0 (0x0000–0x​FFFF), called the Basic Multilingual Plane, contains characters for almost all modern languages.
  • Plane 1 (0x10000–0x1FFFF), called the Supplementary Multilingual Plane, contains most historical scripts and symbols that are no longer or rarely used today.
  • Planes 2 and 3 (0x20000–0x​2FFFF, 0x30000–0x​3FFFF), called the Supplementary Ideographic Plane, contain CJK unified ideographs not included in earlier encoding standards.
  • Plane 14 (0xE0000–0xEFFFF) is the Supplementary Special-purpose Plane.
  • Planes 15 and 16 (0xF0000–0x​10FFFF) are Private Use Planes (e.g., Apple’s icon characters).

Although Unicode defines a unified character encoding scheme, storing raw Unicode code points directly is not very efficient. So Unicode needs further encoding, which gave rise to UTF (Unicode Transformation Format), including UTF-32, UTF-16, and UTF-8.

UTF-32 uses fixed-length 32 bits to encode Unicode. Given Unicode’s encoding space, only 21 bits are needed at most, so 11 bits in UTF-32 are always zero. This makes UTF-32 very space-inefficient. It’s mainly used in system-internal APIs.

UTF-16 is variable-length, encoding Unicode into 2 or 4 bytes. Since Plane 0 characters are more commonly used:

  • For Plane 0 characters, the Unicode code point is used directly.
  • For characters on other planes, 4 bytes are used. These 4 bytes form two 16-bit code units called a surrogate pair. The high surrogate’s first 6 bits are fixed as 110110, and the low surrogate’s first 6 bits are fixed as 110111. The remaining 10 bits of each represent the character’s Unicode code point minus 0x10000.

UTF-8 is the most common encoding on the internet. Also variable-length, it uses 1 to 4 bytes to encode Unicode. The encoding rules are:

  • For a single-byte symbol, the first bit is 0, and the remaining 7 bits are the Unicode code point. This makes UTF-8 backwards-compatible with ASCII.
  • For an n-byte symbol (n > 1), the first n bits of the first byte are all 1, the (n+1)-th bit is 0, and the first two bits of subsequent bytes are 10. The remaining bits hold the Unicode code point.

3. How Fonts Are Organized

Encoding alone isn’t enough for text display. We also need to map encoded data to corresponding graphics. So let’s look at how fonts are stored on a computer.

Fonts are stored in font files of various formats, including TrueType (.ttf), OpenType (.otf), Type 1 (.pfb), and more.

Some font files have the .ttc extension, which stands for TrueType Collection. These contain multiple TrueType fonts.

These font files store the glyphs corresponding to characters. Based on how they store glyphs, font files fall into bitmap fonts and vector fonts. Bitmap fonts represent each glyph as a 2D pixel grid, while vector fonts describe glyphs with mathematical equations. Vector fonts can be scaled without distortion and are the predominant format today.

However, even though vector fonts have no concept of pixels, most screens display via pixels. So to actually display a vector font, it must first be rasterized — converted into bitmap form.

How do we use a font file to display characters on screen? As mentioned earlier, there are two mapping relationships: character → encoding and character → font. But we can’t directly look up a font given just a character encoding, because different encoding schemes mean the same character may have different encodings. Given only an encoding, we can’t determine the corresponding graphic.

So we first need to convert the character encoding into an encoding-independent glyph index. Then we look up the glyph by that glyph index. The data structure that maps character encodings to glyph indices is called a cmap, and a font may contain multiple cmaps. When rendering text on screen, you must first specify the encoding being used, so the correct cmap can be selected to convert the encoding to a glyph index.

With this understanding of font organization, we can explain some common font problems:

  • Why do we see garbled text? Because the wrong encoding was specified. This causes the wrong cmap to be chosen when looking up the font, which in turn indexes the wrong glyph.
  • Why can’t some fonts display Chinese? It’s impossible to design glyphs for every character when creating a font. Fonts designed for English may not include CJK glyphs, so Chinese text can’t be displayed.

4. Font Configuration on Linux

Fontconfig is the commonly used font configuration tool on modern Unix-like operating systems, with the corresponding commands being fc-*. It’s very simple to use.

First, place font files in directories that Fontconfig can discover. By default, these are /usr/share/fonts/ (system-wide) and ~/.local/share/fonts (per-user). Fontconfig recursively scans all font files under these paths, so you can create subdirectories to categorize fonts.

The basic process for adding new fonts:

  1. Place the new font file(s) in a Fontconfig-discoverable directory.
  2. Run fc-cache to update the font cache so Fontconfig recognizes the new fonts. Use -v to see the update progress, or -f to force a rebuild of all font caches.
  3. Use fc-list to verify the new font is installed. For example, fc-list SimSun checks if SimSun (宋体) is present.

5. Displaying Chinese in Matplotlib

Although this section isn’t closely related to the previous content, it was the reason I wrote this article in the first place, so here it is.

Matplotlib’s configuration is stored in plt.rcParams. The font-related setting is font.sans-serif, which specifies the sans-serif fonts Matplotlib uses. The default list of fallback fonts does not support Chinese.

import matplotlib.pyplot as plt

print(plt.rcParams["font.sans-serif"])
# ['DejaVu Sans', 'Bitstream Vera Sans', 'Computer Modern Sans Serif', 'Lucida Grande', 'Verdana', 'Geneva', 'Lucid', 'Arial', 'Helvetica', 'Avant Garde', 'sans-serif']

To fix this, we need to replace this configuration with a font that supports Chinese. Here we’ll use SimHei (黑体). First install the SimHei font as described in the previous section. Then simply set font.sans-serif to ["SimHei"]:

plt.rcParams["font.sans-serif"] = ["SimHei"]

Don’t append SimHei to the end of the existing list — Matplotlib will pick an earlier font in the list instead.