Linux fontconfig 的字体匹配机制

2020年10月31日

Linux 桌面程序使用字体的方式,受 fontconfig 的影响和控制。为了理解 fontconfig 的实际作用,我们先从 fontconfig 的基础配置和规则起步。为了深入到 fontconfig 的原理中,我们将分析桌面程序和 fontconfig API 的关系。最后,我们还将分析 fc-match 和 Firefox 的部分源码,来理解它们是怎么和 fontconfig 协同工作的。

我想,能花心思看这篇文章的读者,已经是对字体有好奇心并且有一定了解的人,多少掌握着一些字体知识。不过我觉得为了避免引起误解,有必要简单交代一下。

一个字体文件,可以提供多个字体族名 (family)。比如 Arch Linux 用户在安装 wqy-microhei 后,系统端增加了 wqy-microhei.ttc 这个字体文件,分别提供「WenQuanYi Micro Hei」「文泉驛微米黑」,「文泉驿微米黑」三个字体族名,它们是一个意思。我们可以运行 fontconfig 提供的命令行工具 fc-list 去查看系统上已安装的字体已经它们对应的字体族名。

至于 sans-serif,serif,monospace,则是三个通用字体族名 (generic family),它们不是真实存在的字体,而是分别指示程序去使用无衬线、衬线、等宽字体。那么桌面程序又是如何知道具体使用哪些字体呢?它只需要去查询 fontconfig 就行了。由于它们必定要经过 fontconfig 的查询流程后才能使用字体,所以我们可以通过 fontconfig 的配置去精准控制程序使用的字体。

在介绍 fontconfig 前,需要先熟悉 fontconfig 的调试方法。任何使用 fontconfig 的程序,可以指定环境变量FC_DEBUG的值去打印 fontconfig 特定的调试信息。另外,fontconfig 软件包提供的一个命令行工具 fc-match,在获得调试信息和测试规则方面都很有帮助。如果想要理解 fontconfig,就最好熟练使用它。现在不清楚它们是什么不要紧,后文中会反复出先它们的身影。

一次简单的执行流程

当我们设置桌面程序使用的字体后,这些程序绘制文本的时候要从哪里找到字体呢?系统上的现代桌面程序,都使用 fontconfig 来查找字体。当我们给桌面程序设置字体后,桌面程序会以 font pattern 的形式将设置传递给 fontconfig。fontconfig 会按照自身配置的规则对这个 font pattern 进行修改,输出返回结果给桌面程序。这样,桌面程序就知道该系统使用的字体,并通过 FreeType 等渲染引擎绘制文字。

在这里要明确一点,给 GTK 程序、Qt 程序、虚拟终端的配置文件中设置它们使用的字体,和设置 fontconfig 的配置文件是两码事。虽然在一些桌面环境的全局设置中,设置字体会同时修改 GTK、Qt、fontconfig 的配置。

此外,桌面程序可以完全不遵守 fontconfig,或者说,程序可以不使用 fontconfig 来查找字体。当然,这种情况很少,现代桌面程序基本上都会遵守我们的 fontconfig 配置。

比如,我们设置虚拟终端使用 monospace 字体。虚拟终端必须使用 monospace 字体,否则非等宽的文本会重叠在一起。当我使用虚拟终端 Alacritty 的时候,它的默认配置里已经设置 monospace 字体了:family: monospace。monospace 不是一个真正的字体,它要怎么知道具体使用哪些字体呢?fontconfig 需要提供一个 font pattern 给它。所以,它会将 monospace 放入 font pattern 中,传递给 fontconfig。以打印调试信息的方式运行 alacritty

FC_DEBUG=4 alacritty

fontconfig 就会打印调试信息,其中可以看到:

FcConfigSubstitute Pattern has 6 elts (size 16)
    family: "monospace"(s)
    slant: 0(i)(s)
    weight: 80(i)(s)
    pixelsize: 19.1667(f)(s)
    lang: "en"(w)
    prgname: "alacritty"(s)

这份传递给 fontconfig 的 font pattern 有 6 个元素 (elts),其中 family 的值,就是虚拟终端 Alacritty 设置使用的 monospace 字体。后面的调试信息在告诉我们,fontconfig 对 font pattern 进行了一系列的修改操作,输出结果是 (省略了除 family 之外的元素)

FcConfigSubstitute donePattern has 9 elts (size 16)
    family: "Iosevka Custom"(s) "Iosevka Custom"(s) "Noto Sans Mono CJK SC"(s) "Blobmoji"(s) "Symbols Nerd Font"(s) "DejaVu Sans Mono"(w) "Inconsolata"(w) "Andale Mono"(w) "Courier New"(w) "Cumberland AMT"(w) "Luxi Mono"(w) "Nimbus Mono L"(w) "Nimbus Mono"(w) "Nimbus Mono PS"(w) "Courier"(w) "Miriam Mono"(w) "VL Gothic"(w) "IPAMonaGothic"(w) "IPAGothic"(w) "Sazanami Gothic"(w) "Kochi Gothic"(w) "AR PL KaitiM GB"(w) "MS Gothic"(w) "UmePlus Gothic"(w) "NSimSun"(w) "MingLiu"(w) "AR PL ShanHeiSun Uni"(w) "AR PL New Sung Mono"(w) "HanyiSong"(w) "AR PL SungtiL GB"(w) "AR PL Mingti2L Big5"(w) "ZYSong18030"(w) "NanumGothicCoding"(w) "NanumGothic"(w) "UnDotum"(w) "Baekmuk Dotum"(w) "Baekmuk Gulim"(w) "TlwgTypo"(w) "TlwgTypist"(w) "TlwgTypewriter"(w) "TlwgMono"(w) "Hasida"(w) "GF Zemen Unicode"(w) "Hapax Berbère"(w) "Lohit Bengali"(w) "Lohit Gujarati"(w) "Lohit Hindi"(w) "Lohit Marathi"(w) "Lohit Maithili"(w) "Lohit Kashmiri"(w) "Lohit Konkani"(w) "Lohit Nepali"(w) "Lohit Sindhi"(w) "Lohit Punjabi"(w) "Lohit Tamil"(w) "Meera"(w) "Lohit Malayalam"(w) "Lohit Kannada"(w) "Lohit Telugu"(w) "Lohit Oriya"(w) "LKLUG"(w) "Noto Sans Mono"(w) "FreeMono"(w) "monospace"(s) "Terafik"(w)

WTF?? 这长长的一串是什么?这就是我希望 Alacritty 查找字体的顺序。里面有重复的字体,也有好多系统上不存在的字体。fontconfig 在 font pattern 中添加的字体,是可以不存在于系统上的。不用对各种奇奇怪怪的字体感到诧异。因为,在最后输出的 font pattern 中只保留合适的字体,去掉了重复和无效的字体。由于每个字体的具体 font pattern 信息很长,在这里我就不方便贴出来了。所以还请你自己去看看调试信息后面给出的字体信息有些什么。

family 开头写的

"Iosevka Custom"(s) "Noto Sans Mono CJK SC"(s) "Blobmoji"(s) "Symbols Nerd Font"(s)

是我在家目录的 fontconfig 配置中设置的优先使用的字体顺序。我喜欢在 Alacritty 中使用 Iosevka 字体作为英文等宽字体,中日韩字体使用 Noto Sans Mono CJK SC,剩下的 emoji 和特殊符号优先使用 Blobmoji 和 Nerd font。

我是这么设置的:

<!-- Default monospace fonts-->
<match target="pattern">
  <test name="family">
    <string>monospace</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Iosevka Custom</string>
    <string>Noto Sans Mono CJK SC</string>
    <string>Blobmoji</string>
    <string>Symbols Nerd Font</string>
  </edit>
</match>

如果完全没接触过 fontconfig 配置的话,看上面的内容也多少能猜出它是什么意思。如果现在看不懂的话也不要急,后面我会再讲 fontconfig 配置的含义。

至于列表后面还有一长串的 family,又是什么呢?它们不是我设置的,是 fontconfig 默认自带的配置里设置的。在/etc/fonts/目录下能找到很多配置,会让 fontconfig 去添加一些字体。因为 fontconfig 的目标是尽可能保证有字体可以使用,只要系统上有这些字体,那就尽量使用它来显示出字形。所以我不用担心各种不认识的字符能不能显示出来,因为 fontconfig 会主动 fallback 到一个有效的字体上。在这里我们得到了一个很重要的信息:fontconfig 会在我们的规则后面添加很多 fallback 字体。

fontconfig 的行为受配置文件的影响,更重要的是我们可以通过修改配置文件,去修改它的行为。想要掌握 fontconfig,那就有必要了解它是怎么处理配置文件的。

配置的读取流程

fontconfig 主要读取/etc/fonts/fonts.conf/etc/fonts/conf.d/*.conf~/.config/fontconfig/fonts.conf~/config/fontconfig/conf.d/*.conf,至于那些历史遗留的目录位置~/.fonts.conf.d/*.conf~/.fonts.conf,由于不遵守 XDG 规范,我们就不要再使用它们了。

其实 fontconfig 并非固定读取这些位置,它首先读取/etc/fonts/fonts.conf,该文件中有句

<include ignore_missing="yes">conf.d</include>

/etc/fonts/conf.d/目录中的文件纳入读取中。在这个目录中的配置文件,按照文件名前的数字的顺序进行读取。而当读取到50-user.conf的时候,其中的语句:

<include ignore_missing="yes" prefix="xdg">fontconfig/conf.d</include>
<include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include>
<include ignore_missing="yes" deprecated="yes">~/.fonts.conf.d</include>
<include ignore_missing="yes" deprecated="yes">~/.fonts.conf</include>

指示 fontconfig 开始读取用户家目录下的配置文件。语句中的属性值prefix="xdg",代表 XDG_CONFIG_HOME 目录,默认是我们熟悉的~/.config/目录。

fontconfig 在读取家目录的配置文件后,再接着读取完/etc/fonts/conf.d/中剩余的配置文件。

以上流程,可以指定环境变量FC_DEBUG=1024看到 fontconfig 读取了哪些配置文件。运行:

FC_DEBUG=1024 fc-match

在运行上面的命令后,还可以在调试信息中发现大量的 Scanning /etc/fonts/conf.avail/这个目录中的文件。注意是 Scanning 而不是 Loading。这个目录里的文件又是在做什么呢?其实 fontconfig 没有加载和使用它们。/etc/fonts/conf.avail/的文件包含了没有默认启用的配置。不仅仅是 fontconfig 软件包自身的配置文件,很多字体软件包在安装后,也会往/etc/fonts/conf.avail/中放入一些配置文件。

有另一种查看 fontconfig 加载配置的方式,运行 fontconfig 提供的命令行工具fc-conflist即可。

检查一下有哪些软件包往/etc/fonts/conf.avail/里添加了文件

pacman -Qqo /etc/fonts/conf.avail/

可以注意到/etc/fonts/conf.d/里面那些启用了的配置文件,其实是软链接到了这个目录。该目录有很多没有默认启用的文件,所以可以按需启用它们。要启用里面某个配置文件的话,将它软链接到/etc/fonts/conf.d/或者~/config/fontconfig/conf.d/,比如启用 lcddefault 渲染

ln -s /etc/fonts/conf.avail/11-lcdfilter-default.conf ~/.config/fontconfig/conf.d/

一般建议不要直接修改系统端的配置,也就是/etc/fonts/,最好是在家目录中建立我们的配置文件。修改系统端的配置文件不易维护,也不好实现多用户配置隔离。

这里涉及到如何有效地管理家目录的配置文件。一些桌面环境的设置可能会「贴心地」覆盖掉我们的 fontconfig 配置,甚至只打开了设置面板、在没保存的情况下就已经修改了配置文件。建议及时备份,或者用 git 管理配置:使用 Git 管理 Linux 用户配置的新思路。而有的 Linux 发行版会额外添加自己的配置,甚至还可能给 fontconfig 打补丁,这都可能会对实际的配置文件造成影响,其结果不是我可以预知的。所以我主要谈论 Arch Linux 的情况,而且是在没有使用桌面环境的情况下。我确实不使用桌面环境,只使用窗口管理器。

字体文件位置

从上文中我们已经知道,fontconfig 的很多配置文件是先从/etc/fonts/fonts.conf引入的。其实,fontconfig 获取字体文件的位置,也是该文件定义的。你会发现该文件的开头就在指定字体目录:

<dir>/usr/share/fonts</dir>
<dir>/usr/local/share/fonts</dir>
<dir prefix="xdg">fonts</dir>
<!-- the following element will be removed in the future -->
<dir>~/.fonts</dir>

当我们安装字体软件包时,软件包把字体文件放在了/usr/share/fonts/目录下。快看看安装了哪些字体吧:

pacman -Qqo /usr/share/fonts

然后我就发现 Arch Linux 在安装 GTK3 后就已经默认依赖 cantarell-fonts 和 adobe-source-code-pro-fonts 了。不太可能不使用 GTK3; 一旦使用 GTK3,基本上等同于预装了两个字体:无衬线英文字体 Cantarell,等宽英文字体 Source Code Pro。

熟悉配置文件

多个配置文件是按顺序执行的,而且每个配置文件内的语句也是按顺序执行的。每个配置文件都是 XML 格式,内容被包裹在<fontconfig>标签中。我们最应该熟悉的是其中的<match>...<test>...<edit>...,该语句控制着对 font pattern 的操作。在今后自己写配置文件的时候,也会大量使用该语句。

示例一

让我们看看一个实例。我们给调试程序 fc-match 传入 font pattern

FC_DEBUG=4 fc-match 'monospace'

指定 family 为 monospace,经过下面的配置语句,font pattern 会变成什么样呢?

<match target="pattern">
  <test name="family">
    <string>monospace</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Iosevka Custom</string>
    <string>Noto Sans Mono CJK SC</string>
    <string>emoji</string>
  </edit>
</match>

<match target="pattern">,被操作对象是 font pattern。<match target="font">则是对单个字体的操作,不过这里没有它。

<test>,可选的测试条件。只有当满足测试条件的时候,才执行<edit>

<edit name="family" mode="prepend" binding="strong">,执行编辑操作,name="family"表明被操作对象是是 font pattern 中的 family。

在这里,test 语句针对了 font pattern 中的 monospace。也就是说,接下来的 edit 语句就在 font pattern 的 monospace 这个位置上进行操作。它做了什么呢?mode="prepend"的意思是在 monospace 前添加三个字体:英文等宽字体 Iosevka Custom,中文字体 Noto Sans Mono CJK SC,以及通用字体族名 emoji。至于binding="strong",是强绑定的意思,它会影响 font pattern 的排序结果,但现在先不讲它的作用,后文再讲。

其实,<match>...<test>...<edit name="family" mode="prepend">...等价于 <alias>...<family>...<prefer>...,如果你见过后一种写法的话,不会对它的作用感到陌生。

在这条规则被应用后,font pattern 中的 family 变成了

family: "Iosevka Custom"(s) "Noto Sans Mono CJK SC"(s) "emoji"(s) "monospace"(s)

所以,如果程序指定使用 monospace 字体,那么我就能按 Iosevka Custom -> Noto Sans Mono CJK SC -> emoji 的顺序显示英文和中文字体了。

示例二

向 fc-match 传入的 font pattern 是可以有多个字体的。现在我们要运行

FC_DEBUG=4 fc-match 'cantarell, WenQuanYi Micro Hei'

经过这段配置会变成什么呢?

<match target="pattern">
  <test name="family">
    <string>Cantarell</string>
  </test>
  <edit name="family" mode="assign" binding="strong">
    <string>Noto Sans</string>
  </edit>
</match>

这里的mode="assign",表示将 font pattern 中的 Cantarell 修改成 Noto Sans。没有对 WenQuanYi Micro Hei 的操作,所以结果是

family: "Noto Sans"(s) "WenQuanYi Micro Hei"(s)

示例三

我们现在再来看看 fontconfig 自带的配置执行了什么。就以/etc/fonts/conf.d/49-sansserif.conf为例

<match target="pattern">
  <test qual="all" name="family" compare="not_eq">
    <string>sans-serif</string>
  </test>
  <test qual="all" name="family" compare="not_eq">
    <string>serif</string>
  </test>
  <test qual="all" name="family" compare="not_eq">
    <string>monospace</string>
  </test>
  <edit name="family" mode="append_last">
    <string>sans-serif</string>
  </edit>
</match>

test 中的qual="all" name="family"表示对 font pattern 中的每一个字体都执行测试,compare="not_eq"就是 family 不等于这个值的时候。一共有三条 test,它们是「与」不是「或」的关系。当三条测试规则都满足的时候,也就是 font pattern 没有任何通用字体族名的时候,默认 fallback 到 sans-serif 无衬线字体。注意 edit 操作里面的mode="append_last"表示 sans-serif 添加在 family 列表的最末尾。

test 中的qual="all",是指每一个被测试对象都应该满足条件,而不是 test 可以包含多个测试值。与此相对的,qual="any" name="family"是指只要 font pattern 中有一个字体满足条件即可。如果在 test 中包含多条<string>的时候,fontconfig 会报错。

建议多看看/etc/fonts/目录下的配置,并且自己去观察调试信息,就能明白它们在做什么。我计划在下一篇文章中好好讲讲我是怎么配置 fontconfig 的。(在写了在写了、写好一半了)

调试信息

理解调试信息,就能理解 fontconfig 是怎么一步步操作 font pattern 的。今后在测试字体匹配的时候,可以自己分析了。

查看调试信息,指定环境变量FC_DEBUG=4运行 fc-match

FC_DEBUG=4 fc-match

打印出的调试信息会很长,我们主要看几个部分:

第一部分,Add Rule,指已添加的配置文件规则。这里面也包含了家目录下的配置文件,可以找来看看被解析成了什么。

第二部分,在 Add Rule 之后,迎来了最关键的、我们应当关心的 FcConfigSubstitute Pattern,它包含了 font pattern。(s) 和 (w) 分别代表强弱绑定;prgname 代表程序名,此时就是 fc-match。至于 lang,由于没有对 fc-match 指定语言,所以默认是 en。

接下来有很多条 FcConfigSubstitute editPattern,代表对 font pattern 的替换操作。但是必须当规则匹配的时候,也就是 Rule Set 不是 No match 的情况下,才执行 FcConfigSubstitute editPattern。那么,又应该怎么看 FcConfigSubstitute editPattern 呢?主要看 family,因为 family 代表着字体匹配顺序。它就是配置文件中的<edit target="pattern">操作。

最后应该关心 FcConfigSubstitute donePattern,这是 fontconfig 执行完字体替换后的结果。

fontconfig 的文档实在是太晦涩了。学会看调试信息很关键。

fontconfig API

所谓的桌面程序依赖 fontconfig 来查找和替换字体,其实它不过在是调用 libfontconfig 提供的 fontconfig API 而已。

fontconfig 定义了两个FcMatchKind宏: FcMatchPatternFcMatchFont,由底层函数FcConfigSubstituteWithPat使用。但实际使用的时候,不直接使用它,而是使用封装了它的函数:

FcConfigSubstitute:调用FcConfigSubstituteWithPat并传入 font pattern 和宏FcMatchPattern。所以它从配置文件中,把<edit target="pattern">语句挑选出来进行执行,其他语句不执行。对应调试信息中的 FcConfigSubstitute editPattern。

FcFontRenderPrepare:调用FcConfigSubstituteWithPat并传入 font pattern 和宏FcMatchFont。所以,执行了配置文件中的<match target="font">

桌面程序使用 fontconfig 来完成字体查找,它想怎么调用 fontconfig API 就怎么调用,fontconfig 不会去限制它。我看见一些文章去分析 fontconfig 到底会执行几遍配置文件,其实没什么参考价值,因为是否执行替换操作,完全是由桌面程序自己决定的。桌面程序爱怎么使用 fontconfig 就怎么使用,它想执行多少遍配置文件、执行FcConfigSubstitute多少次都可以,它只想执行全部的<edit target="pattern">,但却不执行<edit target="font">,也可以做到。当然,从实践的角度讲,桌面程序应当规范使用 fontconfig API,所以大部分桌面程序都合理地遵守着我们的 fontconfig 配置。然而,少部分程序就不怎么合理的,比如 Chrome 浏览器的怪异行为是我预料不及的,后文再谈这个问题。

在执行FcConfigSubstitute后,完成了对 font pattern 的替换操作。如果程序想使用这个字体顺序的话完全可以。但它也可以调用 fontconfig API 再对字体进行排序,使用排序后的结果。有两个函数会对 font pattern 进行排序:

FcFontSort:对每个字体打分,根据分数排序 font pattern。

FcFontMatch:返回字体的最佳匹配值。内部调用了FcFontSetMatchInternal,而它会对字体打分,根据分数返回首个字体。

突然就开始给字体打分了,这是怎么回事?字体得分,涉及到很多因素,包括强弱绑定的概念。接下来我们看看字体得分和强弱绑定是什么。

字体得分和强弱绑定

在上面介绍配置实例的时候,就已经出现了强绑定的概念,但是那时没有展开说明。

fontconfig 会为每个字体计算得分 (score),分数越小,优先级越高。所以如果对 font pattern 排序,那么分数较小的会排在前面。

比如,Noto Sans CJK SC 这个字体,在我的系统上经过配置后,是强绑定字体,得分是

Score 0 0 0 0 0 0 0 2 0 0 1016 0 0 0 0 0 0 0 130000 0 0 0 0 0 0 0 2.14735e+12

而另一个弱绑定字体 Source Code Pro 在我的配置的影响下,得分是

Score 0 0 0 0 0 0 0 1000 0 0 1016 0 0 0 0 0 0 0 130000 0 0 0 0 0 0 0 2.14735e+12

每个数字对应的含义在 fontconfig 源码里是以一个枚举类型定义的:

typedef enum _FcMatcherPriority {
    PRI1(FILE), PRI1(FONTFORMAT), PRI1(VARIABLE), PRI1(SCALABLE),
    PRI1(COLOR), PRI1(FOUNDRY), PRI1(CHARSET), PRI_FAMILY_STRONG,
    PRI_POSTSCRIPT_NAME_STRONG, PRI1(LANG), PRI_FAMILY_WEAK, PRI_POSTSCRIPT_NAME_WEAK,
    PRI1(SYMBOL), PRI1(SPACING), PRI1(SIZE), PRI1(PIXEL_SIZE),
    PRI1(STYLE), PRI1(SLANT), PRI1(WEIGHT), PRI1(WIDTH),
    PRI1(FONT_HAS_HINT), PRI1(DECORATIVE), PRI1(ANTIALIAS), PRI1(RASTERIZER),
    PRI1(OUTLINE), PRI1(ORDER), PRI1(FONTVERSION), PRI_END
} FcMatcherPriority;

第 8 个数字是 PRI_FAMILY_STRONG,代表强绑定;第 10 个数字是 PRI1(LANG),代表语言匹配;第 11 个数字是 PRI_FAMILY_WEAK,代表弱绑定。至于前 7 个数字,通常都是 0,我们可以不用去管它们。那么在比较大小的时候,我们只需要关心强弱绑定和语言的得分比较。

因为强绑定字体的第 8 个数字的值,会比弱绑定字体的值小。那么强绑定字体会在排序后,排在弱绑定字体的前面。你应该注意到了,在上面的例子中出现的强绑定字体,此处数值是 2,而弱绑定字体是 1000。

然后,是语言对字体排序的影响。桌面程序在调用 fontconfig API 时,可以传递自己使用的语言给函数 (当然也可以不传)。在所有的弱绑定字体中,如果 fontconfig 发现某个字体支持该语言,那它会排在其它弱绑定字体的前面。

但这些都是排序后的结果,如果不排序,字体得分没什么用。上节说过,桌面程序在使用 fontconfig 的时候自行决定使用哪些 API。在排序这个问题上,它可以选择排序 font pattern,也可以选择不排序;它可以在排序时抛弃语言属性。

这对我们有什么启发呢?弱绑定字体的排序比较复杂,受很多因素影响。但是我们已经知道在排序后,强绑定字体比弱绑定字体的优先级高。所以在我们写自己的配置的时候,只使用强绑定来替换字体。弱绑定的结果很难预测,只有那些 fontconfig 默认添加的 fallback 字体才使用弱绑定。

注意:其实 font pattern 排序的准确方式比上面说的要复杂得多,我也没搞懂……一是文档语焉不详,二是源码缺少注释过于神秘。似乎不是完全按照字体得分计算排序的。有好心人的话麻烦看下源码,告诉我具体是怎么回事。

fc-match 源码分析

fc-match 是个相当有用的工具,你可以对它传入多个 family 并且指定语言,打印整个 font pattern 排序后的结果

fc-match --sort 'simsun,serif:lang=ja'

其实,fc-match 这个命令行工具的源码相当简单,在这里我把它打印格式化信息和调试信息的代码去除后,就可以简化分析源码了。

此处源码来自于fontconfig 2.13.92,对于源码中的一些函数的具体作用,你必须先了解在 上文中提到的 fontconfig API

184
185
FcConfigSubstitute (0, pat, FcMatchPattern);
FcDefaultSubstitute (pat);

从一开始就用FcConfigSubstitute执行字体替换。这一步永远少不了。

187
fs = FcFontSetCreate ();

用函数FcFontSetCreate创建FcFontSet结构体,它即将被用来保存结果。用函数FcFontSetAdd可以把结果保存在该结构体里。

现在分成两种情况,如果 fc-match 指定使用--sort或者--all选项

189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
if (sort || all)
{
    FcFontSet	*font_patterns;
    int j;
    font_patterns = FcFontSort (0, pat, all ? FcFalse : FcTrue, 0, &result);

    if (!font_patterns || font_patterns->nfont == 0)
    {
        fprintf (stderr, _("No fonts installed on the system\n"));
        return 1;
    }
    for (j = 0; j < font_patterns->nfont; j++)
    {
        FcPattern  *font_pattern;

        font_pattern = FcFontRenderPrepare (NULL, pat, font_patterns->fonts[j]);
        if (font_pattern)
            FcFontSetAdd (fs, font_pattern);
    }

    FcFontSetSortDestroy (font_patterns);
}

调用FcFontSortFcFontRenderPrepare。没错,--sort--all就是上文说到的字体排序,而--all额外让FcFontSort打印所有字体的信息。

如果没指定选项呢?

211
212
213
214
215
216
217
else
{
    FcPattern   *match;
    match = FcFontMatch (0, pat, &result);
    if (match)
        FcFontSetAdd (fs, match);
}

直接调用FcFontMatch完事,所以 fc-match 只会打印一个结果。

可以看到,fc-match 只是简单地包装了我在 fontconfig API 一节中提到的几个函数。想理解它们的话,借助 fc-match 这个工具,就可以随便玩耍 fontconfig API 了。

浏览器和网站的字体设置

CSS font-family

网站使用什么字体,是在 CSS 中的 font-family 设置的。

那么浏览器就需要传递 font-family 给 fontconfig。尽管 fontconfig 支持一次性传入多个字体组成的字符串列表,但 Firefox 和 Chrome 这些桌面浏览器是不会直接把整个 font-family 传递给 fontconfig 的。它们会把 font-family 中的字体拆开,依次向 fontconfig 询问。

为什么不完整传入多个字体族名组成的字符串,而是要分开传?我们已经知道,fontconfig 会尽量保证每个字符能有字体去显示它,除非系统上确实没有任何字体能对应这个字符。默认配置会给 font pattern 添加很多 fallback 字体,这样一来,就干扰了浏览器得知一个字体被 fontconfig 替换成了什么字体。

我们看个例子,知乎上设置的 font-family

-apple-system,BlinkMacSystemFont,Helvetica Neue,PingFang SC,Microsoft YaHei,Source Han Sans SC,Noto Sans CJK SC,WenQuanYi Micro Hei,sans-serif

sans-serif这个通用字体族名是垫底的,只有当前面的字体没有匹配的时候,才 fallback 到系统上的无衬线字体。

另外,font-family 还支持system-ui, -apple-system, BlinkMacSystemFont这样的特殊值。它们一定会放在 font-family 的最前面。其中,后两个是给苹果系统使用的,我们不用考虑。system-ui 是什么呢?它指示浏览器直接使用系统默认字体,这样就不需要使用 font-family 后面列出的那些值。当然,这过于理想了,它在 Windows 上的表现很糟糕;而接下来我们会看到 Linux 上的 Chrome 有多不合理地对待这个值。

Chrome

Chrome 的实现方式有点坑。为了得到 font-family 中的字体在 Linux 系统上对应某个字体,它会直接从 fontconfig 的返回结果中取首个字体。可惜,这么做会和我们的目标有出入。

注意:关于 Chrome 的这部分内容,由于我没有看 Chromium 的源码,仅仅是从测试结果和调试信息中得知的,可能有误,请注意甄别,所以我就先不负责啦。等我有空看了 Chromium 的源码再说吧。

比如,网站设置 font-family 为Noto Serif CJK SC, serif。我们希望将 Noto Serif CJK SC 替换成 DejaVu Serif, Noto Serif CJK SC,也就是英文优先显示DejaVu Serif,中文显示Noto Serif CJK SC。而serif则替换成DejaVu Serif, Noto Serif CJK SC, serif

<match target="pattern">
  <test name="family">
    <string>Noto Serif CJK SC</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>DejaVu Serif</string>
  </edit>
</match>

<match target="pattern">
  <test name="family">
    <string>serif</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>DejaVu Serif</string>
    <string>Noto Serif CJK SC</string>
  </edit>
</match>

这里是在用 edit mode="prepend"的方式在前部添加字体。font pattern 处理后

DejaVu Serif, Noto Serif CJK SC, DejaVu Serif, Noto Serif CJK SC, serif

再去重,预期结果是

DejaVu Serif, Noto Serif CJK SC, serif

嗯。英文使用DejaVu Serif,中文使用Noto Serif CJK SC。然而,Chrome 可不这么想。Chrome 从一开始替换的时候只取每次替换的首个结果,在它眼里最终结果是

DejaVu Serif, DejaVu Serif

嗯?Noto Serif CJK SC 哪去了?中文字体没了!因为没指定使用哪个中文字体,Chrome 开始使用 fallback 字体去显示中文,这时你很可能会惊奇地发现,中文字体用无衬线的黑体显示。而我们期待的却是使用 Noto Serif CJK SC 这样的宋体。这个机制导致 Chrome 使用的字体和其它桌面程序不一致。

而当面对 system-ui 这个字体族名的时候,Chrome 会直接使用 GTK 设置中的 UI 字体 (毕竟 Chrome 依赖 GTK)。它依旧只取 fontconfig 返回的首个字体。如果 GTK 设置成无衬线字体那到还好;如果是设置成衬线字体或者等宽字体,很遗憾,它只使用 fontconfig 返回的首个衬线字体或者等宽字体,然后又开始接着使用无衬线字体了。Chrome 只取首个字体的习惯,让我们配置的字体顺序成了一场空。

接下来,我们看下 Firefox 如何巧妙地解决这些问题。

Firefox

Firefox 会先传入一个不存在的字体族名-moz-sentinel,查询到所有 fallback 字体。然后再传入font, -moz-sentinel。font 指被查询的那个字体。比对两次结果,即可得知字体被配置替换后的结果。至此,Firefox 就能知道 Noto Serif CJK SC 被替换成了 DejaVu Serif, Noto Serif CJK SC,然后 Firefox 正确地使用这两个字体去显示了。而 serif 这个通用字体族名,将按标准方法进行查询,也就是和其他桌面程序一样的结果,使用所有 fallback 字体。

不妨看看 Firefox 源码中的注释是怎么介绍这个技巧的

1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
// fontconfig allows conditional substitutions in such a way that it's
// difficult to distinguish an explicit substitution from other suggested
// choices. To sniff out explicit substitutions, compare the substitutions
// for "font, -moz-sentinel" to "-moz-sentinel" to sniff out the
// substitutions
//
// Example:
//
//   serif ==> DejaVu Serif, ...
//   Helvetica, serif ==> Helvetica, TeX Gyre Heros, Nimbus Sans L, DejaVu
//   Serif
//
// In this case fontconfig is including Tex Gyre Heros and
// Nimbus Sans L as alternatives for Helvetica.

关于 system-ui:Firefox 会将 system-ui 视作和其他字体一样,进行一次普通的 fontconfig 查询。所以尽管 Firefox 也依赖 GTK,但它没有使用 GTK 设置的 UI 字体去渲染网页。好处是明显的:我们可以在 fontconfig 配置中去设置 system-ui 的字体匹配规则。

还是不太明白 Firefox?我们一起来看 Firefox 的源码吧!

firefox 源码分析

我们来查看 Firefox 的相关源码,进一步了解 fontconfig。本节来自于 Firefox 82.0 的源码。由于 Firefox 不提供在线阅览所有版本的源码 (它只提供在线查看 nightly 版本),所以我只能直接在文章中给出源码。

Firefox 涉及字体匹配的那部分在 gfx/thebes/ 这个目录中。关于 Linux fontconfig 那部分的文件是 gfxFcPlatformFontList.cpp。文件名中的 Fc 就是指 fontconfig。除此之外,这个目录中还有其他平台的字体查找的源码,但这不是本文所关心的内容。

在这个文件中,使用的命名空间是gfxFcPlatformFontList。我们需要重点关心的函数是FindAndAddFamilies。那我们来看看该函数。

bool gfxFcPlatformFontList::FindAndAddFamilies(
    StyleGenericFontFamily aGeneric, const nsACString& aFamily,
    nsTArray<FamilyAndGeneric>* aOutput, FindFamiliesFlags aFlags,
    gfxFontStyle* aStyle, gfxFloat aDevToCssSize) {
    ...
}

Firefox 会往该函数传入aFamily,也就是被查询的字体。查询结果被保存在aOutput当中。至于返回的布尔值,是用来表示查询结果是否有值。

那它的具体内容是在做什么呢?第一步,将字体族名转成小写,方便后续查询。

1922
1923
nsAutoCString familyName(aFamily);
ToLowerCase(familyName);

接下来,就要视familyName,分成两种情况来处理了。Firefox 会对 sans-serif,serif,monospace 这三个通用字体族名进行区别对待。

通用字体族名的情况

先处理最特殊的情况:如果被查询的是通用字体族名及其变种,则直接调用FindGenericFamilies得到结果,然后返回。

1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
nsAtom* language = (aStyle ? aStyle->language.get() : nullptr);

if (!(aFlags & FindFamiliesFlags::eQuotedFamilyName)) {
  // deprecated generic names are explicitly converted to standard generics
  bool isDeprecatedGeneric = false;
  if (familyName.EqualsLiteral("sans") ||
      familyName.EqualsLiteral("sans serif")) {
    familyName.AssignLiteral("sans-serif");
    isDeprecatedGeneric = true;
  } else if (familyName.EqualsLiteral("mono")) {
    familyName.AssignLiteral("monospace");
    isDeprecatedGeneric = true;
  }

  // fontconfig generics? use fontconfig to determine the family for lang
  if (isDeprecatedGeneric ||
      mozilla::FontFamilyName::Convert(familyName).IsGeneric()) {
    PrefFontList* prefFonts = FindGenericFamilies(familyName, language);
    if (prefFonts && !prefFonts->IsEmpty()) {
      aOutput->AppendElements(*prefFonts);
      return true;
    }
    return false;
  }
}

那么这个FindGenericFamilies又做了什么?在里面,Firefox 向 fontconfig 传入当前的页面语言,指定使用矢量字体,执行FcConfigSubstituteFcDefaultSubstituteFcFontSort。所以它会计算字体得分和排序。

一般情况

上面说的是特殊情况。而对于剩下的大部分字体族名,则按照以下方式。在这里,Firefox 会用到缓存,它把每个字体查询到的结果保存在缓存中。所以,它不用每次都去向 fontconfig 查询,直接返回先前查询到的结果就可以了。毕竟调用 fontconfig 的代价很昂贵。

1965
1966
1967
1968
1969
1970
1971
1972
1973
// Because the FcConfigSubstitute call is quite expensive, we cache the
// actual font families found via this process. So check the cache first:
if (auto* cachedFamilies = mFcSubstituteCache.GetValue(familyName)) {
  if (cachedFamilies->IsEmpty()) {
    return false;
  }
  aOutput->AppendElements(*cachedFamilies);
  return true;
}

如果不在缓存中,那么 Firefox 就开始向 fontconfig 查询了。

Firefox 会先传入一个不存在的字体族名-moz-sentinel,查询到所有 fallback 字体。然后再传入 font, -moz-sentinel。比对两次结果,即可得知字体被配置替换后的结果。实现方式是找出-moz-sentinel 之前所有字体。由于-moz-sentinel是一个不存在的字体,fontconfig 也不会有专门对应它的规则,所以会对 font pattern 添加大量 fallback 字体。这样的话,Firefox 就知道系统上的字体 fallback 机制是怎么样的了。

具体实现的源码

1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
// It wasn't in the cache, so we need to ask fontconfig...
const FcChar8* kSentinelName = ToFcChar8Ptr("-moz-sentinel");
FcChar8* sentinelFirstFamily = nullptr;
RefPtr<FcPattern> sentinelSubst = dont_AddRef(FcPatternCreate());
FcPatternAddString(sentinelSubst, FC_FAMILY, kSentinelName);
FcConfigSubstitute(nullptr, sentinelSubst, FcMatchPattern);
FcPatternGetString(sentinelSubst, FC_FAMILY, 0, &sentinelFirstFamily);

// substitutions for font, -moz-sentinel pattern
RefPtr<FcPattern> fontWithSentinel = dont_AddRef(FcPatternCreate());
FcPatternAddString(fontWithSentinel, FC_FAMILY,
                   ToFcChar8Ptr(familyName.get()));
FcPatternAddString(fontWithSentinel, FC_FAMILY, kSentinelName);
FcConfigSubstitute(nullptr, fontWithSentinel, FcMatchPattern);

// Add all font family matches until reaching the sentinel.
AutoTArray<FamilyAndGeneric, 10> cachedFamilies;
FcChar8* substName = nullptr;
for (int i = 0; FcPatternGetString(fontWithSentinel, FC_FAMILY, i,
                                   &substName) == FcResultMatch;
     i++) {
  if (sentinelFirstFamily && FcStrCmp(substName, sentinelFirstFamily) == 0) {
    break;
  }
  gfxPlatformFontList::FindAndAddFamilies(
      aGeneric, nsDependentCString(ToCharPtr(substName)), &cachedFamilies,
      aFlags);
}

再次看见我们熟悉的FcConfigSubstitute

代码段第一部分是对-moz-sentinel的查询,代码段第二部分是对familyName, -moz-sentinel的查询,代码段第三部分进入循环,找出-moz-sentinel前出现的字体。循环中会调用 gfxPlatformFontList::FindAndAddFamilies,该函数在gfxPlatformFontList.cpp中定义。注意命名空间和文件名中都没有 Fc。这个函数是其它平台也会调用的底层函数,它具体做什么,好吧,我没细看 (尴尬),不影响对其它流程的理解。

最后将该字体对应的结果保存在缓存和输出结果中,之后返回。注意在这个流程里,Firefox 没有使用排序函数。

2004
2005
2006
2007
2008
2009
2010
2011
// Cache the resulting list, so we don't have to do this again.
mFcSubstituteCache.Put(familyName, cachedFamilies);

if (cachedFamilies.IsEmpty()) {
  return false;
}
aOutput->AppendElements(cachedFamilies);
return true;

通过源码,我们确认了很重要的两点:

  • Firefox 会依次向 fontconfig 查询 font-family 中的每个字体。
  • Firefox 通过使用 -moz-sentinel 这个技巧来得知某个字体被 fontconfig 处理后的准确结果。

后记

关于这篇文章,有很多真实性尚不明确的内容,比如 Chrome 的准确行为方式,fontconfig API 对字体排序的真正方式。还有更多的源码等待我去细读。我也很无奈,毕竟 fontconfig 的文档有些敷衍,网上的相关资料和讨论也比较少,有些甚至错误百出。我无法保证我没增加一篇错误的文章,所以请各位批判性地阅读和指正。

另外,基于我对 fontconfig 的理解,我在 GitHub 上维护了一份 自己的配置规则,基本上满足了我在 Linux 环境中的日常使用需求,欢迎参观。

知识共享许可协议

© 2020 rydesun

6 个流行的分布式 ID 方案之间的对决

开始加载评论