4.4 文本渲染与字体 本节摘要:Matplotlib 的文本渲染,本质是把一串字符交给某个字体文件去"找字形"。标题、刻度、图例、注释最终都要落到具体的字体上。中文在默认配置下常常变成空心方框,根源就是字体族里找不到中文字形。这一节从"中文为什么变方块"讲起,梳理字体家族、全局与局部配置、自定义字体加载几条路径,再讲 mathtext 数学公式渲染,最后落到跨平台出图时的字体嵌入与兼容性坑,让中文标签不再掉链子。 本节导航 阅读完本节,你应当能够: 说清中文显示为方框的直接原因,并区分"字体族选错"和"字形缺失"两种情况。 用 rcParams、FontProperties、函数参数三种方式设置字体,并分清全局与局部的边界。 加载自定义字体文件,并列出系统可用字体做核对。
本节摘要:Matplotlib 的文本渲染,本质是把一串字符交给某个字体文件去"找字形"。标题、刻度、图例、注释最终都要落到具体的字体上。中文在默认配置下常常变成空心方框,根源就是字体族里找不到中文字形。这一节从"中文为什么变方块"讲起,梳理字体家族、全局与局部配置、自定义字体加载几条路径,再讲 mathtext 数学公式渲染,最后落到跨平台出图时的字体嵌入与兼容性坑,让中文标签不再掉链子。
阅读完本节,你应当能够:
先看一个几乎所有新手都撞过的场面:代码里写了 plt.title("月度销量"),图上却印着四个空心方块。图能画出来,线条、坐标都对,唯独中文没显示。这个现象和印刷厂很像——排版师傅手里只有西文字模,你递过去一张中文底稿,他只能摆出几个"缺字"的空铅位。
Matplotlib 的文本引擎做的事类似。每个 Text 对象手里握着两样东西:一串字符,和一组字体属性。真正绘制时,它要把字符逐个映射到"字形",也就是字体文件里那个字的实际轮廓。映射的依据是字体属性里指定的字体族,比如 sans-serif、serif、monospace。字体族不是某一个具体字体,而是一个"候选名单":拿 sans-serif 来说,它在不同系统上可能对应 DejaVu Sans、Arial、Liberation Sans 等一串字体。
关键就在这里。Matplotlib 自带的默认字体 DejaVu Sans,覆盖了拉丁字母、希腊字母和一部分符号,但几乎没有 CJK(中日韩)字形。当标题里出现汉字,引擎顺着候选名单一个个问"你这里有没有'月'这个字",名单里的字体都答没有,引擎没辙,就画一个空心方框当作占位。所以你看到方框,不是 Matplotlib 坏了,也不是编码乱了,纯粹是"没找到含这个字的字体"。
import matplotlib as mpl import matplotlib.pyplot as plt # 看看当前 sans-serif 族的候选名单 print(mpl.rcParams['font.sans-serif']) plt.title("月度销量") # 默认名单里没有中文字体,标题显示为方框 plt.plot([1, 2, 3], [3, 1, 2]) plt.show()
解决方向也就清楚了:往候选名单里塞一个真正带中文字形的字体。Windows 上有黑体、宋体、微软雅黑,macOS 上有苹方、冬青黑体,Linux 上常见文泉驿、思源黑体。把它们加进 font.sans-serif 列表,或者干脆把 font.family 指定成某个中文字体名,方框就会变回汉字。
⚠️ 常见坑:把字体名写错一个空格也会失效。字体名必须和系统里登记的"显示名"完全一致,而不是文件名。比如"微软雅黑"对应的英文显示名是 Microsoft YaHei,两者都可能被接受,但"微软雅黑.ttf"这种带扩展名的写法基本不会被识别。拿不准就用
font_manager把系统字体列出来核对一遍。
Matplotlib 给字体配置留了三条路,作用范围各不相同。选哪条,取决于你是想"全图统一"还是"只改一处"。
第一条路是改 rcParams,这是全局配置。rcParams 是一个运行时参数字典,图表所有默认值都从这里来。改了它,之后画的每一张图都会用新字体,直到你改回来或进程结束。
import matplotlib as mpl import matplotlib.pyplot as plt mpl.rcParams['font.family'] = 'Microsoft YaHei' mpl.rcParams['font.size'] = 12 mpl.rcParams['axes.unicode_minus'] = False # 避免负号显示成方块 plt.title("全局字体已经生效") plt.plot([1, 2, 3], [2, 4, 1]) plt.show()
这里顺手带上 axes.unicode_minus 是因为它是个高频坑:Matplotlib 默认用 Unicode 的减号字符画负号,有些中文字体不含这个字形,负号也会变方块,设成 False 让它改用 ASCII 的连字符。和字体问题同源同解,一并处理最省事。
第二条路是 FontProperties 对象,走局部。你建一个字体属性对象,只把它传给某一个标题、某一段注释。它不碰全局默认值,别的文本该怎么显示还怎么显示。
from matplotlib.font_manager import FontProperties title_font = FontProperties(family='SimHei', size=16, weight='bold') note_font = FontProperties(family='KaiTi', size=10) plt.title("黑体标题", fontproperties=title_font) plt.text(2, 2, "楷体注释", fontproperties=note_font)
第三条路是直接在文本函数里写参数,算是最省事的一种局部配置。title、xlabel、text、annotate 这些函数大多直接接受 fontsize、fontweight、fontstyle、family 这些关键字,适合快速微调。
plt.title("直接指定参数", fontsize=18, fontweight='bold', family='monospace') plt.xlabel("横轴", fontsize=14, fontstyle='italic')
这三条路不是互斥的,多数实战是混着用:先用 rcParams 把全局默认定成中文字体,再用函数参数对个别标题调大小粗细。
自定义字体是第四块拼图。系统里没有你想要的字体时,可以手动加载字体文件,把它注册进字体管理器。
from matplotlib import font_manager font_manager.fontManager.addfont(font_path) # font_path 指向字体文件 name = font_manager.FontProperties(fname=font_path).get_name() print(name) # 拿到登记后的显示名,再去 rcParams 里用
加载之后,可以用 font_manager.fontManager.get_font_names() 把系统里所有可用字体名都列出来,核对你要用的名字到底叫什么。也可以反过来用 findfont 查某个字体名到底命中了哪个字体文件,出图前确认一遍,能少踩很多"改了半天没生效"的坑。
| 配置方式 | 作用范围 | 适用场景 | 代价 |
|---|---|---|---|
| rcParams | 全局 | 全图统一默认字体 | 影响后续所有图 |
| FontProperties | 单个文本 | 只改某处特殊字体 | 代码略长 |
| 函数参数 | 单个文本 | 快速微调大小粗细 | 可设的属性有限 |
| 自定义字体加载 | 全局或局部 | 系统没有的字体 | 部署时要带字体文件 |
💡 关键直觉:全局管"默认",局部管"例外"。先定全局中文字体,再用局部参数覆盖个别地方,是最省心也最不容易乱的组合。
科研图里常要标公式:坐标轴写个 ρ、标题带个 e 的负指数、图例里出现积分号。Matplotlib 对此给了两套引擎。
内置的那套叫 mathtext,用法是拿一对美元符号把内容包起来,Matplotlib 就会按 LaTeX 的一个子集去解析并渲染。它不需要你装任何额外软件,开箱即用,速度也快,代价是语法覆盖有限,复杂排版(比如对齐的方程组、自定义宏)它做不了。
import matplotlib.pyplot as plt plt.title(r'衰减曲线 $y = A e^{-\lambda t}$') plt.xlabel(r'时间 $t$') plt.ylabel(r'幅度 $A$') plt.text(1, 0.5, r'积分 $\int_0^\infty e^{-x^2} dx$')
注意字符串前面的 r,它告诉 Python 别去解析反斜杠,把 \lambda、\int 原样交给 mathtext。漏了这个 r,反斜杠转义会先捣一次乱,公式十有八九报错或显示异常。
另一套是完整 LaTeX 渲染,靠 text.usetex 打开。它把文本交给系统里安装的 TeX 引擎去排版,能支持 mathtext 做不到的完整语法,出来的效果也更接近论文排版。
import matplotlib as mpl mpl.rcParams['text.usetex'] = True
两套怎么选?我个人的建议是:能 mathtext 解决就别上 usetex。usetex 的收益是完整度和美观,代价是必须装一整套 TeX 发行版、每次绘图都要等它编译、还要处理它和字体配置的联动。多数公式图,mathtext 那点语法已经够用。
⚠️ 常见坑:mathtext 和 usetex 是互斥的两条路,
text.usetex打开后,mathtext 的字符串会被丢给 TeX 处理,行为会变。另外中文字体配置和 usetex 的组合比较麻烦——TeX 默认不认中文字体,想中文和公式同框,往往得单独配置 TeX 的 CJK 支持,否则中文照样变方块。
字体问题最容易在"换一台机器"时爆出来。你本机配好了微软雅黑,图存成 PNG 发给同事,一切正常;可要是把绘图脚本放到 Linux 服务器上跑,或者直接交给别人用他的机器复现,对方系统里没有微软雅黑,图又变回方框了。
这背后是两种输出格式的差异。PNG 这类位图格式,出图时把文字光栅化成像素点,字形已经"烧"进图里了,别人打开不依赖字体。PDF、SVG 这类矢量格式不同,它们要么把字体嵌入文件,要么只记一个字体引用、等打开时再在读者机器上找。字体没嵌入、读者机器又没装,就会看到字体被替换,甚至中文消失。
针对跨平台,有三条实用做法。
一是给 font.family 传一个列表而不是单个名字,让它按顺序回退:
mpl.rcParams['font.family'] = ['Microsoft YaHei', 'SimHei', 'WenQuanYi Micro Hei', 'sans-serif']
这样 Windows 上命中第一个,Linux 上跳过后两个,最后回退到通用 sans-serif。列表式的回退链是应对环境差异最稳的一招。
二是尽量选跨平台可得的字体。思源黑体、文泉驿、Noto 系列在中英文环境下都比较容易找到,或者干脆把字体文件随项目一起分发、用 addfont 手动加载,让所有环境用同一份字体,效果最一致。
三是矢量出图时留意嵌入设置。保存 PDF 时把字体子集嵌入文件,能保证拿到文件的人看到同样的字;SVG 则可以要求把文本转成路径,彻底摆脱对读者端字体的依赖,代价是文件变大、文字不再可选可搜索。
plt.savefig("figure.pdf") # 矢量格式,注意字体嵌入 plt.savefig("figure.svg")
出图前最好先确认一件事:当前这张图实际用了哪个字体。可以用 findfont 反查,看看它到底命中了哪个字体文件,再决定要不要调整。
💡 关键直觉:位图把结果"烧死"在像素里,矢量把"字体引用"留给打开方。要分享就给位图,要可编辑的矢量就确保字体嵌进去,否则宁可把文字转路径。
字体选对了,还得把字摆对地方。文本对象有两个对齐参数:horizontalalignment 管水平,verticalalignment 管垂直。水平对齐 left、center、right 决定文字锚点在哪,垂直对齐 top、center、bottom 决定文字相对给定坐标是压在上面、居中还是垫在下面。默认的组合在很多场景下会让字"漂"出你想放的位置,尤其是给数据点加标签时,字常常盖住点本身。
plt.text(1.5, 4.5, '居中', ha='center', va='center') plt.text(1.5, 5, '靠左', ha='left', va='bottom')
rotation 是另一件常用工具。横轴刻度标签一多,互相挤在一起,转个 45 度立刻清爽。它不是装饰,是解决标签重叠的实用手段。
plt.xticks(rotation=45)
要给某个数据点加带箭头的说明,用 annotate。它比 text 多一个 xy 参数,指定箭头指向哪,再配一个 arrowprops 控制箭头样式,就能把"这个点很关键"这样的信息钉在图上。
plt.annotate('最小值', xy=(3, 1), xytext=(2, 2.5), arrowprops=dict(arrowstyle='->', color='black'))
这三样——对齐、旋转、箭头注释——是文本渲染里最常用的"摆位"操作。它们和字体选择是两个维度:字体决定"字长什么样",摆位决定"字放在哪、怎么指向"。一张信息密集的图,往往是字体不出错、摆位也讲究,两者缺一不可。
sans-serif 等家族会映射到一串具体字体,按顺序回退。axes.unicode_minus 要记得关:它决定负号用 Unicode 还是 ASCII,是中文图里的高频坑。font.family 写成列表,配合跨平台字体或随项目分发字体,最稳。字体是图表的"脸面",解决了它,下一步我们跳到 Matplotlib 的周边生态,看看 Seaborn、Cartopy、mplfinance 这些扩展库是如何在它之上各管一摊的。