Featured image of post fontconfig:Linux下的字体配置

fontconfig:Linux下的字体配置

配置Linux环境下的字体,解决字体样式错误、异形字、全角引号以及合成字体

字体的分类

字体的数量可以说是成千上万,但一般在电脑上显示的基本为以下这三类1

  • monospace [等宽]

    等宽字体是指字符宽度相同的字体,用于需要字符严格对齐的场合,例如控制台和源代码。与此相对,字符宽度各不相同的字体称为比例字体(其余四类字体都是)。不过,对于中文字体而言,并不存在等宽与比例的差别,因为所有中文字都是等宽的。中文字体中的“等宽”指的是字体的西文部分是等宽的,2个字母对应1个汉字。

  • sans-serif [无衬线]

    是指笔画末端没有修饰(衬线)的字体,通常用于屏幕显示。中文的黑体与圆体就属于此类字体。

  • serif [有衬线]

    是指笔画末端有修饰(衬线)的字体,通常用于打印。中文的宋体与仿宋就属于此类字体。

我们要做的字体配置主要就是针对上面这三类字体。

选字体

有了目标,下面就是选一个自己喜欢的字体了。不过,对于中文字体,目前现在 Linux 上常用的、在维护的开源中文字体就一套——“思源黑体”和“思源宋体”,同时被 Noto思源两个项目收录。Noto 系列字体是 Google 主导的,思源系列字体是 Adobe 主导的。2

对于编程字体,可以选择的余地就多多了,像是Source Code Pro,Consolas,Menlo等等。我一开始选择的是 Iosevka 。但是,这款字体给我感觉太瘦了,看起来很是别扭。最后,我选择了广受好评的 Fira Code

小结一下,我的选择是:

  • 无衬线:西文 Noto Sans,中文 Noto Sans CJK
  • 衬线:西文 Noto Serif,中文 Noto Serif CJK
  • 等宽:西文 Fira Code,中文 Noto Sans Mono CJK

在ArchLiunx上,我们只需要安装noto-fontsnoto-fonts-cjk这两个包即可,他们分别提供了西文字体 Noto Sans / Noto Serif和中文字体 Noto Sans CJK / Noto Serif CJK / Noto Sans Mono CJK 。3

关于 emoji,我选择了 Twitter 推出的字体 Twemoji。

  • ttf-twemoji

另外,少不了人见人爱的图标字体 Nerd Fonts。

  • ttf-nerd-fonts-symbols

这里就有小伙伴开始好奇了,如何让西文和中文使用不同的字体呢?

在 Windows 下,我们可以选择合成字体,即将各类字体打包到一起。例如更纱黑体就是由思源黑体和西文字体 Iosevka 整合而来的。这种字体的好处就是方便,直接选择使用即可。但是缺点也是显而易见,就是打包太麻烦了,引入 Iosevka 要打一次包,想要支持 Nerd Fonts,又要打一次包。如果是别人帮你提供好的合成字体那还好说, 从网上下载、从软件仓库安装就完事了,自己打包的话真的工作量巨大。

而在 Liunx 下,我们只需要配置 fontconfig 就好了,无论想怎么搭配都可以实现,听起来是不是特别酷😎。可惜的是,有一些程序对 fontconfig 支持并不完善,这就达不到我们想要的效果。(说的就是你,Chrome😠)

fontconfig

在我们开始正式配置前,还是有必要了解一些基本的知识。这里我就简单介绍一下,如果想要深入了解的话可以看看双猫大佬的这篇文章,里面详细介绍了Linux fontconfig 的字体匹配机制。

字体的属性

字体有很多属性,常用的有字族(family)、倾斜(slant)、字重(weight)。后两者合一起叫样式(style)。

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

倾斜就是斜不斜,英文叫「Roman」「Italic」或者「Oblique」,Italic 是专门的斜体写法(更接近手写样式), Oblique 是把常规写法倾斜一下完事。

字重就更简单了,就是笔划的粗细。常见的有 Regular、Normal、Medium、Bold、Semibold、Black、Thin、Light、Extralight 等。

通用字族名

很多时候,程序并不在乎用户具体使用的是哪款字体,像很多网站的 CSS 那样把各个平台的常见字体全部列出来太傻了,又容易出问题。所以,人们发明了「通用字族名」,也就是 sans-serif (sans)、serif 和 monospace (mono) 这些。它们不是真实存在的字体,而是分别指示程序去使用无衬线、衬线、等宽字体。那么桌面程序又是如何知道具体使用哪些字体呢?它只需要去查询 fontconfig 就行了。由于它们必定要经过 fontconfig 的查询流程后才能使用字体,所以我们可以通过 fontconfig 的配置去精准控制程序使用的字体。

如何调试

传入环境变量FC_DEBUG=4即可,例如:

1
FC_DEBUG=4 kitty

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

1
2
3
4
5
6
7
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: "kitty"(s)

除了启动一个程序来看它字体的调用日志,我们也可以手动调用。例如,我想看 monospace 在系统里被修改成了什么字体,就可以执行:

1
FC_DEBUG=4 fc-match 'monospace'

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

第一部分,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 执行完字体替换后的结果。

配置文件

整个配置文件由如下几个部分依次拼接而成:

  1. 目录设置(<dir>, <cachedir>, <include>)
  2. 杂项设置(<config>)
  3. 扫描阶段(<match target="scan">)
  4. 匹配阶段(<alias>, <match target="pattern">)
  5. 渲染阶段(<match target="font">)

想要实现合成字体的效果,一个最简单的思路,本文也基于该思路:不让程序使用某个具体的字体,而是使用通用字体族名 (Generic Font Family)。比如,让程序使用 sans-serif,也就是默认的无衬线字体。

我们要关心第四个部分,即匹配阶段,使用 fontconfig 配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<match target="pattern">
  <test name="family">
    <string>sans-serif</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans CJK SC</string>
    <string>Noto Sans</string>
    <string>Twemoji</string>
  </edit>
</match>

这种 font stack 的方式,即可让程序按照以下顺序渲染字体:

1
Noto Sans CJK SC —> Noto Sans -> Twemoji

这里的<test>就是条件判断,mode="prepend"指在前添加,binding="strong"则是强绑定

开始配置

我们的思路就是就是修改默认的字族,让其成为我们想要指定的字体。然后将所有程序的字体配置改为通用字体族名:sans-serif,serif,monospace。

我们的配置都放在此目录中~/.config/fontconfig/fonts.conf,我的完整配置在此仓库中。

设置默认字体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
  <!-- Default system-ui fonts -->
  <match target="pattern">
    <test name="family">
      <string>system-ui</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>sans-serif</string>
    </edit>
  </match>

  <!-- Default sans-serif fonts-->
  <match target="pattern">
    <test name="family">
      <string>sans-serif</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>Noto Sans CJK SC</string>
      <string>Noto Sans</string>
      <string>Twemoji</string>
    </edit>
  </match>

  <!-- Default serif fonts-->
  <match target="pattern">
    <test name="family">
      <string>serif</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>Noto Serif CJK SC</string>
      <string>Noto Serif</string>
      <string>Twemoji</string>
    </edit>
  </match>

  <!-- Default monospace fonts-->
  <match target="pattern">
    <test name="family">
      <string>monospace</string>
    </test>
    <edit name="family" mode="prepend" binding="strong">
      <string>Noto Sans Mono CJK SC</string>
      <string>Symbols Nerd Font</string>
      <string>Twemoji</string>
    </edit>
  </match>
  <match target="pattern">
    <test name="family" compare="contains">
      <string>Source Code</string>
    </test>
    <edit name="family" binding="strong">
      <string>Fira Code</string>
    </edit>
  </match>

对 system-ui,sans-serif,serif,monospace 设置优先显示的字体。在这里我让 system-ui 默认为无衬线。注意,system-ui 必须在最前。由于 fontconfig 对 font pattern 的操作是按顺序执行的,所以必须先让 system-ui 能优先以 sans-serif 显示,然后才是对 sans-serif 的操作。

所有的 Noto CJK 字体都以 SC 结尾,因为我希望在没指定语言的默认情况下,以简体中文显示中文字形。

设置异形字

什么是异形字?Noto Sans CJK 中的异体字,是在 相同的 Unicode 码位下,不同的语言会使用不同的字形。

可以在双猫大佬的这个测试网站中看到,不同的语言环境下,这些字的显示是不同的。

异形字

我们想要实现在保留异体字的情况下,让它默认显示中国大陆字形;只在特定语言下显示异体字,比如用浏览器查看一个日文页面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Replace fonts for Japanese -->
<match target="pattern">
  <test name="lang">
    <string>ja</string>
  </test>
  <test name="family">
    <string>Noto Sans CJK SC</string>
  </test>
  <edit name="family" binding="strong">
    <string>Noto Sans CJK JP</string>
  </edit>
</match>

在字体匹配时,将 Noto Sans CJK SC 替换成 Noto Sans CJK JP。除了 ,你还需要照葫芦画瓢分别指定 zh-TW、zh-HK、ja、ko,以及对宋体和等宽字体进行重复的步骤。在此我就不重复了。

解决全角引号

英文中的单引号有两种,'(U+0027) 和(U+2019)。可能会觉得前一种出现在英文文本中,后一种出现在中文文本中,并且宽度和中文等宽。然而,英语世界中的一些人在英文文章中也是会使用后一种的。所以,字体在显示后一种引号的时候,究竟是和英文字母一样窄,还是该和中文字体等宽呢?如果在英文文章中显示成全宽字符则会显得突兀。

比如,思源黑体其实也包含了拉丁字母的字形,可以完全使用思源黑体去显示西文。此时,后一种单引号的宽度会显示成中文字形的宽度。导致即使在全英文环境中,单引号也会突兀地在文本中过宽。

同样地,(U+2018) (U+201C) (U+201D) 也有类似的情况。

我们的目标是在不同语言环境下字形会有所区别,让引号只在中文文本中全宽。

同样,在双猫大佬的这个测试网站中可以进行测试:

全角引号

利用fontconfig,可以方便的解决此问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- 解决全角引号 -->
<match target="pattern">
  <test name="lang" compare="contains">
    <string>en</string>
  </test>
  <test name="family" compare="contains">
    <string>Noto Sans CJK</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans</string>
  </edit>
</match>

覆盖西文字体

如果去观察 Noto Sans CJK 这个中文字体,会发现它的西文部分的字形其实和 Noto Sans 不一样,虽然它们都以 Noto 自称。中文字体携带的英文字符有可能十分糟糕,特别是 Windows 自带的 SimHei,也就是中易黑体,它的英文相当糟糕。另外,微软雅黑的字重实在是太少了,对于设计师来说很不友好。而各种流行的英文字体支持很多字重。

Noto Sans CJK 的英文还是比较不错的,为了字体的样式统一,这里就不修改sans-serif和seri了。主要将编程用的等宽字体换成Fira Code。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Replace english fonts-->
<match target="pattern">
  <test name="prgname" compare="not_eq">
    <string>msedge</string>
  </test>
  <test name="family" compare="contains">
    <string>Noto Sans Mono CJK</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Fira Code</string>
  </edit>
</match>

这里为什么要多加一句判断呢?这是因为有些程序居然只使用 font pattern 结果中的首个字体,比如 Chrome(以及衍生的Chromium),虽然 Chrome 接受了我们指定的西文字体,但是它忽略了紧接其后的中文字体,即使配置采用了强绑定!然后中文字体又不知道它 fallback 到哪去了,可能会出现你想要的中文字体,也可能不是。

这里是 msedge 主要是我平时都用 edge 而不是 Chrome。可惜由于 Chromium 在 Linux 上小问题实在是太多了,还是老老实实的用 firefox 吧。

在所有情况下,除了程序名为 msedge 的情况下,优先使用 Fira Code 显示西文,再用 Noto Sans Mono CJK 显示中文。虽然我不能让 msedge 使用 Fira Code,但它一定能用上 Noto Sans Mono CJK 显示中文。

替换任意字体

当系统里已经安装了一些不需要的字体,但又不想删除或者屏蔽它怎么办呢?替换掉 font pattern 就可以了。

我这里则是用来替换思源字体,毕竟它就是 Noto 嘛

1
2
3
4
5
6
7
8
<match target="pattern">
  <test qual="any" name="family">
    <string>Source Han Sans</string>
  </test>
  <edit name="family" mode="assign" binding="same">
    <string>Noto Sans CJK SC</string>
  </edit>
</match>

字体渲染参数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!--rendering options-->
<match target="font">
  <edit name="autohint" mode="assign">
    <bool>false</bool>
  </edit>
  <edit name="hinting" mode="assign">
    <bool>true</bool>
  </edit>
  <edit name="hintstyle" mode="assign">
    <const>hintslight</const>
  </edit>
  <edit name="antialias" mode="assign">
    <bool>true</bool>
  </edit>
  <edit name="lcdfilter" mode="assign">
    <const>lcddefault</const>
  </edit>
  <edit name="rgba" mode="assign">
    <const>rgb</const>
  </edit>
</match>

这里主要设置了一些字体的渲染方式:

  • autohint:优先使用内嵌微调
  • hinting:开启微调
  • hintstyle:微调的程度,轻微
  • antialias:开启抗锯齿功能
  • lcdfilter:LCD filter 的风格,默认
  • rgba:LCD 子像素的排列顺序,rgb

这里就直接抄作业了。

遇到的问题

全角引号设置无效

一开始我遇到了这个问题,无论怎么设置都是半角,经过排查以及网友的留言后发现,这是由于系统变量中的LANGen_US.utf-8,这会导致传递给 fontconfig 的 lang 是 lang: zh-cn(s) "en"(w)。于是就一直匹配西文字体。

解决办法有两个,一个是直接修改LANG环境变量为zh_CN.UTF-8,我目前采用的就是这种形式。

或者就是默认渲染英文,当网页声明是中文时再换字体,相关配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- Default sans-serif fonts-->
<match target="pattern">
  <test name="family">
    <string>sans-serif</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans</string>
    <string>Noto Sans CJK SC</string>
    <string>Noto Color Emoji</string>
  </edit>
</match>

<!-- cn -->
<match target="pattern">
  <test name="lang" compare="contains">
    <string>cn</string>
  </test>
  <test name="family" compare="contains">
    <string>Noto Sans</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Noto Sans CJK SC</string>
  </edit>
</match>

这样如果网页声明了lang=zh-cn,就会用Noto Sans CJK SC字体进行覆盖,也就实现了全宽引号。假如网页没有声明lang或者错误地声明了lang=en,这时就会出现半角引号+汉字的组合了。

不能解决的问题

Linux 不强迫程序必须使用特定的依赖,而是程序主动选择了约定俗成的依赖。老话重谈,程序可以自由选择完全遵守 fontconfig,也可以选择部分使用 fontconfig 的配置,或者完全不遵守它。这也导致了对一些程序无法实现字体的修改。以及上面提到的 chrome 对 fontconfig 并不是很好,或许面对这种程序,就需要合成字体的出场了。

参考文献