Skip to main content

配置 matplotlib 中文字体

matplotlib 中作图时常遇到中文显示乱码的问题,本文探索一下原因,并在文末给出一个通用的解决方案。

import os
import matplotlib as mpl
import matplotlib.pyplot as plt

mpl.FontManager

matplotlib 中有个字体管理类,可以查询、添加、删除字体,包括系统安装、matplotlib 安装、用户自己安装的 3 类字体。

fm = mpl.font_manager.FontManager()
print(len(fm.ttflist))
print(fm.ttflist[0])
print(len(fm.afmlist))
print(fm.afmlist[0])

print(f"manager 找到了 {len(fm.ttflist)} 个 ttf 字体,{len(fm.afmlist)} 个 afm 字体。")
551
FontEntry(fname='/data/kevin/workspace/venv/venv310/lib/python3.10/site-packages/matplotlib/mpl-data/fonts/ttf/DejaVuSans-Bold.ttf', name='DejaVu Sans', style='normal', variant='normal', weight=700, stretch='normal', size='scalable')
138
FontEntry(fname='/data/kevin/workspace/venv/venv310/lib/python3.10/site-packages/matplotlib/mpl-data/fonts/afm/putb8a.afm', name='Utopia', style='normal', variant='normal', weight='bold', stretch='normal', size='scalable')
manager 找到了 551 个 ttf 字体,138 个 afm 字体。

字体由 FontEntry Class 封装,包括 fname、name、style… 等成员

  • ttf: 苹果公司和微软公司共同开发的一种电脑轮廓字体(曲线描边字)类型标准。
  • afm:Adobe 公司开发,并包含了有关 Type 1 PostScript 字体的度量特性的信息。AFM 结构需要一个定义了每一个字体符号的样式的控制模版。它主要被用于 UNIX。

通过 manager 还能查询当前 matplotlib 使用的默认字体:

print(fm.defaultFamily)
print(fm.defaultFont)
{'ttf': 'DejaVu Sans', 'afm': 'Helvetica'}
{'ttf': '/data/kevin/workspace/venv/venv310/lib/python3.10/site-packages/matplotlib/mpl-data/fonts/ttf/DejaVuSans.ttf', 'afm': '/data/kevin/workspace/venv/venv310/lib/python3.10/site-packages/matplotlib/mpl-data/fonts/pdfcorefonts/Helvetica.afm'}

如果不出意外,使用的都是 matplotlib 自带的字体,DejaVu Sans 和 Helvetica,文件路径在 matplotlib 安装包所在的文件夹下。

DejaVu 字体是历史悠久的开源字体,github 上下载该字体,3M 左右,有体积上看也能猜出来不可能包含中日韩等东亚字体。在其 官网 上可以查询它支持的字符集。

三类字体

如何分辨系统安装、matplotlib 安装、用户自己安装的 3 类字体?

可以从 fname (文件路径)入手,缺点是如果用户强制安装到系统路径就分辨不了:

ttflist = fm.ttflist
systtf = [t for t in ttflist if t.fname.startswith("/usr/")]
mplttf = [t for t in ttflist if t.fname.find("matplotlib") != -1]
usrttf = [t for t in ttflist if t.fname.startswith(os.environ["HOME"])]

print(f"{len(systtf)} 个系统安装字体")
print(f"{len(mplttf)} 个Matplotlib安装字体")
print(f"{len(usrttf)} 个用户安装字体")
353 个系统安装字体
38 个Matplotlib安装字体
160 个用户安装字体

字体这么多,其实是字体文件,基本上每个字体都有多种 style,比如:极细、细、正常、粗、极粗、斜体……6、7 个都常用,所以每种字体都多个文件。

现在来看看 mpl 默认的字体 DejaVu Sans 在哪里:

if len([t for t in systtf if t.name.find("DejaVu Sans") != -1]) > 0:
print("systtf 中发现 DejaVu Sans")
if len([t for t in mplttf if t.name.find("DejaVu Sans") != -1]) > 0:
print("mplttf 中发现 DejaVu Sans")
if len([t for t in usrttf if t.name.find("DejaVu Sans") != -1]) > 0:
print("usrttf 中发现 DejaVu Sans")
systtf 中发现 DejaVu Sans
mplttf 中发现 DejaVu Sans

在 Linux 中可能会在系统和 mpl 两个集合中发现 DejaVu Sans,因为这个字体也是 Linux 默认自带、推荐的;

在 Windows 中可能只会在 mpl 自己的集合中发现了。

发现中文字体

众多的字体中怎么发现哪些是中文字体?

  1. fc-list :lang=zh 找到字体 —— 但只能用于 Linux
  2. 名字中包含 CN、zh —— 但很多中文字体不满足
  3. 看文件体积,我觉得至少要大于 5M 吧 —— 但只能作为参考,不能作为依据
from subprocess import Popen, PIPE

_p = Popen("fc-list :lang=zh", shell=True, stdout=PIPE, encoding="utf-8")
zhs = _p.communicate()[0]

# 捕获回来的 shell 打印是个字符串,用 `\n` 表示新行,字符串很长
# 首先分割逐个提取出来。
zhs = [zh for zh in zhs.split("\n") if zh != ""] # 发现有空的,去掉
print(f"发现 {len(zhs)} 个中文字体文件")

# 进一步提取出文件路径,放在 zhsfn 数组中
zhsfn = [zh.split(":")[0] for zh in zhs]

# 提取出名称,放在 zhsn 数组中,并去重
zhsn = list(set([zh.split(":")[1].split(",")[0] for zh in zhs]))

print(f"中文字体(去重后):\n共 {len(zhsn)}, \n前3个 {zhsn[:3]}")
发现 234 个中文字体文件
中文字体(去重后):
共 41,
前3个 [' Noto Sans CJK SC', ' AR PL UKai CN', ' AR PL UKai HK']

上面查询出了本机安装的所有中文字体,但特别需要注意的是:并非 fc-list :lang=zh 能够找到的字体,Matplotlib 都能使用。

下面定义一个用 fontEntry 集合匹配 zhsfn 函数,来查找入参(FontEntry 数组)与 zhsfn 的交集,以此可找出 Matplitlib 能够使用的中文字体:

def intersection_fclistzh_entry(fontEntries):
_fs = [t.name for t in fontEntries if t.fname in zhsfn]
# _fs = sorted(_fs, key=lambda f: f[1])
# _fs.reverse()
return list(set(_fs)) # 去重

上面函数中做了去重,就是每种字体的多种 style 只算一个 —— 这也是我们口头上认为的一个字体。

OK,下面可以分别查找 3 级字体中各自的中文字体:

  • 系统安装的中文字体:
intersection_fclistzh_entry(systtf)
['Droid Sans Fallback',
'AR PL UKai CN',
'Noto Serif CJK JP',
'AR PL UMing CN',
'Noto Sans CJK JP']
  • Matpltlib 安装的中文字体(不出意外的话没有):
intersection_fclistzh_entry(mplttf)
[]
  • 用户安装的中文字体:
intersection_fclistzh_entry(usrttf)
['Sarasa Mono SC Nerd',
'Sarasa Mono Slab J',
'Sarasa Mono TC',
'Sarasa Mono Slab TC',
'Sarasa Mono SC',
'Sarasa Mono Slab SC',
'Sarasa Mono CL',
'Source Han Sans CN',
'Sarasa Mono Slab CL',
'LXGW WenKai Mono',
'Sarasa Mono Slab K',
'Source Han Serif SC',
'Sarasa Mono K',
'Sarasa Mono HC',
'Source Han Sans HW SC',
'LXGW WenKai',
'Sarasa Mono Slab HC',
'Sarasa Mono J']

总共交集多少个?

len(intersection_fclistzh_entry(fm.ttflist))
23

这里的数字(23),比上面所有的数字(41)要少一些,说明 Matplotlib 并不能认出所有已安装的字体,中文字体也就同理。

配置 plot 字体

import matplotlib.pyplot as plt

plot 绘图只是 matplotlib 中的一个功能模块,plot 会配置自己的参数来定义绘图中所用到的字体。

plot 的参数定义在全局变量 plt.rcParams 中,包括日期格式、 x/y 轴的显示、figure 的显示……其中也有 font 字体的配置:

plt.rcParams["font.family"]
['sans-serif']

说明 plot 默认使用无衬线字体。字体一般分为 5 类:

  • serif: 衬线
  • sans-serif: 无衬线
  • monospace: 等宽
  • cursive:
  • fantasy: 艺术字

继续查看无衬线字体有哪些:

plt.rcParams["font.sans-serif"][:5]
['DejaVu Sans',
'Bitstream Vera Sans',
'Computer Modern Sans Serif',
'Lucida Grande',
'Verdana']

继续查看无衬线字体中哪些是中文的:

def get_font_entry(font_names: [str]):
result = []
for fn in font_names:
result += [entry for entry in fm.ttflist if entry.name == fn]

return result

intersection_fclistzh_entry(get_font_entry(plt.rcParams["font.sans-serif"]))
[]

不出意外,没有。

添加 plot 中文字体

origin = plt.rcParams["font.sans-serif"]
origin[:3]
['DejaVu Sans', 'Bitstream Vera Sans', 'Computer Modern Sans Serif']

没有中文字体,plot 中的绘图遇到中文就是乱码,解决的办法有:

  • baidu 到的常见方式:
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["font.sans-serif"]
['SimHei']

SimHei 是 Windows 中常见的黑体中文,Linux 没有,需要安装,这种方式跨操作系统兼容性不强,不好移植。

  • 修订 1
plt.rcParams["font.sans-serif"] = ["SimHei"] + origin
plt.rcParams["font.sans-serif"][:3]
['SimHei', 'DejaVu Sans', 'Bitstream Vera Sans']

这样万一 SimHei 没有,其余字体还能继续用,不至于全军覆没。

  • 修订 2
plt.rcParams["font.sans-serif"] = intersection_fclistzh_entry(fm.ttflist) + origin
plt.rcParams["font.sans-serif"][:3]
['Sarasa Mono SC Nerd', 'Sarasa Mono Slab J', 'Sarasa Mono Slab SC']

这样就能保证 Matplotlib 正确使用中文了。

plt.bar(["猫", "狗", "鸡"], [3, 2, 1])
plt.show()

  • 修订 3
ff = plt.rcParams["font.family"][0]
plt.rcParams[f"font.{ff}"] = (
intersection_fclistzh_entry(fm.ttflist) + plt.rcParams[f"font.{ff}"]
)
plt.rcParams["font.sans-serif"][:3]
['Sarasa Mono SC Nerd', 'Sarasa Mono Slab J', 'Sarasa Mono Slab SC']

这样的代码更具通用性一些,万一 font.family 被其他代码修改为无衬线字体,也能兼容。

所以,Linux 最终可以这样实现:

from subprocess import Popen, PIPE

_p = Popen("fc-list :lang=zh", shell=True, stdout=PIPE, encoding="utf-8")
zhs = _p.communicate()[0]
zhs = [zh for zh in zhs.split("\n") if zh != ""]
zhsfn = [zh.split(":")[0] for zh in zhs]

fm = mpl.font_manager.FontManager()
ff = plt.rcParams["font.family"][0]
intersection = [t.name for t in fm.ttflist if t.fname in zhsfn] # 交集,得到 mpl 可用的中文

plt.rcParams[f"font.{ff}"] = intersection + plt.rcParams[f"font.{ff}"]
plt.bar(["猫", "狗", "鸡"], [3, 2, 1])
plt.show()

如果你的操作系统或使用环境明确,当然也可以用 intersection_fclistzh_entry(fm.ttflist) 查到某个中文字体,然后指定它。