文本布局是松散的分段层次

2020-10-27 22:23:57

我喜欢文本布局,并且已经以这样或那样的形式使用它超过35年了。然而,关于它的知识相当晦涩难懂。我不相信有一个地方都写得很好。我对此有一些解释:虽然基本文本布局对于UI、游戏和其他上下文非常重要,但是许多关于文本布局的“专业”需求都嵌入到更为复杂的系统中,如Microsoft Word或现代Web浏览器。

一本完整的文本布局至少要有一本小书。既然我现在不可能写出这一点,这篇博文就是朝着这个方向迈出的一小步--特别是试图用“松散层次”的概念框架来描述“大图景”。基本上,文本布局引擎将输入分解成越来越细的颗粒,然后将结果重新组合成适合绘制、测量和命中测试的文本布局对象。

主层次结构涉及将整个段落布局为单行文本。换行符也很重要,但有一个独立的、平行的层次结构。

层次结构是:段落分割是最粗的粒度,紧随其后的是富文本样式和BiDi分析,然后是分项(按字体覆盖),然后是Unicode脚本,并将聚类整形为最细的。

最粗略也是最简单的分割任务是段落分割。大多数情况下,段落只是用换行符(U+000A)分隔,尽管Unicode在其无限智慧中指定了许多在纯文本中用作段落分隔符的代码点序列:

在富文本中,段落通常通过标记而不是特殊字符来指示,例如HTML中的<;p>;或<;br>;。但在这篇文章中,就像在大多数文本布局API中一样,我们将把富文本视为纯文本+属性范围。

富文本段落可能包含可能影响格式设置的跨度。具体地说,字体、字体粗细、斜体或无斜体以及许多其他属性的选择会影响文本布局。因此,每个段落通常被分成一定数量的样式运行,以便在运行中样式是一致的。

请注意,某些样式更改不一定会影响文本布局。颜色就是一个典型的例子。众所周知,Firefox在这里没有为颜色更改定义分段边界。如果颜色边界切割了一个连字,它就会使用奇特的图形技术将部分连字渲染成不同的颜色。但这是一种微妙的改进,我认为对于基本的文本渲染不是必需的。有关更多详细信息,请参见文本呈现讨厌您。

段落与样式跨度完全分开,通常可以包含从左到右和从右到左的文本。对双向(BiDi)文本的需要肯定是使文本布局更加复杂的原因之一。

幸运的是,堆栈的这一部分是由标准(UAX#9)定义的,并且有许多好的实现。感兴趣的读者请参阅Unicode双向算法基础。这里的关键要点是,BiDi分析是在整个段落的纯文本上完成的,结果是一系列级别运行,其中每个运行的级别定义它是LTR还是RTL。

然后合并标高管路和样式管路,以便在后续阶段中每个管路具有一致的样式和方向性。因此,出于定义层次的目的,BiDi分析的结果可替换地被认为是隐式的或导出的富文本跨度。

除了BiDi(我认为这是一项基本要求)之外,更复杂的文本布局引擎还将能够处理垂直书写模式,包括短串在垂直主方向内水平的混合情况。极其复杂的布局引擎还将能够处理拼音文本和使用插入字符串注释主要文本流的其他方式。有关复杂布局要求的许多示例,请参阅日语文本布局要求;这篇博客文章的范围实际上是用户界面中所需的那种基本文本布局。

分项是层次结构中最棘手、指定最少的部分。它没有标准,也没有通用的实现。相反,每个文本布局引擎都以自己的特殊方式处理它。

本质上,分项的结果是从字体集合中为运行选择一种具体的字体。通常,字体集合由主字体(通过字体名称从系统字体中选择,或作为自定义资产加载)组成,并有后备堆栈(通常是系统字体),但多亏了Noto,如果您不介意为资产花费几百兆字节,则可以将后备字体堆栈与应用程序捆绑在一起。

首先,确定字体是否可以呈现特定的文本字符串并非易事。原因之一是Unicode标准化。例如,字符串“é”可以编码为U+00E9(NFC编码)或U+0065U+0301(NFD编码)。由于Unicode等价的原则,它们应该以相同的方式呈现,但是字体在其字符到字形索引映射(Cmap)表中可能只覆盖其中的一个或另一个。整形引擎拥有处理这些情况的所有Unicode逻辑。

当然,具有拉丁文覆盖的现实字体将在Cmap表中包含这两个特定的序列,但边缘情况肯定会发生,无论是在扩展的拉丁文范围中,还是在其他脚本中,如具有复杂规范化规则的Hangul(部分归功于与Unicode有些不一致的韩国标准化标准)。值得注意的是,DirectWrite对韩文规范化的理解是完全错误的。

我相信阿拉伯语的演示形式也存在类似的情况;有关这方面的更多详细信息,请参阅开发阿拉伯语字体。

由于这些棘手的规范化和表示问题,确定字体是否可以呈现字符串的最可靠的方法是尝试。这就是LibreOffice一段时间以来的工作方式,2015年Chromium紧随其后。有关Chromium文本布局更改的更多背景信息,请参阅消除简单文本。

另一个复杂的类别是表情符号。许多表情符号可以用文本或表情符号呈现,没有硬性的规则来选择其中之一。通常,文本呈现为符号字体,而表情符号呈现为单独的颜色字体。一个特别棘手的例子是微笑表情符号,它的编码生命始于代码页437中的0x01,这是最初IBM PC的标准8位字符编码,现在是Unicode中的U+263a。但是,建议的默认演示文稿是文本,这在需要颜色的世界中是行不通的。IOS上的苹果单方面选择了表情符号演示,因此许多文字堆栈都跟随苹果的脚步。(顺便说一句,对这样的表情进行编码的最健壮的方法是附加一个变体选择器来固定演示文稿。)。

在尝试编写跨平台文本布局引擎时,另一个复杂性来源是查询系统字体。有关这方面的更多信息,请参见字体后备深度潜水。

我应该注意一件事,这可能会帮助人们对遗留文本栈进行考古:过去,文本布局解决诸如NFKC和NFKD之类的“兼容性”形式是很常见的,这可能会导致各种问题。但是今天,通过提供具有大量Unicode覆盖范围(包括相关兼容范围内的所有代码点)的字体堆栈来解决该特定问题更为常见。

文本的整形或将代码点序列转换为定位字形序列取决于脚本。有些文字(如阿拉伯语和梵文)具有极其精细的整形规则,而另一些文字(如中文)则是从代码点到字形的相当简单的映射。拉丁语介于两者之间,从简单的映射开始,但连字和紧排也是高质量文本布局所必需的。

确定脚本运行相当简单-许多字符都有一个Unicode脚本属性,该属性唯一地标识它们属于哪个脚本。但是,有些字符(如空格)是“通用的”,因此分配的脚本只是继续前一次运行。

一个简单的例子是“Helloмир”。此字符串分为两个脚本运行:“hello”表示Latn,“мир”表示cyrl。

在这一点上,我们有了一系列恒定的样式、字体、方向和脚本。它已经准备好整形了。整形是将字符串(Unicode代码点序列)转换为定位字形的复杂过程。就这篇博客文章而言,我们通常可以将其视为一个黑匣子。幸运的是,以HarfBuzz的形式存在一个非常高质量的开源实现。

不过,我们还没有完全完成分段,因为Shaping会将输入中的子字符串分配给字形簇。通信在很大程度上取决于字体。在拉丁语中,字符串“fi”通常被塑造成单一的字形(连字)。对于梵文这样的复杂脚本,簇通常是源文本中的一个音节,并且在簇中可能会发生复杂的重新排序。

群集对于命中测试或确定文本布局中的物理光标位置与文本中的偏移之间的对应关系非常重要。通常,如果只呈现文本,而不会编辑(或选择)文本,则可以忽略它们。

注意,这些成形群集与字素群集不同。“fi”示例有两个字素簇,但只有一个整形簇,因此一个字素簇边界可以切割一个整形簇。由于可以在“f”和“i”之间移动光标,因此在这种情况下确定光标位置是一个棘手的问题。字体确实有插入符号表,但实现情况参差不齐。更健壮的解决方案是将簇的宽度平均分配给簇内的每个字素簇。另请参见停止将含义赋予代码点,以详细了解字素簇。

虽然短字符串可以被认为是一个单独的条带,但较长的字符串需要断成行。做好这件事是一个相当棘手的问题。在这篇文章中,我们将其视为一个独立的(小)层次结构,与上面的主要文本布局层次结构平行。

该问题可以考虑到识别换行符候选者,然后选择这些候选者的子集作为满足布局约束的换行符。主要限制是线条应适合指定的最大宽度。使用贪婪算法是很常见的,但是高端排版往往使用一种最小化段落粗糙分数的算法。Knuth和Plass有一篇著名的论文,“将段落拆分成行”,详细描述了TeX中使用的算法。但我们将集中讨论确定候选人和测量宽度的问题,因为这些问题已经够棘手的了。

理论上,Unicode换行符算法(UAX#14)识别字符串中作为候选换行符的位置。在实践中,还有一些额外的微妙之处。首先,一些语言(泰语是最常见的)不使用空格来分隔单词,因此需要某种自然语言处理(基于字典)来识别单词边界。其次,自动连字通常是可取的,因为它可以更有效地填充各行,并使右边缘不那么粗糙。梁的算法是最常用的自动推断“软连字符”的算法,并且有很多很好的实现。

Android的换行实现(在Minikin库中)应用了额外的改进:由于电子邮件地址和URL在移动设备上显示的字符串中很常见,而且UAX#14规则为这些提供了糟糕的选择,因此它有一个额外的解析器来检测这些情况并应用不同的规则。

最后,如果单词非常长或最大宽度非常窄,则单词有可能超过该宽度。在某些情况下,行可能会“超满”,但更常见的情况是在仍然适合行内的最后一个字素簇边界处断开单词。在Android中,这些被称为“绝望的休息”。

因此,简单地说,在段落分割(也称为“硬中断”)之后,有一个由3个换行候选者组成的松散层次结构:由UAX#14(可能的“剪裁”)确定的分词、软连字符,最后是字素簇边界。第一个是优选的,但是为了满足布局约束,可以使用另外两个。

这就留下了另一个问题,这个问题要完全正确是非常棘手的:如何测量两个候选中断之间的线的宽度,以便验证它是否符合最大宽度(或者,在更一般的情况下,帮助计算全局粗糙度分数)。对于普通字体的拉丁文文本,这似乎非常简单:只需测量每个单词的宽度,然后将它们相加即可。但在一般情况下,事情远没有这么简单。

首先,虽然在拉丁语中,大多数换行符候选字符都在空格字符处,但在完全通用的情况下,他们可以在文本布局层次结构中的任何位置进行剪切,甚至可以在簇的中间进行剪切。另一个复杂之处在于,连字符可以添加连字符。

即使没有连字符,因为整形是图灵完成的,所以线的宽度(两个换行符候选之间的子字符串)可以是任何函数。当然,这样的极端情况很少见;最常见的情况是宽度正好等于单词的宽度之和,即使在其他情况下,这也往往是一个很好的近似值。

因此,在一般情况下准确做到这一点在概念上并不困难,但效率低得可怕:对于行尾的每个候选对象,从行的开头对子字符串执行文本布局(主要是整形)(可能插入连字符),并测量布局的宽度。

很少有文本布局引擎甚至尝试处理这种一般情况,使用各种启发式和近似法,这些试探法和近似法在大多数情况下都工作得很好,但当提供具有积极改变宽度的成形规则的字体时,它们就失效了。然而,DirectWrite确实使用了非常聪明的技术,这些技术花了几年的迭代时间。完整的故事在harfbuzz/harfbuzz#1463(评论)。对于在开源文本布局引擎中实现这一目标的进一步分析,请参阅yeslogic/allsorts#29。如果HarfBuzz或Allsorts实现了低级逻辑,我可能会想写另一篇博客文章,更详细地解释高级文本布局引擎如何利用它。

换行可能出错的一个很好的例子是Firefox bug 479829,在该错误中,文本中的“f+软连字符+f”序列被塑造为“ff”连字,然后在软连字符处断行。因为Firefox重用了现有的形状,而不是重塑线条,所以它实际上使用跨行分割的连字字形进行渲染:

虽然我仍然觉得需要一个可靠的、高级别的、跨平台的文本布局引擎,但还有很好的实现需要研究。在开放源码中,我最喜欢的(虽然我有偏见)之一是Android文本栈,它基于Minikin,因为它的级别较低。它相当有能力和效率,并且齐心协力把“所有的Unicode”都做好,包括表情符号。它也相当简单,并且代码是可访问的。

虽然DirectWrite不是开源的,但它也很值得研究,因为它无疑是最强大的引擎之一,支持Word和Edge在被Chromium抛弃之前的上一次迭代。请注意,有一项关于跨平台实现的建议,也有可能将其开源。如果这真的发生了,它将在某种程度上改变游戏规则。

Chrome和Firefox也是一个丰富的来源,特别是它们推动了HarfBuzz的很多改进。但是,它们的文本布局堆栈相当复杂,并且与应用程序的其余部分没有一个干净的、有文档记录的API边界,因此它们不像我在这里选择的其他应用程序那样适合研究。

段落和样式分段(使用BiDi)在较高级别上完成,在Layout.java和StaticLayout.java中。在这一点上,运行被交给Minikin进行较低级别的处理。层次结构的其余大部分位于layout.cpp中,最终由HarfBuzz完成整形。

Android通过使用启发式算法进一步将文本分割成隐含的单词边界(这些边界也用作布局缓存的颗粒)来处理边界形状。如果字体跨越这些边界进行整形,则整形上下文就会丢失。这是一个合理的折衷方案,特别是在移动设备中,因为结果总是一致的,即测量的宽度永远不会与布局的宽度不匹配。而且系统堆栈中的字体都没有异国情调,比如跨空格整形。

Android确实以Cmap覆盖为基础进行条目划分,并构建了复杂的位图结构以实现快速查询。因此,它可能会在正常化问题上出错,但总体而言,这似乎是一个合理的妥协。特别是,大多数时候您会遇到规范化问题是拉丁语和组合变音符号,这两者都是由Roboto提供的,而Roboto又有大量的Unicode覆盖(因此不太需要依赖规范化逻辑)。但是使用自定义字体时,处理可能不太理想,导致Roboto出现比实际需要更多的后备。

请注意,Minikin也是libTxt的起点,libTxt是Ffltter中使用的文本布局库。

一些关于我在研究API时发现的东西的笔记;这些观察结果不是很清楚,但对于想要深入了解或使用API的人来说可能会很有用。

DirectWrite中的命中测试基于前导/尾随位置,而Android中的命中测试基于主要和次要位置。后者对于文本编辑更有用,但是前导/尾随是一个定义更明确的概念(首先,它不依赖于段落方向)。有关此主题的详细信息,请参阅折线机/Piet#323。我的观点是,正确的命中测试需要遍历文本布局以访问较低级别的结构。

Core Text(见下文)公开对象的层次结构,而DirectWrite使用TextLayout作为主要接口,并通过在命名混乱的Draw方法中每次运行的回调迭代来公开内部结构(甚至包括行)。此回调的粒度为字形运行,对应于上面层次结构中的“script”。簇信息在相关联的字形运行描述结构中提供。

还有其他方法可以访问低级文本布局功能,包括TextAnalyzer,它计算BiDi和换行符机会、脚本运行和整形。事实上,该接口上的各种方法代表了文本布局引擎的大部分内部结构。但是,分项是在稍后添加的FontFallback接口中完成的。

另一个高质量的实现是核心文本。我个人认为它没有DirectWrite设计得那么好,但它确实能完成工作。不过,一般而言,Core Text被认为是较低级别的接口,建议应用程序使用较高级别的机制(MacOS上的Cocoa Text,iOS上的Text Kit)。

在MacOS上进行文本布局时,最好使用平台提供的分项方法(CTFontCreateForString),而不是在客户端获取字体列表并进行分项。有关此权衡的更多信息,请参见Linebender/Skribo#14。

此时,Druid GUI工具包没有自己的原生文本布局引擎,而是提供了跨平台API,该API被委托给平台文本布局引擎,特别是DirectWrite和Core Text。

Linux上的情况目前并不令人满意,因为它基于开罗玩具文本API。目前正在进行改进这方面的工作,但没有任何进展。

.