#84 从删除线到默默无闻 MVAR
谜面藏于芸芸,谜底鲜于了了。
先看看删除线发生了什么:
苹方和 SF Pro 都是界面设计时常用的字体。在 Figma 中添加删除线样式时,上图中可以看到苹方和 SF 字体的删除线位置不一,一个偏下一个居中。原因是,删除线属性由字体内的信息 OS/2 表内两个参数控制:
<yStrikeoutSize value="120"/>
<!-- 删除线尺寸 -->
<yStrikeoutPosition value="620"/>
<!-- 删除线位置 -->
yStrikeoutPosition 的值是相对于 baseline 的高度,比如苹方字体(PingFang.ttc)的 yStrikeoutPosition 是 200,x-height 是 600 [1],所以苹方的默认删除线处于 x-height 1/3 处位置而不是近似 1/2 处。而像 Adobe illustrator 等软件,虽然和 Figma 同用 Harfbuzz 塑形引擎,但 Adobe 有着自己的一套渲染规则。如上图左一,同为苹方字体但删除线位置却不同。
[1] 苹方中,全身字宽单位是 1000,并非现在新新字体常见的 2048。
这会引出很多问题,比如:
- 如何手动修正删除线位置?
- 字体中删除线应该出于什么位置?
- 删除线的样式会随着可变字体改变吗?
本文的目的就是探索这些问题的背后还隐藏了什么信息。
修正删除线位置?
如果使用默认删除线属性,那么不同应用环境之间删除线的位置和粗细也会不同。Henrique Beier 在六年前写过一篇文章,对比了不同软件中下划线和删除线的位置和粗细,结果很让人意外。虽然六年后各大应用环境里做了不少统一,但是正如开头所提,差异依然存在。
一般来说,像苹方那样看着默认删除线位置不爽。设计师可以直接划一条杠,开发就要想方设法再画出一条线。以前端为例,无论是 <s>
还是 <del>
,实际浏览器都开始用 text-decoration-line
调取字体内信息渲染。如要自己划线——
- 古朴的做法有 border-bottom 上移;
- 或者 ::after 选择器手搓个矩形;
- 选择使用 Unicode 组合变音符号区(Combining Diacritical Marks)的 Combining Long Stroke Overlay (U+0336),和文字「组合」成删除线样式。但这在 CJK 字体上会有异常,比如 Chrome 里会直接变「豆腐」(.notdef),Firefox 倒是会显示却也有异样 ;
- 后来下划线有独立的 CSS 特性,可以使用
text-underline-offset
挪动下划线,还可以定义粗细和颜色;
说起手打 Unicode,macOS 自带了 U+16 输入法,只需选择后,按下 option 键输入几位万国码,松开 option 键后即可完成输入。其他系统可详见这篇文章。
应该在什么位置?
我没有接受过专业的字体设计教育,也还没有读到如何摆放删除线位置的文章。我的猜测是,适用于正文的字体,删除线的预期出现位置应该在 x-height 垂直居中处。为了视觉平衡,实际删除线位置会高于 x-height 一半。且粗细小于字干(stem),与字横(bar)相当。
为了验证,我在 Coze 上建立个 bot🤖 [2],让它帮我写个 python 脚本,需求是识别一个文件夹内多个字体的 x-height 和 yStrikeoutPosition 数值,将其除以全身字宽 ,并导出为 csv 文件。font tools 速度拖进 Font Table Viewer 快多了。最终结果我汇聚到了飞书文档。
如图我选用了一些经典的开源、企业和古典字体,以无衬线体居多。可以看到大部分字体的删除线位置居于 x-height 的 50%-60% 处,符合上面推测。
另外一提,OPPO Sans 的 x-height 有很大问题,数值上比实际 x-height 小得多,所以这里有很大偏差……Palatino 的删除线则低到了脚下,都是比较极端的。Optima 则是没有定义 x-Height 参数,自测得知。
[2] 随后把这个 bot 布置到了 Discord,辅助完成本文。非广告,白嫖 GPT-4 (8K) 还是很爽的。
可变删除线?
除了删除线位置,尺寸粗细(yStrikeoutSize)也让人在意。很多静态字体无论字重只会定义一个删除线位置和尺寸信息。这就会导致字体在很粗的情况下,删除线却很细这种情况。
目前很多可变字体,删除线信息仅跟着母版走。母版之外的字重,会取相近字重对应的数值,而不是随着可变轴一起插值变化。
很久很久以前,2016 年华沙,OpenType 1.8 发布,OpenType Variable Fonts 归来,其中一个特性是增加了 MVAR 表,用于微调各种字体参数,以及 Delta [3] 的峰值插值。原本 OS/2, hhea, vhea and post 表中固定数值都可以对字体进行微调,其中就包括删除线位置和尺寸。你可以在下方微软这篇文档中查看详细。
[3] Delta 可理解为可变字形的矢量锚点所变化的路径,如下图。
顺便一提,目前几乎所有 OpenType 相关文章都绕不开 Microsoft Typography 的内容引用,微软虽然界面一直做得不怎么样,文档这方面还是很负责任。
但即便文档已足够详细,恕我愚钝,大多数篇章依然读起来很吃力。如果想简单了解一下 MVAR 这个小 XML 表,不妨继续阅读下去。
MVAR Table
MVAR 继承自苹果 TrueType GX 数据表中的 fmtx 表,属于可选内容,即使不存在也不会影响一款可变字体的正常使用。但如果想要解决上面提出的删除线可变位置和尺寸问题,就必须要用到它。
MVAR 能达成的效果如下面动图所示:
上图是我从 Inter 字体作者 Rasmus Andersson 的过往推文 threads 上下载下来的测试包。里面只有一条控制字重的可变轴,原本是测试下划线可变样式,我稍做修改加入了删除线样式,并且修复了一个 text-decoration shorthand 写法初始化的小 bug💦[4]
[4] 出问题的源代码在这里,貌似 text-decoration-thickness 写在 body 里不会生效(被继承)。
解析字体文件里的 MVAR 表,能看到:
<Version value="0x00010000"/>
<Reserved value="0"/>
<ValueRecordSize value="8"/>
<!-- ValueRecordCount=3 -->
<VarStore Format="1">
<Format value="1"/>
<VarRegionList>
<!-- RegionAxisCount=1 -->
<!-- RegionCount=1 -->
<Region index="0">
<VarRegionAxis index="0">
<StartCoord value="0.0"/>
<PeakCoord value="1.0"/>
<EndCoord value="1.0"/>
</VarRegionAxis>
</Region>
</VarRegionList>
<!-- VarDataCount=1 -->
<VarData index="0">
<!-- ItemCount=2 -->
<NumShorts value="0"/>
<!-- VarRegionCount=1 -->
<VarRegionIndex index="0" value="0"/>
<Item index="0" value="[-64]"/>
<Item index="1" value="[120]"/>
</VarData>
</VarStore>
<ValueRecord index="0">
<ValueTag value="strs"/>
<VarIdx value="1"/>
</ValueRecord>
<ValueRecord index="1">
<ValueTag value="undo"/>
<VarIdx value="0"/>
</ValueRecord>
<ValueRecord index="2">
<ValueTag value="unds"/>
<VarIdx value="1"/>
</ValueRecord>
整段代码分为三部分:
- 上方
VarRegionLis>
代表字体可变轴 delta set 的初始和峰值位置。 - 中间
VarData
代表下方ValueTag
相对于可变轴的 delta 变量值。 VarRegionList
和VarData
共同存储在VarStore
中。
由于轴数不多,Delta set 在下一个例子中再看。VarData
中储存着两条 Item
值,索引为0和1。ValueRecord
有三条,其 ValueTag
分别为:
- strs:(对应 OS/2.yStrikeoutSize),删除线尺寸;
- undo: (对应 post.underlinePosition),下划线位置;
- unds:(对应 post.underlineThickness),下划线尺寸;
以第 0 条 ValueRecord
为例,VarIdx
值为 1。这不是简单的10进制,而是由两个 unit16 数据类型缝合而成:
0000 0000 0000 0000 | 0000 0000 0000 0001
这两个整数值 deltaSetOuterIndex 和 deltaSetInnerIndex 分别对应 VarData
和里面 Item
的索引值,即第 0 个 VarData
里面第1个 Item
值,是 120。也就是说该字体删除线尺寸,随着字重可变轴从头到尾变换会增加 120 单位(虽然数据类型是数组但是只有1 个轴),yStrikeoutSize 从 160 升到了 280。这也是上方动图的效果。下划线属性同理。
俯视 Delta Set
再来看一个多轴的例子。我找到了 Recursive 1.077 版本的可变字体文件,里面保留了 MVAR 表。Rescrive 有五个可变轴, delta-set 有22条,比较复杂。好在有 Laurence Penney 这样的好心人做了 Samsa 这个可变字体检视网站,非常 respect。
将下载好的 Recursive_VF_1.077.ttf 拖拽进 Samsa 网页中,就能看到丰富的可变字体参数。将 UI 预览模式勾选 ,你能看到字体框架的这个样式便是 delta 的可视化。
点开 delta sets 一栏能看到各种「三角形」。只调节 wght 轴(字重)到 800 (ExtraBold)后,Delta sets 中的 wght 一列开始有红线指示。
可以看到第 2 行变白,代表当前正在使用该 delta 参数的状态。通常该表存储在 gvar 中,该表不单独存在时,会调用 MVAR 或 HVAR 中的 VarRegionList
。源文件中第 2 条是:
<Region index="2">
<VarRegionAxis index="0">
<StartCoord value="0.0"/>
<PeakCoord value="0.0"/>
<EndCoord value="0.0"/>
</VarRegionAxis>
<VarRegionAxis index="1">
<StartCoord value="0.0"/>
<PeakCoord value="0.0"/>
<EndCoord value="0.0"/>
</VarRegionAxis>
<VarRegionAxis index="2"> <!-- 轴排名 -->
<StartCoord value="0.0"/> <!-- 起始坐标 -->
<PeakCoord value="0.6057"/> <!-- 峰值坐标 -->
<EndCoord value="1.0"/> <!-- 终点坐标 -->
</VarRegionAxis>
<VarRegionAxis index="3">
<StartCoord value="0.0"/>
<PeakCoord value="0.0"/>
<EndCoord value="0.0"/>
</VarRegionAxis>
<VarRegionAxis index="4">
<StartCoord value="0.0"/>
<PeakCoord value="0.0"/>
<EndCoord value="0.0"/>
</VarRegionAxis>
</Region>
第 2 条轴的 delta 变量从 0 开始,到 1 结束,并在 0.6075 处达到峰值。
这便是上图黑色小三角的由来。每当共同调整不同可变轴时,会根据实际字型微调每个锚点的 delta 大小。比如该参数代表的是, Recursive 从 300 字重调到 800 是一种粗细变化,从 800 到 1000 是另一种粗细变化。
至于为什么 0.6075 是 800,而不是 [0.6075 * (1000-300)] + 300 = 725.25。因为在标准化的流程中,作者在 avar 表定义了映射:
<segment axis="wght">
<mapping from="-1.0" to="-1.0"/>
<mapping from="0.0" to="0.0"/>
<mapping from="0.1429" to="0.2514"/>
<mapping from="0.2857" to="0.34"/>
<mapping from="0.4286" to="0.4286"/>
<mapping from="0.5714" to="0.51715"/>
<mapping from="0.7143" to="0.6057"/>
<mapping from="0.8571" to="0.8686"/>
<mapping from="1.0" to="1.0"/>
</segment>
0.7143 对应的是 800 也就是 ExtraBold 字重,但实际上 725.25 字重便可满足 ExtraBold 需求,所以会重新映射为 0.6075,也间接影响到坐标的定义。
当有多个轴变化并进入相应 StartCoord 和 EndCoord 之间时,就会触发多个 scalar 值,delta 变得极为灵活,相乘然后叠加便是受到多轴影响的可变字体最终形态。Delta 数量不设限。对此感兴趣的话,十分建议在 Samsa 中亲自体验一下。
再看多轴 MVAR
之后再看 Recursive 的多轴 MVAR 表是什么样子(删去了部分代码):
<VarStore Format="1">
<Format value="1"/>
<VarData index="0"> ... </VarData>
<VarData index="1"> ... </VarData>
<VarData index="2">
<!-- ItemCount=1 -->
<NumShorts value="0"/>
<!-- VarRegionCount=5 -->
<VarRegionIndex index="0" value="1"/>
<VarRegionIndex index="1" value="2"/>
<VarRegionIndex index="2" value="3"/>
<VarRegionIndex index="3" value="9"/>
<VarRegionIndex index="4" value="10"/>
<Item index="0" value="[-2, 40, 30, -2, -3]"/>
</VarData>
</VarStore>
<ValueRecord index="0"> ... </ValueRecord>
<ValueRecord index="1"> ... </ValueRecord>
<ValueRecord index="2"> ... </ValueRecord>
<ValueRecord index="3"> ... </ValueRecord>
<ValueRecord index="4">
<ValueTag value="stro"/>
<VarIdx value="131072"/>
</ValueRecord>
stro 的索引值是 131072,拆分并补位到 32 位为:
0000 0000 0000 0010 | 0000 0000 0000 0000
前面是 2 后面是 0,即删除线位置使用了第 2 个 VarData
中第 0 个 Item
的值 [-2, 40, 30, -2, -3]。5 个 delta 值对应该 VarData
中引用的 5 条 Region
变化,这 5 条 Region
也对应着 VarRegionList
的 delta set——正如上小节所言。这里索引值是 1、2、3、9、10,对应的 delta set 是:
<Region index="1"> ... </Region>
<Region index="2"> ... </Region>
<Region index="3"> ... </Region>
<Region index="9"> ... </Region>
<Region index="10"> ... </Region>
可以看出,Recursive 删除线位置只跟字重(wght)和 Casual (CASL)两条轴有关。
所幸计算方面也是简单的加减乘除。假设把 wght 调到 900,CASL 调到 0.5,根据上一节的计算原理,5 个区域对应的 scalar 为 0.5、0.333、0.667、0.167 和 0.333。实际删除线位置变化为:
0.5 x (-2) + 0.333 x 40 + 0.667 x 30 + 0.167 x (-2) + 0.333 x (-3) = 30.997
yStrikeoutPosition 从 284 升到了 315。其他 tag 同理。
默默无闻 MVAR?
由于种种渲染和显示原因,MVAR 虽早早诞生但落地一直很艰难。
- 早期 Windows 10 DirectWrite 也有 MVAR 的 bug,导致一些可变字体开始移除 MVAR 表;
- Roboto Flex 支持 13 条可变轴,但是不支持 MVAR;
- Font Bakery 会对 MVAR 表报错;
- FontForge (January 2023 Release) 目前依然会主动忽视 MVAR 表;
- Glyphs (3.X) 目前也不支持,即便 fonttools 那边早已支持;
- Chrome(120)目前依然不能渲染正确 MVAR 字体参数,详见;
- ……
作为可变字体中一个可有可无的表,属于互联网犄角旮旯半成品的边角料,修复优先级自然不高。字体设计师见此也大都暂且搁置微调,久而久之变得默默无闻。但互联网也从不缺少怜悯之心,2021、2022 年都有人呼吁停止将 MVAR 拒之门外。随着 foottools 提案和更新 designspace v5,有设计师又将 MVAR 带回到字体中。
MVAR 未来依然「生死未卜」,但我很期冀不要让 bug 磨灭了字体设计中的匠人精神之光辉。
附录
以下是一些探索过程中发现的其他文章,以供拓展阅读:
- CFF2 编码字体的可执行漏洞:通过操纵 blend 参数和 VarRegionList 来写入字节
- Text Rendering Hates You:浏览器引擎二三事
- SwiftUI: Text Customization:SwiftUI 真是方便……
- Combining Diacritical Marks 一些用法
- Introducing OpenType Variable Fonts:比微软文档更易读的版本
- Karsten Lücke 的作字笔记
- CSS from-font 属性探索
- Chrome 中下划线 BUG
- 非常棒的下划线和删除线测试网页
- 张擎天的个人网站 分享排版设计
- 参数化设计与字体战争:从 OpenType 1.8 说起(非常经典)
以上文章可通过点击这里添加到你的 Arc 浏览器文件夹,或使用其他浏览器在线查看。
另,图中所用灰色说明字体均为 Recursive,作为开源字体真的花了很多心思。
如果你觉得文章对你有些帮助,可以请我的猫吃罐头 ↓