tech

WebFont 缺字时怎么办:修复缺字回退与基线错位

不规范使用 SF Pro SC 的小技巧

自从我的博客改用我新作的主题以来,有一个问题就一直萦绕在我的心头……

我上一篇文章的副标题
我上一篇文章的副标题

发现了吗,看这个副标题,「钊钊」两个字,明显比其他的字大一圈!

而且不单单这一篇,其实许多文章,都出现了这样的问题。

一坨,噩梦,阿姨,电驴,全是突然变大的字
一坨,噩梦,阿姨,电驴,全是突然变大的字

这是为什么呢?

高级的 SF Pro SC

这个新主题是我一比一复刻了 Apple Newsroom 的样式制作而成。为了完全一致地达到 Apple 的文字排印效果,我使用了 Apple 为网站特制优化的中文字体「SF Pro SC」。

这款字体主要是对苹果目前系统字体「苹方-简」的修改版:缩小了汉字的大小,调整了标点符号的宽度,使得中英文混合排版以及网页排版更加美观。为了更加详细了解这个字体,并且方便下文的理解,推荐读者可以先阅读李瑞东老师的这篇博文:SF Pro SC 是什么字体?

然而,追求苹果那样优雅的高级感却是有代价的。这款字(对于非苹果官方)在使用上却有着一个很大的问题,就是字符数太少

所谓的 SF Pro SC 只有 4587 个中文字符型 _(:з」∠)_
所谓的 SF Pro SC 只有 4587 个中文字符型 _(:з」∠)_

也许 4587 个中文字符型看着很多,但是……很遗憾不知道苹果是怎么选取这些字的范围的。常用的「姨」「驴」字都没有包含在内,而「蠟」「豔」「弢」这种奇怪的生僻字又是包含在内的。

这就带来了问题:博客写作中,太多的字,并不被包含在这个字体内。而一旦缺字触发回退(fallback),又因为 SF Pro SC 的字形被人为缩小过,回退后使用系统字体(在我的博客中,优先的回退字体是真正的「苹方-简」)就会显得大一圈。

我其实一开始就知道这个问题,不过一直懒着拖着没有想去解决。偶尔跳几个字大一点?……算了,好像也不是不能忍。

直到后来给主题加了副标题显示,看到自己朋友的名字显示出来这么奇怪……唉(这个「唉」也是字体里缺的)!还是想想怎么改善吧。

「补字」的拉锯战

我真正的需求:

  1. 保持视觉一致:回退是必然,但是应该保证回退时,字的大小和基线不能突变。
  2. 不想逐字 patch: 维护成本太高了,累死我。
  3. 不动原始字体:我不能去改这个「SF Pro SC」,本来用了就不礼貌了,再改更不礼貌了。而且苹果可能未来会更新,把字符数量提上去。
  4. 中文环境兼容性:中文字体家族本身差异很大,要考虑跨平台。

基于这些想法,我逐一尝试了下面的思路。

size-adjust:把回退缩小

似乎幸运的是,CSS 提供了size-adjust,可以对某个字体整体缩放包装成新字体。WOW,难道这个问题这么方便就能被我解决了?我只需要回退到其他的系统无衬线体,然后给它加个缩放不就好了?

遵照 Apple Newsroom 的样式,我原先的字体回退是:

1
2
3
p {
font-family: "SF Pro SC", "SF Pro Text", "SF Pro Icons", "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif;
}

下面,我希望让字体优先回退到我包装的「缩小版」字体上。姑且把这个字体叫做「PingFang SC local」吧。将它写进正确的回退位置(注意在真正的 “PingFang SC” 前面)。

1
2
3
p {
font-family: "SF Pro SC", "SF Pro Text", "SF Pro Icons", "PingFang SC local", "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif;
}

在 CSS 里搞一个自定义字体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@font-face {
font-family: 'PingFang SC local';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('PingFang SC Regular'), local('PingFangSC-Regular');
size-adjust: 89%;
}
@font-face {
font-family: 'PingFang SC local';
font-style: normal;
font-weight: 500;
font-display: swap;
src: local('PingFang SC Medium'), local('PingFangSC-Medium');
size-adjust: 89%;
}
@font-face {
font-family: 'PingFang SC local';
font-style: normal;
font-weight: 600;
font-display: swap;
src: local('PingFang SC Semibold'), local('PingFangSC-Semibold');
size-adjust: 89%;
}

见证奇迹的时刻!

看上去似乎好了一点
看上去似乎好了一点

奇迹发生了……一半。

字确实缩小了,但是,同样很明显的是,这几个字在往下掉。这看着真是难受!

我问问 GPT,它说可以用ascent-override/descent-override/line-gap-override这几个@font-face属性来解决。但我实际试了几次,发现根本没用。

CSS 微移:vertical-align / transform

怎么搞都搞不定,我想不开了,在这篇祝我生日快乐2023的文章里,把每一个会回退的字,都用<span class="font-fix">给包上了,然后写 CSS:

1
2
3
.font-fix {
vertical-align: -0.045em;
}

嗯,看上去是对齐了,但其实这样会导致上下两行行高不一致。所以后来又换了:

1
2
3
4
.font-fix {
display: inline-block;
transform: translateY(-0.045em)
}

(注意,span 标签的 transform 不可用于 inline 对象的,所以先改成 inline-block。另外,这样一来,@font-face 的缩放也可以直接在这里用 scale 完成。)

这样确实齐平了(原谅我没有截图)。不失为一种……差强人意的办法。这是有效的,但是:

  • 要把每个字揪出来单独用标签合上,逐字处理,麻烦,写新文章改老文章,都麻烦;
  • 如果改成用 js 去检测,js 也不好写,对性能也不好;
  • 不适合副标题,副标题是用 front matter 生成控制的,手动写 HTML 标签最多用于 Markdown 内部;
  • 如果用鼠标滑动选中能看出来这个字的高度位置被上移过。

总之,挺麻烦的,再加上如果想要跨平台,Windows 上也没有苹方。我试着用「微软雅黑UI」作为回退方案,但是微软雅黑真是太丑了,而且只有 Light,Regular,Bold 三种字重,和我主题使用的 Regular,Medium,Semibold 都不相符,看起来很不协调,所以,最终不得不放弃这个方案。

改个字体用用

我一开始不是没想过这个选择——改一份字体出来用作网站字体。实在是想想就觉得麻烦!

但是现在不得不回到这个选择…… 淦!

其实这个方案是很不划算,中文字体普遍很大,连字形最简单的黑体至少也要 5MB 以上,这对网页加载速度来说是很不好的——太晚加载出字体显示会造成 Layout Shift,这在绝对会拉低网页评分。而且手动改了字体,只能托管在自己的服务器,就无法享受 Google Font 中直接引用的 CDN 加速和分包的优势。

经过简单的选择,用了思源黑体(现在也可以是 Noto Sans)作为改版的来源。理由如下:

  • 看起来比较顺眼,各个字重的粗细和苹方比较一致;
  • 开源的,放心改,不怕;
  • 可变字体,我要三个字重不用拿 3 份字体文件,稍稍对网站性能好一点;
  • 字符数量绝对够,字重够。要是哪天我想用到超级生僻字或者 Ultra Light 这种字重也能满足我。

那么,在 Google Fonts 中下载 Noto Sans SC,得到NotoSansSC-VariableFont_wght.ttf

说起来,用 Font Creator 来批量修改字符好像比较方便。不过我没有,我用 Glyphs。那么,用 Glyphs 打开 Noto Sans SC,稍微修改一下不兼容的母版,测试一下可以正常导出可变字体的 woff2 字体文件。

下一步很关键。我们要进行缩放和字的量度(metrics)的调整,来达到字的「变小」。注意,如果直接把字缩小,只会造成每个字符字面率变小,排出来每个字之间都有空隙;因此,缩小字符型后,要缩小字符的宽度,这样才能密排。以及,不要同时改变字的高度,这样字排出来依然是大的。原理我不用讲了吧。

变换机制的示意图
变换机制的示意图

原本 Noto Sans SC 的字宽是 1000,依照 SF Pro SC 的数据,应当缩小为 887。学习一下 Glyphs 官方的脚本教程,让 GPT 辅助来写这个「缩放+改变字宽」的脚本:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# MenuTitle: 对所有母版重做:缩放88.7%
# -*- coding: utf-8 -*-

from GlyphsApp import *
from AppKit import NSPoint

SCALE = 0.887
OLD_WIDTH = 1000.0
NEW_WIDTH = 887.0
WIDTH_TOL = 0.0
CX, CY = 500.0, 0 # 缩放中心

def scale_point_about(pt, cx, cy, s):
dx, dy = pt.x - cx, pt.y - cy
return NSPoint(cx + dx * s, cy + dy * s)

font = Glyphs.font
if not font:
raise Exception("请先打开字体。")

processed = 0
font.disableUpdateInterface()
try:
for g in font.glyphs:
if not g.export:
continue
# 只遍历“母版”层
for m in font.masters:
layer = g.layers[m.id]
if not layer:
continue

oldW = float(layer.width)
if abs(oldW - OLD_WIDTH) > WIDTH_TOL:
continue # 只处理宽度=1000的母版层

has_content = (layer.paths and len(layer.paths) > 0) or (layer.components and len(layer.components) > 0)

# 组件先分解为路径
if has_content:
try:
if len(layer.components) > 0 and hasattr(layer, "decomposeComponents"):
layer.decomposeComponents()
except Exception:
pass

# 等比缩放路径节点与锚点
for p in (layer.paths or []):
for n in (p.nodes or []):
n.position = scale_point_about(n.position, CX, CY, SCALE)
for a in (layer.anchors or []):
a.position = scale_point_about(a.position, CX, CY, SCALE)

# 缩放后依据边界把内容水平居中到新宽度
b = None
try:
b = layer.bounds
except Exception:
pass

if b:
center_x = b.origin.x + b.size.width * 0.5
target_center_x = NEW_WIDTH * 0.5
shiftX = target_center_x - center_x
if shiftX:
for p in (layer.paths or []):
for n in (p.nodes or []):
n.x += shiftX
for a in (layer.anchors or []):
a.x += shiftX

# 设置新宽度
layer.width = NEW_WIDTH
processed += 1

Glyphs.showNotification(
"Masters scaled",
"已处理母版层:%d(只处理宽度=%.0f的层)。" % (processed, OLD_WIDTH)
)
print("Done. Master layers processed:", processed)

finally:
font.enableUpdateInterface()

一些细节:

  • 操作所有的「母版」;
  • 只对宽度值为 1000 的字符型进行操作。嗯,姑且认为宽度为 1000 的都是中文或者中文相关字符,反正都是要缩小的;而拉丁和数字等变宽字符是不用的;
  • 缩放的变换中心点坐标是 (500,0),注意基线的 y 坐标值是 0,而非下降部的高度。

使用此时导出的字体作为PingFang SC local,查看效果。

1
2
3
4
5
6
7
@font-face {
font-family: 'PingFang SC local';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/fonts/NotoSansSCVF.woff2") format("woff2-variations");
}

嗯,大致是可以的,感觉回退的字稍微比别的字高了一点。再做一下高度的移动:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
# MenuTitle: 对所有母版重做:上移30
# -*- coding: utf-8 -*-

from GlyphsApp import *
from AppKit import NSPoint

SHIFT_Y = 30.0 # 向上移动 30,向下填负数
TARGET_WIDTH = 887.0 # 仅处理字宽≈这个值的层
WIDTH_TOL = 0.0 # 判断“≈TARGET_WIDTH”的容差
ONLY_EXPORT = True # 仅处理可导出字形

font = Glyphs.font
if not font:
raise Exception("请先打开字体。")

processed = 0
font.disableUpdateInterface()
try:
for g in font.glyphs:
if ONLY_EXPORT and not g.export:
continue
# 遍历所有母版层
for m in font.masters:
layer = g.layers[m.id]
if not layer:
continue

# 只处理宽度≈TARGET_WIDTH 的层
if abs(float(layer.width) - TARGET_WIDTH) > WIDTH_TOL:
continue

# 移动路径节点
if layer.paths:
for p in layer.paths:
if p.nodes:
for n in p.nodes:
n.y += SHIFT_Y

# 移动组件
if layer.components:
for c in layer.components:
try:
pos = c.position
c.position = NSPoint(pos.x, pos.y + SHIFT_Y)
except Exception:
pass

# 移动锚点
if layer.anchors:
for a in layer.anchors:
a.y += SHIFT_Y

#(可选)层内指导线,如需也移动可解除注释
# if layer.guides:
# for guide in layer.guides:
# guide.position = NSPoint(guide.position.x, guide.position.y + SHIFT_Y)

processed += 1

Glyphs.showNotification(
"Shift Y done",
"已处理母版层:%d;每层 Y 平移 %.1f(仅字宽≈%.0f)。" % (processed, SHIFT_Y, TARGET_WIDTH)
)
print("Done. Master layers processed:", processed, " | ShiftY:", SHIFT_Y)
finally:
font.enableUpdateInterface()

最后参照 SF Pro SC 的数据修改量度数据。

主要是修改了上升部和下降部。似乎这样改了会对竖排造成影响……不过我也不竖排了,要是有影响我也认了🥲
主要是修改了上升部和下降部。似乎这样改了会对竖排造成影响……不过我也不竖排了,要是有影响我也认了🥲

大功告成。得到的 NotoSansSCVF.woff2 大小大约在 8MB 左右,作为回退字体,不分包也可以接受。就让这部分回退的字稍微等一等再加载出来吧。嗯,尽管有些字的风格和苹方那个不是很相符,也就这样吧。

现在,你正在看的这篇文章中这些字,应该都已经看起来正常的显示了。

对比一下前后的效果
对比一下前后的效果

总结

写不动了。草草总结一下:

字体设计与网页排版,是要认真对待的。追求精致的视觉效果时,有时「缺字」这个小细节,足以牵动整个页面的观感。CSS 勉强救急吧,看来根本的答案,还是要动字体……

  • 觉得本文不错?
    本文采用 CC BY-NC-SA 4.0 协议共享,欢迎分享与转载。