另一种很新的中文字体网页嵌入方案

当你看到 一种很新的中文字体网页嵌入方案但是懒得优化 DP也不会进化算法你可以……

整一个一种很新的中文字体网页嵌入方案

这就是你 开坑 DDPP 不填的理由吗

背景

前不久看到 一种很新的中文字体网页嵌入方案还想着没必要做这种优化毕竟按 Google Fonts 进行子集化就可以做到每页 1MB 左右按现代的网速不会有太大问题用 devtools 开节流试了下看起来也还行

然后我回家了离开了校园网虽然自定义域名的 Cloudflare Pages 还能访问但速度暂且不论丢包率就很有点恐怖经常页面加载一半就卡住了

再然后Google Fonts 里 Noto Serif SC 的 unicode range 不含单引号和撇号是同一个字符导致 DDPP 序 里引用的那段话的撇号在我的手机上显示成了很宽的另一种字体才让我发现这个问题为了以后能及时发现这种问题我就设置了不使用本地字体直接使用 web font 的 Stylus

恐怖的丢包率加上自己亲身体验 web font 加载让我意识到了在国内普通网络环境下如果你的设备没有安装思源宋体访问我的博客会是多么难受

主体思路

另一种的主体思路一种是一样的就是把字体划分成常用字和非常用字但是一种在这之后选择了使用动态规划 + 进化算法来对常用字进行进一步的拆分另一种所做的优化则基于这样一个观察虽然一个博客有一堆页面但 80% 的人只会访问 20% 的页面一个字体是否常用不仅要看出现在几个页面还要看出现在哪些页面

这时候一直充当不蒜子平替哪平价了啊 Plausible Analytics 就发挥作用了近段时间的页面访问量可以用来估计每个页面被访问的概率为常用字的划分提供可靠的数据支撑

一旦估计出了每个页面的被访问概率就可以对每个 glyph 分别计算出如果在每一个使用了它的页面上都加载一遍期望代价是多少也就是使用了它的所有页面的被访问概率之和如果这个数大于 1就设为常用否则设为不常用

这个思路还是非常简单的可以说只是结合实际采取启发式方法也称不上是一种新的方案

具体实现

虽然思路简单但实现起来还是有点复杂的坑有点多

获取页面使用的字体

如果整个页面都使用一种字体直接看 HTML 里有哪些中文就差不多了但我不仅非正文用的是黑体引用块还是楷体说不定在哪冒出来个等宽或者 LaTeX公式\LaTeX\text{公式}有时候还会加粗更别提心情好的时候会像这段一样直接塞 <span class=""> 来修改样式

一开始我通过 subfont 找到了 assetgraph/font-tracer但这个 font-tracer 就两个 star没有文档AssetGraph 又是一个 12 年前的老项目连 Definitely Typed 都没有试了一下完全用不会

想了想最靠谱的还是直接交给浏览器来处理所以就用 puppeteer目前采用的算法是遍历 <body>跳过 <script><style> 子树找到所有 Text 节点再加上所有 <img>alt 属性找到节点后可以用 getComputedStyle 获取 font-familyfont-weight 等信息

因为要处理很多页面也要花一点时间所以用了 puppeteer-cluster 来并行处理不知道为什么在 vitest 和 iles 中运行时不加参数会报错而加了 --no-sandbox --no-zygote 之后并行的优化效果就差一些

代码getPagesFontInfo.ts

字体匹配

完整的字体匹配是非常复杂的我也没找到相关的库就自己写了一个简陋的只匹配完整 font-familyfont-weight 的算法

其实一般 font-weight 也就 400 和 700但我还是实现了一个 完整的 font-weight fallback

代码matchFonts.ts

常用字体划分

最关键的这一步实现起来是最简单的因为是纯算法的不涉及到可怕的 Web

代码getCommonGlyphs.ts

生成字体文件和 @font-face

我使用的是 subset-font 来生成字体子集文件

这一步最大的困难是正确地写出 @font-face

一开始我是给常用字 (common) 和非常用字 (unique) 不同的 font-family然后发现在特殊情况下幸好我的博客文章列表就触发了这个问题不然真没想到某个字的常规样式属于 common 而粗体属于 unique由于 font-family 里 common 排在 unique 前面就会匹配到常规样式而加载不出粗体

为了解决这个问题common 和 unique 需要使用同一个 font-family因为用过 Google Fonts 子集划分方案我知道有不同 unicode-range@font-face 是可以组合在一起的难道我需要计算出精确的 unicode-range 写在 CSS 里?

为此我粗略地读了半天这两个词不冲突CSS Fonts Module Level 4 W3C Working Draft发现它说unicode-range 可以比真实的 character map 大很多若干 @font-faceunicode-range 也可以相交但是它没说两个 @font-face 能不能除了 src 啥都一样而只说了

Multiple @font-face rules with different unicode ranges for the same family and style descriptor values can be used to create composite fonts that mix the glyphs from different fonts for different scripts.

我也不敢依赖于实验结果就没做实验直接加上了粗略但是保证 common 和 unique 不相同的 unicode-range具体来说就是 0 到最大的 code point

这里从 0 开始也是有一定原因的我在看 specification 的时候注意到一个奇怪的东西叫 first available font虽然没太看懂具体是什么意思但大致上感觉最好是让 unicode-range 包含空格为了保险就从 0 开始了

最后还有一个坑如果 composite font 中两个 @font-faceunicode-range 相交给交集内的字符匹配字体时会按出现位置的逆序进行匹配

这能有什么坑呢如果不动手试一试真的很难想到.. 按照整体的设计访问一个页面时很有可能是 common 字体已经缓存好了需要下载 unique 字体如果 unique 的 @font-face 放在 common 的后面就会优先匹配从而阻塞住已经缓存的 common 字体整个页面都要等 unique 下载好才切换字体而不是先显示 common 再显示剩下的 unique当然反过来的话unique 也会等 common但这样一般来说是更好的

还有一些细节优化

  • 生成 unique 字体文件时文件名里有 hash 就够了不要再添加和页面有关的信息这样的话如果两个页面刚好 unique glyph 集合相同就可以共用一个字体文件
  • 如果一个页面一个 common glyph 都没用就可以不添加 common font 的 @font-face

代码generateFontFiles.ts

将 CSS 写入 HTML 文件

虽然也有想过直接找到 </head> 文本替换但为了靠谱还是用的 jsdom

这部分的代码是最短的injectCSS.ts

给项目起名

主体思路是把 glyph 分成两类分别对待就很有 segregate 的感觉

主要还是因为刚写了一个 segregated fit 印象比较深刻所以就取了这么个名字

在博客中使用

就是 从 iles 获取页面信息Plausible Stats API 获取访问量调用 Vite API 跑一个 preview server然后调用 glyph-segregator直接看代码

访问概率的计算方式是看每个页面近 90 天的访客数加一后除以其中最高的加二其实这个加一加二基本没啥用用最高页面访客数而非实际总访客数作分母是想在算法的基础上更加偏向于划分到常用字一些稍微优化一点访问页面较多的访客的体验也可以少一次 API 查询

更新常用字集合用的是 scheduled GitHub Actions在 Docker 里跑是因为 Connection refused for local server in github actions workflow

实际效果

整个 glyphSegregator 用时 40s 左右glyph 数量和 common font file size (woff2) 如表所示

Noto Serif SC Regular Noto Serif SC Bold LXGW WenKai Regular LXGW WenKai Bold
common 575 (162.7KiB) 25 (8.5KiB) 37 (5.2KiB) 0
unique 2311 1536 161 0

首页只需加载两个 Regular 的 common font每个页面需要加载的字体文件总大小缩减到了原来使用子集化的 1/4 左右并且加载的字体文件数量从 10~30 个缩减到了 2~6 个

作为对比如果出现在两个页面就设为常用字Noto Serif SC Regular 的 common 有 299KiB出现在三个页面则是 227KiB

后记font subsetting 与 kerning

因为通过 Stylus 设置了不使用本地字体我可以轻松地在本地字体和 web font 之间切换然后我就偶然发现切换时一些标题发生了字符的偏移原因也很简单就是相邻的两个字符本来有 kerning (字距调整)但它们被划分到了不同的子集中生成字体子集时就丢失了 kerning 信息

我在 glyph-segregator 中 添加了 alwaysCommonGlyphs 选项ASCII 字符总是设为 common如果需要处理非 ASCII 字符的 kerning 也可以修改设置但我的博客就假设只有 ASCII 字符会遇到这个问题来解决这一问题最好的解决方案是真的去看一下字体里有哪些 kerning 信息然后相应地处理但是差不多得了

后记的后记还是改成了即使未被使用也添加进 common glyph set否则如果新加一个 always common 的 glyph为了让它 common就会改变 common glyph set导致 cache 失效这里有一定的 trade-off要在 cache 失效 / 多塞一些未被使用的 glyph / kerning 挂掉之间进行选择我还是选择了多塞一些未被使用的 glyph毕竟整个 ASCII 也没多大最好是借助人类智慧来预测一下哪些 glyph 更有可能在未来被添加而放进 common glyph set 里其他 glyph 就只有使用了才放但是差不多得了