关于 Hack 字体 fontconfig 的一个疑问

今天读了苏姐推荐的文章(https://eev.ee/blog/2015/05/20/i-stared-into-the-fontconfig-and-the-fontconfig-stared-back-at-me/ ),感觉收获很多。
但是关于 Hack 字体的 fontconfig 我有个疑问,在 Hack 的官方 GitHub 里面推荐了这个字体的 fontconfig,里面写了要删除该字体的 sans-serif 属性并添加 monospace 属性。

奇怪的是 openSUSE 的 Hack 字体打包并没有打包进这个 45-Hack.conf,是不是 openSUSE 的打包忽略了这个文件,为什么?

@xzhao

http://marguerite.su/posts/fontconfig_几个常见的坑/

我的这篇文章看了没有?答案都在里面。不要以为上游的 fontconfig 配置就是永远正确的,永远正确的只有 fontconfig 的内置配置和 FC_DEBUG 的结果。

sans-serif/serif/monospace 只是一个锚点,所有的 fontconfig 都在针对它们做文章,但只能放在它们前后,在最终 match 到的列表里是没有这个名字的字体的,也就是说它们是从未存在的。永远不会应用的配置打包它干嘛呢?

@marguerite
不好意思我努力了很久也没看懂您的文章……现在我的机器上运行"fc-match -s “Hack”"的话会出现如下列表:

Hack-Regular.ttf: “Hack” “Regular”
Hack-Bold.ttf: “Hack” “Bold”
NotoSans-Regular.ttf: “Noto Sans” “Regular”
NotoSansJP-Regular.otf: “Noto Sans JP” “Regular”
NotoSansSC-Regular.otf: “Noto Sans SC” “Regular”
LiberationSans-Regular.ttf: “Liberation Sans” “Regular”

可以看出,替补的字体都是非 Monospace 的,这个问题要怎么解决呢?还是说这其实不是问题?

@xzhao

  1. 你 fc-match Hack 肯定得到 Hack 啊,这种测试只在一种情况下有意义:你没装 Hack。类似于没安装 Source Han Sans CN 却想用它,才需要 Noto CJK SC 去替代。否则除了 Hack 以外的任何字体都对你没用,因为你要 Hack 给 Hack,任务已经完成了。但是这种结果可以证明下面我要说的第二点。
  2. fontconfig 不是你想的那样,你以为的是比如我要求 Hack,这是 monospace,于是 matched pattern 里应该全是 monospace。我要求的是 Noto Sans SC,那么 pattern 里就应该全是 sans-serif。不是这样的。

我在文章里也说了,没有名为 monospace 的字体。即不是说针对每一个字体名都有一个替代品 list,而是 fontconfig 从头到尾都永远只有一个 pattern,即全部已安装字体的顺序排列(fc-list 命令返回的就是这个初始 pattern)。sans-serif,serif,monospace 全部混在一起的,无论你 match 什么,这个初始 pattern 都是不会变的。你的配置规则只是在调整它们谁先谁后的顺序,但是你不可能把全部已安装的字体都配置一遍(因为一旦安装一个新的字体,就都没用了),那么未配置到的就会保持它们的初始状态,也就是你看到的混沌的状态。

你 match monospace 的过程是这样的:

  1. 因为名为 monospace 的字体不存在,所以 fontconfig 不会直接像你 match Hack 那样直接把 Hack 放第一个给你扔回来。它会首先返回初始 pattern。
  2. 然后按顺序加载你的全部 fontconfig 配置里的 match 部分,比如你的 01.conf 里说把 Hack prepend,它就会把 Hack 放在整个 pattern 的头部,你的 02.conf 说 Noto Sans Mono 要 prepend,它就把它再放在整个 pattern 的头部。这时候 Hack 在第几个?对,在第二个。这也是好比 59-lang-specific.conf 里面只有一个 match 但是 edit 里面我 一次 prepend 了好多字体的原因,因为一个 edit 是一次操作,我在里面 prepend noto sans mono 和 hack,它就会把我写第一个的放 pattern 最前面,顺序是我的顺序不会乱。而你写两个 match,就是按照配置名那种 01 先应用,09 后应用。
  3. 这时候再根据你 match monospace 的配置中的第二个 test,比如 lang,来在这个调整过的 pattern 里选择,比如如果 Noto Sans Mono 不支持 zh-cn 的 lang 而 hack 支持,那你如果 test 了 zh-cn lang,就会返回 Hack。当然这个例子返回第一个 Noto Sans Mono。

以上就是你网页或者 app 里字体 family 写成 monospace 得到 Noto Sans Mono 的全过程。

这么调整完后,能保证你得到 Noto Sans Mono,但肯定有没调整的,没调整的都还是那样。你用 fc-match 不带 s 会看到底部的什么字体都有。你 match sans-serif 给你的结果里有 serif 和 monospace 字体。

你理解了上面的,就能明白 Hack 上游那个配置为什么错,它有两个 family test,第一个是 Hack,这会给你一个 pattern,然后你再在返回的 pattern 里匹配第二个 family 即 monospace。这个字体不存在。在所有的 pattern 里都永远找不到(所谓的锚点,类似于你永远也不可能找到一个名字就叫做 “这个” 的东西,但是大家都会说 “你拿这个之前先帮我拿一下包”,包是实际存在的字体比如微软雅黑,而 “这个” 则是不存在的东西比如 monospace,正如 “这个” 是分语境的一样,monospace 也是不一样的,我的系统上的 monospace 不是 Hack 而你的系统上的是,但想用字体的人会说:我就用 “monospace”,于是在你的系统上就得到 Hack,我的系统上得到别的;如果你够厉害,能让全世界全部的 developer 都管 sans-serif 叫 Hack,写在网页里也都是 Hack,那么 Hack 就是新的锚点,但你做不到,所以只能承认 “monospace” 就是字体圈的 “这个”。但是你把 monospace 写在第二个 test 里,相当于我需要一个 Hack 字体,还需要一个名字就叫 monospace 的字体,然后再干什么什么),所以你的第二个 test 永远不会成功,那后面的 delete 永远也不会执行。

1赞

@marguerite
我理解"fc-match -s Hack"第一个会返回 Hack,没问题。但是我理解这个 fc-match 的测试还有另外一种意义:如果有的字形 Hack 里面没有,应该会继续往下找吧?这时候我希望 fallback 的字体也是一个 Monospace 的字体,这个列表就做不到了呀,因为 NotoSans 不是一个等宽字体。

没有你说的另外一种意义。match Hack get Hack, pattern done, no further more magic that you imagine. 你混淆了 match font alias 和 font。font alias 例如 sans-serif 之所以能向下一个字体 fallback 是因为它本身不存在,可以视为 cover 了所有 lang 所有 unicode code point,单一 font 不行,找什么就是什么。

@marguerite
也就是说如果指定一个界面(例如 Konsole)的字体是 Hack,而某个字形(例如中文字符或者 Emoji)在 Hack 里面找不到,fontconfig 就认为这是未定义行为,可能显示出来任意的东西,不能对显示出来的字体做任何假设,我这个理解对吗?

对也不对。除非你用 monospace,不然就是现在这样。因为 monospace 这个常见 alias 发行版给你配置过,而 Hack 没有。

如果可以像你想的那样:

  1. 文泉驿项目就不会做中文字然后 copy 一堆别的字体做常见语言比如 en/ja(文泉驿的日文是 M1+ 这个字体)。
  2. Google 不会说在用 Noto CJK 的时候要 prepend 一下 Noto Sans。
  3. fontconfig 本身就没有存在的必要了。网页要 sans-serif,你在 chromium 里设一个中文字其他能自动都 fallback 到无衬线体,还费劲配置个什么呢?或者是你不配置任何东西它用的也都是无衬线字体,那还配置干什么呢?

fontconfig 的默认 fallback 规则就是在初始 pattern 里选最前面的那个有这个语言的字符的字体,不管它是什么(其实就是 family A-Z 和 lang A-Z)你系统上只有 Noto Sans SC 能显示中文那就是它,不管它是无衬线体还是等宽体。你有两个中文字体那就看 fc-list 里面的顺序。要是好比阿拉伯语字体你一个都没有,那就真的显示是口口口。除非你针对 test Hack 做 edit,prepend 一个中文等宽字体。对你来说是未定义行为,但实际不是无迹可循,应该说就是一个 default/unset 状态。

@marguerite
实在不好意思,我没看懂什么是 “对也不对”……
也就是说如果指定字体是 “Hack” 或者其他实际字体的话,我的理解就是对的,如果指定字体是 “monospace”“sans-serif”"serif"等虚拟锚点字体的话,会自动 fallback 到 fc-match 列表的第一个字体上(fontconfig 的默认 fallback 规则),这么理解对吗?

此外,还有个问题,如果说 monospace 只是一个虚拟锚点字体的话,那么为什么 “fc-match -s Hack” 的字体列表排在前面的都是 sans-serif 的字体,而"fc-match -s Dejavu Sans Mono"的字体列表排在前面的都是等宽字体呢?……这两个字体肯定有什么地方不一样,才会让 fc-match 生成的列表如此不同的。

都 fallback。你 match hack 因为你没针对 hack 有 edit,未配置状态用的是默认规则。monospace 则是配置过的,用的是我给你配的规则。对的是你说的未定义行为,不对的是你说的不能有任何假设(因为默认规则也是规则

这应该是你用了 sort 的结果。sort 的意思就是按照权重排序,权重就是 lang charset 和 style,越接近越靠前,但是你要无衬线体,衬线体和等宽只能说权重很低,但不会 filter 掉还在 pattern 里。Hack 字体没说过字体是 monospace 字体(你可以用 fc-query 看它的 spacing),那默认就视为无衬线体。

明白了,也就是 fc-match 不管是 Hack 还是 monospace,对于没有的字体(例如对于没安装 Hack 或者 monospace),默认规则都是 fallback 到列表里面第一个存在的字体。如果第一个存在的字体里面某个字符没有,就 fallback 到列表里面第一个有这个字符的字体。列表有序的,按照权重从高到低排序。

那么我现在的问题是,为什么 Hack GitHub repo 里面错误的解决方案(删除 匹配 sans-serif 并添加匹配 monospace)会影响 fc-match 对 Hack 进行匹配排序的权重?这是不是 fontconfig 的一个 bug?

经过 fc-query 查询到 Hack 字体的 spacing 是"spacing: 100(i)(s)",而 Dejavu Sans Mono 的 spacing 也同样是 “spacing: 100(i)(s)”,这个又是什么意思呢,为什么同样的 spacing,fontconfig 认为 Hack 不是 monospace 字体,而认为 Dejavu Sans Mono 是 monospace 字体(因为 fc-match -s "Dejavu Sans Mono"匹配在前面的都是等宽字体)呢?

:joy: 你还是没明白,我觉得我讲是讲不通了。你自己 FC_DEBUG=4 fc-match monospace 这样看输出吧,输出可能比我讲要直观一些。我在这边讲你在那边猜终归不是办法。

  1. 错误的配置不会应用。它什么也影响不了。你说的删除匹配 sans-serif 永远也实现不了。
  2. 那可能就不是 spacing,你看一下 fontconfig 的用户手册,里面告诉你 100 是啥了…

@marguerite
匹配 monospace 和 sans-serif 的过程我觉得我理解的比较清楚了,我不理解的是匹配 Hack 这些具体字体的过程。

如果说 1. 错误的配置 “什么也影响不了”,为什么应用了错误的配置之后,fc-match -s Hack 得到的列表顺序发生了变化呢? 这个变化是否是 fontconfig 的 bug?

我查到了 spacing 100 表示 Monospace:fontconfig/fontconfig-user.sgml at 37c7c748740bf6f2468d59e67951902710240b34 · freedesktop/fontconfig · GitHub ,这也无法解释为什么 Hack 被识别为了等宽字体为什么匹配列表不能优先匹配其他等宽字体。

另外解释一下,100(i)(s) 中的 i 是 integer 的意思,s 是 strong binding 的意思。

错误的配置不会应用,但是 45-Hack.conf 不是全错的啊,我的文字里面引用的是第二段啊…

<alias>
    <family>Hack</family>
    <default><family>monospace</family></default>
</alias>

这段还是对的啊。它把 Hack alias 为 monospace,也就是指定 Hack 为 monospace 字体,用 fontconfig 的内部语言说:pattern 里 Hack 后面那个位置是 monospace 锚点。那我针对 monospace 给你写的别的规则就应用了呀。你完全不用这个配置的话没有这段,那应用的是默认的规则,默认是视为 sans-serif 的…

错的是这段:

<match>
    <test compare="eq" name="family">
        <string>Hack</string>
    </test>
    <test compare="eq" name="family">
        <string>sans-serif</string>
    </test>
    <edit mode="delete" name="family"/>
</match>

它的意思是这样:先 test Hack 得到一个 pattern,然后再在这个 pattern 里找 sans-serif,然后把 Hack 删了。不能运行的原因是这样:

  1. 第一段指定为 monospace 了,就没有 sans-serif 锚点了,你再在里面找 sans-serif 肯定找不到。
  2. 没有第一段也不对…能找到 sans-serif 这个锚点,
    但删不掉。edit delete family 它想删的是第二个 test 里的 sans-serif,但不是这样的,它删的是第一个 Hack。一个 match Hack 的片段里把 Hack 自己删掉了还有意思吗。想删第二个测试的 name?fontconfig 就不支持…
  3. 即使它对,把 sans-serif 删掉也没有意义,它认为这样针对 sans-serif 的 prepend 规则就不会被应用(因为它是 45-Hack.conf 比我的 65-nonlatin.conf 那些做 prepend 的规则靠前。这样是不会应用,但是同样 monospace 的规则也不会应用呀。那按照默认 pattern 走里面还是有无衬线体)

具体的 FC_DEBUG 在这里:

Rule Set: /etc/fonts/conf.d/45-Hack.conf
FcConfigSubstitute test pattern any family Equal(ignore blanks) "Hack"
Substitute Edit family AppendLast "monospace"

这是第一个片段的表示,下面是结果:

Append list before  "Hack"(s) [marker]
Append list after  "Hack"(s) "monospace"(w)

第一个 Hack 是你 fc-match 给的。第二个加个 monospace 锚点。那我写的发行版默认的 monospace 规则就应用了啊,比如我要在 monospace 前 prepend DejaVu Sans Mono,它就出现在 Hack 和 monospace 锚点的中间。

以下是错的第二段:

FcConfigSubstitute test pattern any family Equal  "Hack"
FcConfigSubstitute test pattern any family Equal "sans-serif"
No match

你给的参数只有一个 Hack,所以默认 pattern 就是 Hack,你再找锚点?No Match。你想给两个参数?fontconfig 不支持。

@marguerite
明白了!应用了第一段正确的配置之后,fc-match -s Hack 的次序就变成正常的了(monospace 的排序在了前面)。

但是那样的话不应该把这个 45-Hack.conf 的第一段配置打包进 openSUSE 吗?

@xzhao 可以啊,完全没有问题啊…这个包不是我维护的,你可以提交啊

@marguerite
此外,想跟您讨论如下的情况:

因为 /etc/fonts/conf.d/49-sansserif.conf,我 fc-match -s Hack 的时候,匹配列表会变成下面这样:
Hack, …, sans-serif,…

而在~/.config/fontconfig/fonts.conf 里面加上

<alias>
    <family>Hack</family>
    <default><family>monospace</family></default>
</alias>

之后,匹配列表(在展开虚拟锚点规则之前)会变成下面这样(注意, ${HOME}下面的规则会在/etc 下面的规则之后被执行):
Hack, …, sans-serif,…,monospace

这样的话,对于 Hack 当中没有的字形,会去找 sans-serif(的展开)而不是 monospace。

如果再增加一个规则:

 <match>
     <test compare="eq" name="family">
         <string>sans-serif</string>
     </test>
     <test compare="eq" name="family">
         <string>monospace</string>
     </test>
     <edit mode="delete" name="family"/>
 </match>

匹配列表会变成:
Hack, …monospace

这样的话,对于 Hack 当中没有的字形,会去找 monospace 展开的字形了。