文本渲染讨厌你

Alexis Beingessner 原作,授权 New Frontend 翻译。

文本渲染有多难?要多难有多难,难到没有一个系统能够「完美」渲染文本。文本渲染都是尽力而为,不过在有些地方花力气更重要。

我这里假定你想要支持渲染用户提供的任意文本,包括定制字体、色彩、样式、换行,也包括文本选取。你是想要正确渲染一份简单的富文本文档也好,显示字符界面也好,显示网页也好,不管要显示什么,这些都是最最基本的需求。

本文整体上的调子是:没有标准答案,所有事项都会比你预想的要关键,所有事项都会影响其他事项。

本文选择的主题没有特别的标准和理由,都是来自我多年从事 Firefox 渲染方面的开发后积累的一些经验。比方说,我没有花多少篇幅讨论文本分割面临的挑战,或者如何处理各种平台相关的库,因为我个人不是很关注这些方面。

1 术语

文本很复杂,英语很难表述一些细微的差别。根据本文讨论的主题,我将努力使用以下这套术语。注意这样的用法未必「正确」,我只是觉得这样有助于向以英语为母语,但不具备语言学背景的人传达关键的概念。

字符相关:

  • 标量(scalar):Unicode 标量是 Unicode 描述的「最小单位」,也称为码位(code point)。
  • 字符(character):Unicode 扩展字位丛(Unicode Extended Grapheme Cluster,EGC),Unicode 描述的「最大单位」(可能由多个标量组成)。
  • 合字(ligature):由多个标量组成的字形(glyph),甚至可能是多个字符(母语人士可能认为一个合字由多个字符组成,也可能不这么认为,但是在字体层面这就是一个「字符」)。
  • Emoji(表情符号):「全彩的」字形。🙈🙉🙊

字体相关:

  • 字体(font):一个将字符映射到字形的文件。
  • 文字(script):组成某种语言的字形集合(字体一般实现某种特定的文字)。
  • 连写文字(cursive script):字形相牵连的文字,比如阿拉伯文。
  • 色彩(color):字体的 RGB 和 Alpha 值(一些使用场景下 Alpha 值用不到,但另一些使用场景下会很有意思)。
  • 样式(style):粗体、斜体(就具体实现而言,往往还涉及微调(hinting)、锯齿(aliasing)等其他设置)。

2 样式、布局、造型全都互相依赖?

我先概述下文本渲染的流程,让你有个大概的印象:

  1. 样式计算(styling):解析标记,向系统请求字体。
  2. 布局(layout):分行。
  3. 造型(shaping):计算一行中字形的位置。
  4. 栅格化(rasterization):将所需字形栅格化为贴图(atlas)/缓存(cache)。
  5. 合并渲染(composition):将字形从贴图复制至所需位置。

不幸的是,这些步骤并不像看起来那么清晰。

大部分字体实际上并没有提供现存的所有字形。字形数量太多了,所以字体通常设计为只实现某一特定文字。终端用户通常并不知晓或者在意这些,因为健壮的系统在字符不存在时必定会级联显示其他字体。

例如,尽管标记下面这些文字符号并不一定表明系统上存在多种字体,但能够在所有系统上正确绘制它们无疑需要多种字体:hello 😺 मनीष بسم 好。这几乎就意味着步骤一(样式计算)依赖于步骤三(造型)的结果!

(或者你也可以采取Noto 的做法,使用包含所有字符的字体。虽然这意味着用户无法设置字体,也无法获得所有平台的「原生」文本体验,不过让我们假定你希望能有一个更健壮的字体。)

类似地,布局需要知道文本每部分占据的空间,但这只有在对文本造型之后才能知道!步骤二依赖步骤三的结果?

造型无疑依赖布局和样式计算,所以看起来我们陷入了一个僵局。我们该怎么办?

首先,样式计算需要作弊。尽管我们实际上需要字体提供完整的字形,但样式计算只需请求标量。如果一个字体不能正确地支持一种文字,那么它不应该声明支持属于这种文字的标量。这样我们就很容易通过以下步骤找到「最佳」字体:

就文本中的每个字符(EGC),向级联的每个字体查询是否支持组成这一字符的所有标量,一旦查到肯定结果,就使用这一字体,否则继续向下一级字体查询。如果最终都没有找到支持的字体,那我们就得到一个豆腐块(tofu,􏿽),表明缺少这一字形。

你大概已经在显示表情符号的场景下见过这一过程的失败模式!由于一些表情符号实际上是若干简单表情符号组成的合字,因此一个字体可能成功报告支持这一字符,但实际上只是最终显示了一些组件。所以,如果字体「过旧」,不知道新的合字,🤦🏿‍♀️可能显示为🤦 🏿‍ ♀。如果你的 Unicode 系统「过旧」,不知道这一字符,那么也可能导致样式计算系统接受字体中的部分匹配,也会出现这一现象。

好了,现在我们已经准确了解如何决定使用哪些字体,暂时没有谈到布局和造型(尽管造型可能会改变色彩,详见之后的章节)。那么我们可以将布局、造型合成一步吗?不能!分段之类的情况下,会有明确的断行。但在大部分情况下,处理换行的唯一方法是不断处理造型!

你需要假定文本能够放进一行,然后不断处理造型,直到占据的空间超出一行。此时可以进行布局操作,判断在哪里换行,然后开始处理下一行。重复这一过程,直到所有文本造型和布局完毕。

3 文本并非单个字符

就英语而言,合字也许会被认为只是花哨之物。谁那么顶真,在意「æ」写成「ae」?好吧,事实上,有些语言基本上全是合字。例如,「ड्ड بسم」由「ड् ड ب س م」这些单独字符组成。在靠谱的文本渲染系统下(任何主流浏览器),这两个字符串看起来全然不同。

不:这并不是 unicode 标量和扩展字位丛造成的差别。如果你在 unicode 健壮系统(比如 Swift)下查询这个字符串的扩展字位丛,会返回 5 个字符。

字符的造型依赖于相邻的字符:逐字符地绘制文本无法得到正确的结果

这就意味着,你必须使用造型库。业界标准是 HarfBuzz。自行实现这样的库极为困难,请使用 HarfBuzz。

3.1 文本重叠

连写文字通常会有相交的字形,以避免出现接缝,这可能给你带来麻烦。

让我们再来看看「मनीष منش」。看起来不错,嗯?放大一下:

मनीष منش

看起来还是没问题。我们再让色彩透明一点:

मनीष منش

如果你用的浏览器是 Safari 或 Edge,可能还是没问题!但是如果你用的是 Firefox 或 Chrome,看起来就很糟了,就像这样:

Firefox 和 Chrome 下渲染半透明连写文字,出现接缝

问题在于 Chrome 和 Firefox 试图作弊。它们在造型的时候很负责,但得到字形后仍然逐个绘制字形。在大多数情况下,这么做效果不错,但在透明和重叠的场景下就暴露了。重叠处的色彩加深了。

「正确」的实现应该是先不考虑透明度,在一个临时的平面上绘制文本,然后再合成计入透明度的场景。Firefox 和 Chrome 没有这么做是因为这样开销很大,而且对大多数西方语言来说,这么做通常没有必要。有意思的是,实际上它们理解这个问题,因为在处理表情符号的时候它们转而采用了这种开销较大的做法(我们之后会讨论这个)。

3.2 合字内部的样式可能变化

好吧,讨论这一点主要是出于好奇心的驱使,因为我不知道这有什么特别合情合理的使用场景。不过从标记的角度来说,这一点很自然。下面是两行内容相同但色彩样式不同的文本:

पन्ह पन््र र्ृक ्ड ्ह إلا بسم الله
पन्ह पन्ह त्र र्च कृकृ ड्ड न्हृे إلا بسم الله

在 Safari 下效果是这样的:

Safari 下显示合字内部的样式变化

在 Chrome 下效果是这样的(如果用了它的新布局引擎

Chrome 下显示合字内部的样式变化

最后是 Firefox 下的显示效果:

Firefox 下显示合字内部的样式变化

总结下:

  • Safari 崩了
  • Chrome 处理得清晰分明,但是丢掉了许多色彩
  • Firefox 既分明又多彩

我猜所有人都应该照 Firefox 这么做,对吧?但是如果我们放大一下,会发现实际上它一点也不靠谱:

Firefox 显示合字内部样式放大效果

它不过是把一个合字分成四等分,每个部分显示一种颜色!

问题在于,这里应该如何处理没有合理答案。我们给一个合字指定了不同的样式,由于某种意义上来说合字是一个渲染「单元」,直接拒绝支持这样的做法是很合理的(大部分软件也是这么做的)。

出于某种原因,Firefox 的一个开发者对尝试更加平滑地处理这一问题极具热情。总体思路是使用推测的最佳掩码和不同色彩多次绘制合字,效果好得出乎意料!

尽力支持这些「部分合字」还是有一些意义的:只有造型阶段清楚是否会有合字,因为取决于系统指定的字体,合字可能出人意料地出现!英文中经典的例子是用户安装的字体导致出现 æ 合字,跨越了超链接的界限。

另一方面,英文可以在单词内部改变样式(change style)但是连写文字却不可以,说起来还挺不合理的。

千万别问合字内部加上换行的代码是怎么处理的。

4 表情服务搞乱了颜色和样式

以原生系统的方式绘制表情符号,并不会尊重文本的色彩设置(透明度除外):

Hello ❤️ 😺 🎉 ™️ 🥶 😡 😈 🤟 🤟🏻 🤟🏿 There (Black)

Hello ❤️ 😺 🎉 ™️ 🥶 😡 😈 🤟 🤟🏻 🤟🏿 There (Red)

Hello ❤️ 😺 🎉 ™️ 🥶 😡 😈 🤟 🤟🏻 🤟🏿 There (Transparent)

Hello ❤️ 😺 🎉 ™️ 🥶 😡 😈 🤟 🤟🏻 🤟🏿 There (Bold)

Hello ❤️ 😺 🎉 ™️ 🥶 😡 😈 🤟 🤟🏻 🤟🏿 There (Italics)

表情符号一般有它们自己原生的色彩,具体的颜色甚至可能有语义,比如肤色。更大的问题是,它们可能有多种颜色!

就我所知,在表情符号之前没有出现过类似的不同平台显示方式大不相同的情况。有些平台的表情符号可能直接是图像(苹果),其他平台可能使用一系列单色图层(微软)。

后者某种程度上比较友好,因为它「只是」把一个字形分解成了一系列单色字形,比较容易结合现有的文本渲染流程。一般来说文本渲染流程习惯处理单色的字形。

然而这意味着在绘制「单个」字形时样式会反复改变。这也意味着「单个」字形可以重叠,出现上一节讨论过的透明度问题。然而,如上所示,浏览器确实可以正确处理表情符号的透明度!

你有三种方法说服自己这样的不一致性是合理的:

  1. 既然已经需要检测彩色字形并特殊处理,所以为它们提供一条独立的合并渲染通道比较容易。
  2. 透明度有问题时,连写文字只是看起来稍微有点丑,但表情符号看起来会很吓人/糟糕,所以多做一些工作是有理由的。
  3. 和阿拉伯语和马拉地语相比,以西方为中心的开发者,更关心表情符号。

随你选择。🙃

哦,对了,表情符号的斜体和粗体是要表示什么?应该忽略这些样式吗?应该合成它们吗?谁知道呢。🤷‍♀️

另外为什么这些表情符号看起来异常地小呢?👀

是的,不管出于什么原因,许多系统偷偷地增加了表情符号的字号,让它们看起来更舒服。

5 抗锯齿是地狱

文本很小,细节很丰富,所以清晰可辨非常重要。听起来像是抗锯齿(AA)的活!哎呀,480p 分辨率真低。多来一点抗锯齿!

抗锯齿主要分两类:

  • 灰度抗锯齿(Greyscale Anti-Aliasing)😻
  • 次像素抗锯齿(Subpixel Anti-Aliasing)🙀

字母 e 抗锯齿

灰度抗锯齿是一种「自然」的抗锯齿方法。基本思路是让部分覆盖的像素部分透明。在合并渲染过程中,这会导致相应的像素淡一点,看起来就像是轻微覆盖,制造出看起来更清晰的细节。

名字叫灰度抗锯齿是因为这是用于单通道色彩的术语,就像我们使用了单通道透明度。如果透明度不存在,那么字形都会是实色(solid color)。也因为常见的情形是白底黑字,此时抗锯齿显示效果上等价于边缘的灰度。

次像素抗锯齿是一种滥用桌面显示器排列像素的一般方式的技巧。具体机制比较复杂,感兴趣的话你可以查阅一些资料。不过从高层概念上来说,基本原理如下:

显示器的像素实际上由红、绿、蓝三个通道组成。如果将像素设为红,那么某种程度上你也把它设为了「白 黑 黑」。类似的,如果设为蓝,那么某种程度上也把它设为了「黑 黑 白」。换句话说,通过摆弄色彩,你可以将水平分辨率扩大三倍,得到更多细节!

你也许会认为这也太乱来了,分明是痴心妄想,但是说实话,这种方式在实践中的效果非常好(某种程度上而言)。人脑倾向于寻找模式,平滑物体。话虽如此,如果截屏次像素抗锯齿的文本,你绝对可以看到色彩,只需缩放图像,甚至只是在次像素设置不同的显示器上查看图像。这就是为什么截屏中的文本经常看起来很怪很糟的原因。

附带提一句,这一机制也意味着图标的色彩可能无意中改变视觉上的尺寸和位置,这一点非常讨厌。

所以次像素抗锯齿能够很巧妙地明显提升文本清晰度,棒!不过,很不幸它也有一项致命的缺陷!

不管使用的是什么样的抗锯齿系统,都会产生次像素字形偏移(subpixel glyph offsets)。尽管你总是希望栅格化的字形能够分成完整的像素,但栅格化自身不处理特定的次像素偏移(偏移值介于 0 和 1 之间)。

要理解这一点,想象一个 1x1 的黑方块,使用了灰度抗锯齿:

  • 如果它的次像素偏移为 0,那么栅格化的结果就只是一个黑像素。
  • 如果它的次像素偏移为 0.5,那么栅格化的结果会是两个 50% 的灰像素。

5.1 次像素偏移破坏了字形缓存

栅格化字形出人意料地昂贵,所以你会很希望能缓存为贴图。但是,使用次像素偏移时该如何缓存字形栅格呢?偏移自身形成了栅格,这就很难缓存了。

为了兼顾质量和性能,可以对齐次像素偏移。对英文文本来说,合理的平衡是纵向的次像素偏移直接舍去,横向的次像素偏移对齐至四分之一整数。这意味着只会存在 4 种次像素偏移的位置,这在允许数量合理的缓存的同时,仍然大幅提高了质量。

5.2 次像素抗锯齿无法合并渲染

灰度抗锯齿的一个优点是采用起来比较快速省力,因为它会优雅地降级。例如,如果对带有文本的纹理进行一些变形(缩放、旋转、翻转),也许看起来会有一点模糊,但基本上效果不差。

然而,如果纹理上的文本使用了次像素抗锯齿,那这样的变形会导致非常糟糕的显示效果。次像素抗锯齿的整体思路就是滥用屏幕排列像素的方式。如果屏幕上的像素和纹理的像素没有对齐,那么红色边缘和蓝色边缘会清晰可见!

有人也许认为要「修复」这个问题,只需在新位置上重新栅格化字形。事实上,如果变形是静态的,这个方法会有效果。不过如果变形是动画,这只会让实际显示效果更糟。实际上这是一个真的很常见的浏览器 bug:一旦我们没能正确检测到涉及文本的动画,相应的字符会抖动,这时由于每一帧中的每个字形会在不同的次像素对齐和微调间频繁变动。

因此,浏览器包含了一些启发式算法来检测这些可能是动画的东西,为页面的相应部分强制禁用次像素抗锯齿(理想情况下甚至会禁用次像素布局)。要可靠地做到这一点相当困难,因为任意复杂的 JS 可能会触发动画,而不会明确地通知浏览器。

此外,如果涉及到部分透明,那么次像素抗锯齿也会有问题。我们基本上是在微调 RGB 通道以编码 3 个透明度(每个次像素一个),但文本自身也有颜色,文本的背景也有颜色,所以很容易丢失信息。

使用灰度抗锯齿时我们有一个专门的 alpha 通道,所以不会丢失任何东西。因此,涉及到透明度时,浏览器通常使用灰度抗锯齿。

……Firefox 除外。是的,又是 Firefox 的一个开发者在一个奇怪的地方特别有热情,做了一些复杂的处理:Component Alpha. 事实上你可以恰当地合并渲染次像素抗锯齿文本,但它涉及分配 3 个额外的通道用于 R、G、B 的透明度。毫不意外,这样合并渲染的文本会消耗两倍的内存。

好在次像素抗锯齿近年来不那么重要了:

  • 视网膜屏幕不需要它。
  • 手机的次像素排列妨碍了这一技巧的效果(除了花很大精力适配)。
  • 新版的 macos 在操作系统层面默认禁用次像素抗锯齿。
  • Chrome 看上去会更加激进地禁用次像素抗锯齿(不太确定具体的策略)。
  • Firefox 的新图形后端(webrender)基于简单性考虑放弃了 Component Alpha.

6 奥秘书

这里列出了一些不值得大书特书的东西。

6.1 字体可能包含 SVG

晴天霹雳!这样的字体大多来自 Adobe,曾几何时,它们就是这么热衷 SVG。有时候你可以直接忽略 SVG 部分(我相信技术上说 Source Code Pro 包含一些 SVG 字形,但实践中网站并没有使用它们),但一般来说你需要实现 SVG 支持以绘制所有字体。

你听说过动画 SVG 字体吗?没听说过?很好。我认为现在这种字体哪里都不可用/未实现。(Firefox 过去有一段时间支持过,因为曾经有一个充满激情的开发者。)

6.2 字符可能超级超级大

如果用户请求非常大的字体(或者非常大的缩放倍数),而你很天真地尊重这样的请求,那你会面临极其严峻的内存管理问题,因为每个字符可能比整块屏幕还大,巨大尺寸的字形贴图会占用大量内存。有一些应对方法:

  • 拒绝绘制字形(可怜的用户)
  • 在更小的尺寸上栅格化字形,然后合并渲染时放大(容易实现,会产生模糊的边缘)
  • 直接在合并渲染平面栅格化字形(不易实现,可能开销较高)

6.3 选取文本不是一个框,文本可能有任意方向

文本的主要方向有从左往右(英语)、从右往左(阿拉伯文)、从上往下(日语),这是大家比较普遍的认知。

好,这里有一些有趣的文本:

Hello There إلا بسم الله Beep Boop!!

在桌面系统上,如果拖拽鼠标选中文本,你可能会注意到选择范围变得不连续,在中间跳跃了一下。这是因为我们在同一行中混合了左行文字和右行文字,这种情况总会发生。

刚开始往右拖拽会增加选择范围,接着会减少选择范围,直到突然之间又开始增加。事实上这完全正确也可取:在实际的底层字符串中选择范围保持连续。通过这种方式,你可以正确地复制横跨方向改变的部分文本。

所以你需要在和选择相关代码的选中检测部分处理这一问题。你的换行算法也需要处理这一问题。

不过你知道这个问题并非到此为止吗?

oh hey what? oh لا بسم الله no 你好1234你好

希望你不需要处理这样的问题。

6.4 写不了的该怎么写?

字体缺少字符时,能够传达给用户发生了什么会比较友好。这是「豆腐块」字形。你可以直接绘制一个空白的豆腐块(矩形),但是如果你想要做得更友好的话,可以显示缺失字符的值,这样更容易找出问题所在。

不过等一等,我们使用文本解释我们无法绘制文本?嗯,是这样。

你可能诉诸这样一个假设,系统必须具备一个能够绘制 0-9 和 A-F 的基本字体,但是为了应付那些决意使用自己的工具毁掉自己的工具的人,你可以像 Firefox 一样使用微字体(microfont)!

Firefox 内部有一个硬编码的小数组,描述了上述 16 个字符的微型字体贴图的点阵图。这样绘制豆腐块的时候就可以这些字形,不用担心字体。

􏿽 􏿽 􏿽 􏿽 􏿽 􏿽 􏿽 􏿽 􏿽

6.5 样式是字体的一部分(除了不是如此的时候)

高质量的字体会提供原生的斜体粗体之类的样式,这是因为没有一种简单的算法可以很好地实现这些效果。

但有些字体不会提供这些样式,所以需要有一个简单的算法实现这些效果。

具体如何检测和处理这种情况相当程度上是平台相关的,也非常复杂,不是我的专长,所以我事实上不能很好地解释这些。我只能建议你研究下webrender 的字体相关代码

不管你打算怎么处理,你都需要一套合成回退方案。谢天谢地,实际上,这一方案的实现相当直接:

合成斜体:在每个字形上应用倾斜变换。

合成粗体:按照文本的方向,多次绘制每个字形,每次加上一个微小的偏移量。

实话说,这些方法可以产生相当不错的效果!但是用户也许会注意到一些看起来「不对劲」的地方,如果你多花精力,那么可以做得更好。

6.6 不存在理想的文本渲染

平台相关的 bug、优化、怪癖长时间存在,以至发展成了美学。所以即便你坚信具体某件事情是理想的,或是重要的,总有一大堆用户持有与此不同的偏好。健壮的文本渲染系统会在选择合理的预设的同时支持不同的偏好。

你应该支持系统配置、字体相关配置、应用相关配置、文本运行(text-run)相关配置。你也应该尝试匹配每个平台的原生「感觉」(怪癖)。

包括:

  • 能够禁用次像素抗锯齿(有些人真的很讨厌它)。
  • 能够禁用所有抗锯齿(是的,有人这么做)。
  • 大量平台/格式相关的属性,像微调、平滑(smoothing)、变形(variation)、gamma 等。

这也意味着你应该使用系统的原生文本库以匹配系统的美学(Core Text、DirectWrite、FreeType)。

7 更多内容

更多关于文本渲染是噩梦的文章:

题图:Raphael Schaller

最新文章

评论

正在加载评论