之前使用 Matplotlib 画图的时候发现不能正常显示中文。在解决问题的过程中了解了一下字体显示的一些知识,在这里记录一下。

一、编码与字体

我们知道,为了让字符能够存储在计算机中,我们为每个字符分配对应的数码。这种人为约定称为字符编码。常见的编码包括 ASCII、GBK、Unicode 等等。字符编码是字符的数据表示。

然而,仅有编码依然不能在计算机中显示字符。因为符号是一种图形,若我们不能确定字符所对应的图形的样式,那么我们就无法在屏幕中看到字符。同样的,这种字符到对应图形的约定称为字形,字形的集合即字体。字体是字符的图像表示。

于是,这里我们就有了两种不同的映射关系。一种是字符到字符编码的映射关系,这一关系确定了字符的存储方式;另一种是字符到字体的映射关系,这一关系确定了字符的显示方式

因此,简单来说,计算机显示字符的过程就是将编码数据转换为字形图像的过程。

二、编码的分裂和统一

然而这一过程并不真的那么简单。原因之一便是从计算机技术早期遗留下来的一系列互不兼容的编码方式。

在互联网还未诞生的时候,各个国家和地区为了在计算机中显示自己的文字,各自开发出自己的字符编码方式,例如简体中文的 GB2312、繁体中文的 Big5、日文的 Shift_JIS 等等。然而在互联网的国际化场景下,不同的编码方式带来了交流上的困难。

所以这时候 Unicode 标准应运而生。Unicode 由统一码联盟推动,旨在使用 Unicode 取代现存的字符编码。该标准整理并编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字。

然而 windows 下的默认编码方式还是 gbk。

Unicode 编码空间区间为 $[0, 17 \times 2^{16})$,但目前只使用了其中很少一部分。该编码空间被划分为了 17 个平面,其中 4-13 号均未使用。其他平面的主要内容为:

  • 0 号平面(0x0000-0x​FFFF)称为基本多文种平面,包含了几乎所有现代语言的字符。
  • 1 号平面(0x10000-0x1FFFF)称为多文种补充平面,包括了绝大多数古代文字,现时已不再使用或很少使用的符号等。
  • 2、3 号平面(0x20000-0x​2FFFF、0x30000-0x​3FFFF)称为补充表意文字平面,用于中日韩统一表意文字中未被包含在早期编码标准中的文字。
  • 14 号平面(0xE0000-0xEFFFF)为特别用途补充平面
  • 15、16 号平面(0xF0000-0x​10FFFF)为私人使用平面。(例如苹果公司的图标字符)

Unicode 虽然制定了统一的字符编码方式,但是直接使用 Unicode 编码进行存储的效率并不高。因此需要对 Unicode 进行进一步编码,这就带来了 UTF(Unicode Transformation Format),包括 UTF-32、UTF-16 和 UTF-8。

UTF-32 使用定长的 32 位对 Unicode 进行编码。根据 Unicode 的编码空间我们知道,最多只需要 21 位即可表示所有编码。因此 UTF-32 中的 11 位始终为 0。这使得 UTF-32 的空间效率很低。所以这种编码方式主要用在系统的内部 API 中。

UTF-16 是变长的,这种方式将 Unicode 编码为 2 字节或 4 字节。由于 Unicode 中 0 号平面中字符更常被使用,因此在 UTF-16 中:

  • 对于 0 号平面字符,直接使用其 Unicode 编码。
  • 对于其他平面上的字符,则使用 4 个字节存储。这 4 个字节分为两个 16 位的编码单元,称为代理对。高位的代理的前 6 位固定为 110110,低位的代理的前 6 位固定为 110111。前后部分剩余的 10 位表示符号的 Unicode 编号减去 0x10000 的结果。

UTF-8则是互联网中最为常用的编码方式。UTF-8 同样是变长的,该方式会使用 1 到 4 个字节对 Unicode 进行编码。UTF-8 的编码规则如下:

  • 对于单字节的符号,字节的第一位设为 0,后面 7 位为符号的 Unicode 码。这使得 UTF-8 与 ASCII 兼容。
  • 对于 n 个字节的符号(n > 1),第一个字节的前 n 位都设为 1,第 n + 1 位设为 0,后面字节的前两位设为 10。剩下的位为符号的 Unicode 码。

三、字体的组织方式

仅有编码还不能实现文字的显示。我们还需要将编码数据映射到对应的图形。因此我们介绍字体在计算机中的存储方式。

字体被存放在不同格式的字体文件中。字体文件格式包括 TrueType(.ttf)、OpenType(.otf)、Type 1(.pfb)等等。

有的字体文件后缀名为 .ttc,意为 TrueType Collection。其中包含了多个 TrueType 字体。

在这些字体文件中,会存储字符所对应的字形。按照存储方式的不同,字体文件可分为点阵字体矢量字体。点阵字体的每一个字形由二维像素表示,而矢量字体的字形则由数学方程描述。矢量字体可以进行缩放而不会产生变形,是目前主要使用的方式。

然而,尽管矢量字体中并没有像素的概念,但是大部分屏幕却是通过像素点进行显示的,因此想要真正显示矢量字体,还需要进行光栅化操作,将其转化为点阵形式。

我们要如何使用字体文件在屏幕显示字符呢?之前我们提到,存在两种不同的映射关系,分别将字符映射到字符编码和字体。但是我们还不能直接根据字符编码找到对应的字体。这是因为存在不同的字符编码方式使得同一字符可能有着不同的字符编码。只给出字符编码,我们不能确定对应的图形。

因此,我们需要首先将字符编码转换为编码无关的字形索引。再根据字形索引查找对应的字形。实现字符编码到字形索引的转换的数据结构称为 cmap,一个字体中可能会包括多个 cmap。在将字符显示到屏幕时,需要先指定当前使用的编码方式,这样才可以选择对应的 cmap 将编码转换为字形索引。

了解了字体的组织方式后,我们可以解释一些经常会遇到的字体问题:

  • 为什么会出现乱码?因为设定了错误的编码方式。这导致在字体文件中查找的时候选择了错误的 cmap,进而索引到了错误的字形。
  • 为什么一些字体无法显示中文?在设计字体时不可能为所有字符都设计对应的字形,因此为英文设计的字体中可能并不包含中文的字形,这时中文便无法正常显示。

四、Linux 下的字体配置

Fontconfig 是现代类 Unix 操作系统中常用的字体配置工具,对应的命令为 fc-*。其使用方法十分简单。

首先,我们需要将字体文件放置在 Fontconfig 可以识别的路径下。默认情况下为 /usr/share/fonts/~/.local/share/fonts,分别对应所有用户可用和单个用户可用。Fontconfig 会递归扫描这些路径下的所有字体文件,因此可以选择创建子目录为字体文件分类。

添加新字体的基本流程如下。

  1. 首先,将新的字体文件放置在 Fontconfig 可以识别的路径下。
  2. 使用命令 fc-cache 更新字体缓存,使 FontConfig 识别新的字体。可以使用 -v 选项查看更新情况,使用 -f 选项强制更新所有字体的缓存。
  3. 使用 fc-list 命令查看新字体是否安装成功。例如 fc-list SimSun 查找宋体是否存在。

五、Matplotlib 显示中文

虽然这部分和前面的内容关系不大,但毕竟是本篇文章的起因,所以还是写在这里了。

Matplotlib 中的配置内容保存在 plt.rcParams 中。其中和字体相关的是 font.sans-serif。此字段设置了 Matplotlib 使用的无衬线字体。默认情况下的一系列候选字体并不支持中文。

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']

为此,我们需要将该配置更换为支持中文的字体,这里我们选择黑体(SimHei)。首先我们需要按照上一节的内容安装黑体字体,之后,我们只需要重新设置 font.sans-serif 的取值为 ["SimHei"] 即可。

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

注意不要将 SimHei 添加到原列表的末尾,否则 Matplotlib 会选择更靠前的可选字体。