#84 从删除线到默默无闻 MVAR

谜面藏于芸芸,谜底鲜于了了。

Design Scenes 第 84 期封面图。

先看看删除线发生了什么:

苹方和 SF Pro 字体在 Adobe Illustrator 和 Figma 中的删除线样式

苹方和 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 在六年前写过一篇文章,对比了不同软件中下划线和删除线的位置和粗细,结果很让人意外。虽然六年后各大应用环境里做了不少统一,但是正如开头所提,差异依然存在。

The state of underlines and strikethroughs - Harbor Type | Fonts made in Brazil
Some weeks ago, while developing a custom typeface for a client, I was asked about adjusting the position and thickness of the underlines and strikethroughs. I knew it was possible to customize these values in Glyphs, Fontlab or any other font editor. But honestly, I didn’t usually bother to configure them because I knew they weren’t really enforced by most graphic software. This was a custom project, so fine-tuned underlines and strikethroughs could potentially save my client from a few headaches when typesetting documents. With that in mind, I decided to investigate further.

一般来说,像苹方那样看着默认删除线位置不爽。设计师可以直接划一条杠,开发就要想方设法再画出一条线。以前端为例,无论是 <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) 还是很爽的。

bot 在 Discord 里向文章读者打招呼

可变删除线?

除了删除线位置,尺寸粗细(yStrikeoutSize)也让人在意。很多静态字体无论字重只会定义一个删除线位置和尺寸信息。这就会导致字体在很粗的情况下,删除线却很细这种情况。

Work Sans 静态字体不同字重下的删除线样式

目前很多可变字体,删除线信息仅跟着母版走。母版之外的字重,会取相近字重对应的数值,而不是随着可变轴一起插值变化。

可变字体 inter 不同字重下的删除线样式

很久很久以前,2016 年华沙,OpenType 1.8 发布,OpenType Variable Fonts 归来,其中一个特性是增加了 MVAR 表,用于微调各种字体参数,以及 Delta [3] 的峰值插值。原本 OS/2, hhea, vhea and post 表中固定数值都可以对字体进行微调,其中就包括删除线位置和尺寸。你可以在下方微软这篇文档中查看详细。

MVAR — Metrics Variations Table (OpenType 1.9) - Typography
Metrics Variations Table (OpenType 1.9)

[3] Delta 可理解为可变字形的矢量锚点所变化的路径,如下图。

OpenType Font Variations Overview 中的 delta 介绍

顺便一提,目前几乎所有 OpenType 相关文章都绕不开 Microsoft Typography 的内容引用,微软虽然界面一直做得不怎么样,文档这方面还是很负责任。

但即便文档已足够详细,恕我愚钝,大多数篇章依然读起来很吃力。如果想简单了解一下 MVAR 这个小 XML 表,不妨继续阅读下去。

MVAR Table

MVAR 继承自苹果  TrueType GX 数据表中的 fmtx 表,属于可选内容,即使不存在也不会影响一款可变字体的正常使用。但如果想要解决上面提出的删除线可变位置和尺寸问题,就必须要用到它。

MVAR 能达成的效果如下面动图所示:

Design Scenes 删除线和下划线样式的可变尺寸演示

上图是我从 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 变量值。
  • VarRegionListVarData共同存储在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 的可视化。

Recursive 1.077 的 A 字形,在 design space (0.8, 0.8) 附近的 Split deltas 样式

点开 delta sets 一栏能看到各种「三角形」。只调节 wght 轴(字重)到 800 (ExtraBold)后,Delta sets 中的 wght 一列开始有红线指示。

Recursive 1.077 delta sets

可以看到第 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 set 对应坐标

这便是上图黑色小三角的由来。每当共同调整不同可变轴时,会根据实际字型微调每个锚点的 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 中亲自体验一下。

调整多个可变轴时的 delta set 值

再看多轴 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 字体参数,详见
  • ……

作为可变字体中一个可有可无的表,属于互联网犄角旮旯半成品的边角料,修复优先级自然不高。字体设计师见此也大都暂且搁置微调,久而久之变得默默无闻。但互联网也从不缺少怜悯之心,20212022 年都有人呼吁停止将 MVAR 拒之门外。随着 foottools 提案和更新 designspace v5,有设计师又将 MVAR 带回到字体中

MVAR 未来依然「生死未卜」,但我很期冀不要让 bug 磨灭了字体设计中的匠人精神之光辉。

附录

以下是一些探索过程中发现的其他文章,以供拓展阅读:

以上文章可通过点击这里添加到你的 Arc 浏览器文件夹,或使用其他浏览器在线查看。

另,图中所用灰色说明字体均为 Recursive,作为开源字体真的花了很多心思。


如果你觉得文章对你有些帮助,可以请我的猫吃罐头 ↓

带有微信赞赏码和文字 A kind and compassionate act is often its own reward. 的横幅图像

订阅 Design Scenes

发布最新文章时,会以邮件通知你
zelda@link.com
订阅