引言
前面我们讲解了字符与编码,知道了Character与Unicode的关系和区别,也介绍了字符(Character)、字形(Glyph)、字体的区别,并通过实际解析一个Font文件,真正了解到了Font文件中有什么;如果你对这些概念还熟悉,推荐先阅读前面几篇文章打好基础。
作为程序员,日常和文本打交道肯定最多,不知道你是否深入想过这样一个问题:
一段中英日等多国混排的文字,系统(排版引擎)是如何知道怎么排布每个文字的,特别是不同国家的语言排版规则不同,比如中文、英文是从左向右排列,阿拉伯文是从右向左排列的;阿拉伯文会有连字(ligature),中文没有连字;更细节的,为了增强文本的可读性和美观性,系统一般还会将文字紧凑处理(kerning)、连字处理(ligature)等,排版引擎在其中到底做了哪些事情,每一步的基本原理又是怎么样的,本文将带你逐步揭开排版引擎的神秘面纱。
通过本文,或许你也能自定义一个文字排版引擎了。
一、文本预处理/Unicode归一化
在字符与编码一文中,我们知道同一个字符可能对应多个code point,比如👨👩👧
对应U+1F468 + U+200D + U+1F469 + U+200D + U+1F467
;甚至Unicode为了兼容历史编码,也允许一个字符有多种表示方法,比如é
可以表示为:单一code point(U+00E9),组合code point(U+0065 + U+0301
)。
预处理就是保证字符串在进行排版/字形选择(shaping)之前是稳定、唯一、可预测的,避免因为 Unicode 的多种表示方法导致排版不一致,比如避免é
被分开成e
和 ́
排版渲染。
预处理一般步骤是:
- 编码转换:将字符统一成UTF-32编码
- 规范化(Normalization):NFC/NFD/NFKC/NFKD等,Web 标准和绝大多数现代系统都默认使用NFC
Q:NFC/NFD/NFKC/NFKD是什么
这些是Unicode标准里定义的几种规范化形式,区别是:
二、分段
为什么要分段:不同国家、语言的排版规则不同,比如阿拉伯文有连字、中文没有,阿拉伯文从右到左排,中文从左往右排,分段之后方便后续的字体选择和shaping,比如HarfBuzz 这样的 shaping 引擎一次只能处理一个 Script run
分段就是把字符串按 Unicode Script (Latin, Han, Hiragana, Katakana 等) 划分成 run(分组)。
原理比较简单,Unicode 为每个 code point 定义了一个 Script 属性,遍历字符串,按 Script 属性连续分段即可。
比如对于Hello世界あい
,从左往右扫描字符串,每遇到 Script 改变,就切分出一个 run,最后会被划分成:
Hello
" → Latin世界
→ Hanあい
→ Hiragana
特殊情况:
有些字符的 Script = Common(标点、数字、空格)或 Inherited(音调符号、声调标记),这些字符分段时需要特殊处理,规则一般是:
- 如果是 Common → 继承相邻 run 的 Script(如果左右run都有Script,一般跟随左边;如果左边没有run,比如开头就是空格,那就跟随右边;如果左右都没有run,比如
!!!
,那整体就是一个Common run)。 - 如果是 Inherited → 附着到前一个 base 字符的 Script。
比如:
世界!
→ "世界" (Han) + "!" (也归 Han run)
é
(e + 重音符) → 整体算 Latin
三、双向文本处理(BiDi)
BiDi就是将字符从逻辑顺序 处理成视觉顺序,计算机里字符串总是按逻辑顺序存储(用户输入顺序),但在渲染时,不同语言有不同的书写方向,比如中文、英文从左往右排列,阿拉伯文、希伯来文从右往左排列,如果一段文本中既有中文、英文,又有阿拉伯文、希伯来文,那还得处理混排时的顺序,BiDi就是处理混排情况下文本的实际显示顺序的。
在后续的例子中,为了方便演示,我们假设以小写字母作为LTR,以大写字母作为RTL,比如:
scss
abc ABC
abc:表示LTR(从左往右排)书写方向
ABC:表示RTL(从右往左排)书写方向
Unicode有一套完整的BiDi算法(细节可参考链接),在介绍原理之前需要先了解几个基本概念:
1)字符类型(Character Types)
Unicode给每个code point定义了一个Bidi_Class的属性(Unicode的方向属性):
-
L
= Left-to-Right(中文、英文...) -
R
= Right-to-Left(希伯来文) -
AL
= Arabic Letter(阿拉伯文) -
EN
= European Number(欧洲数字) -
AN
= Arabic Number(阿拉伯数字) -
ON
= Other Neutral(标点符号) -
...
这些方向属性会有一个隐含的分类:
- 强类型:这类字符具有明确的方向性,如英文字母是从左往右(LTR),阿拉伯文是从右往左(RLT)
- 弱类型 :这类字符方向性不明确,比如数字和一些符号(如出现在数字之间的符号
.
、,
等),比如阿拉伯数字123
, - 中性类型 :这类字符完全没有方向性,如空格、标点符号(
.
、,
、?
等),它们的方向完全由周围的强类型字符决定
比如:
scss
// 如下为计算机中存储的逻辑顺序
abc ABC
a(L) b(L) c(L) space(WS) A(R) B(R) C(R)
2)段落基本方向(Base Direction)
在没有明确指定段落方向时,会采用默认规则来确定段落基本方向,即选择段落中第一个强类型字符的方向作为段落基本方向,段落开头的弱/中性字符会被忽略,直到遇到第一个强类型字符;如果整段都没有强类型字符,则默认LTR。
比如:
scss
// 如下为计算机中存储的逻辑顺序
// case-1
abc ABC
a(L) b(L) c(L) space(WS) A(R) A(R) C(R)
段落方向为LTR
// case-2
"123abc"
1(EN)2(EN)3(EN)a(L) b(L) c(L)
段落方向为LTR
// case-3
"123"
1(EN)2(EN)3(EN)
段落方向为默认LTR
段落基本方向主要有三个作用:
- 确定初始的嵌套等级(见下),如果基本方向为LTR,则嵌套等级从0开始;如果基本方向为RTL,则嵌套等级从1开始
- 决定中性字符的方向,如果中性字符左右都没有强类型字符,那就会跟随段落方向,比如
Hello !
中!
会跟随段落方向LTR - 决定段落中文档流方向,如果基本方向为LTR,文本将从容器左侧开始向右排;如果基本方向为RTL,文本将从容器右侧开始向左排
3)嵌套等级(Embedding Levels)
BiDi算法中用偶数等级(0, 2, 4...)代表LTR方向,奇数等级(1, 3, 5...)代表RTL方向;如前所述,段落基本方向决定了初始的嵌套等级(0级为LTR,1级为RTL),当文本中出现方向变化时,算法会相应地提升嵌套等级。
从段落初始等级开始,当遇到方向变化时,就提升一个等级;对于强类型字符等级比较容易确定,对于弱类型与中性类型字符则需要结合上下文来共同确定。
对于弱类型字符:比如AN/EN数字及其之间的标点符号,这些字符即使在 RTL 文本中也通常按 LTR 书写。
对于中性字符:假设中性字符c
左右字符(逻辑顺序)分别为b
、d
,
-
如果b、d都为强类型字符,且
direction(b) = direction(d) = D
,则direction(c) = D
-
如果b是强类型字符,且
direction(b) = RTL
,且d是AN或EN,则direction(c) = RTL
-
如果b是AN或EN,且d是强类型字符,且
direction(d) = RTL
,则direction(c) = RTL
-
如果b是AN或EN,且d是AN或EN,则
direction(c) = RTL
-
否则
direction(c) = direction(EL(c))
(即其嵌套级别的方向,如果没有控制符明确限制则为段落基本方向)
比如:
scss
// 段落基本方向是LTR,初始等级0
逻辑顺序:car means CAR.
嵌套等级:00000000001110
解释:CAR为RTL,方向变化所以从0提升到1;中性字符.在段落首尾时遵循
// 段落基本方向是RTL,初始等级1
逻辑顺序:CAR means car.
嵌套等级:11112222222221
解释:第一个空格左右分别时RTL和LTR,会遵循段落方向;第二个空格左右都是LTR,被提升为LTR
Q:为什么需要2、3、4等更高等级的嵌套呢?
- Unicode中有一些嵌套控制符,可以显示提升嵌套等级,比如LRE(U+202A),RLE(U+202B)等

- BiDi算法会从最高嵌套等级逐级反转字符,如果没有多级嵌套,遇到复杂结构时(比如 RTL 内嵌 LTR,再内嵌数字),就无法只反转某一层而保持其他层次稳定
理解上面概念后,我们来简述BiDi算法的基本过程:
1)分段并确定段落基本方向
BiDi算法是针对段落生效的,拿到一篇文档后,需要先将文档拆分成段落,并为段落确定基本方向。
2)为每个字符分配嵌套等级
3)在奇数层做镜像字符替换
在奇数级别(即 RTL 层级)中,对称字符(如括号、尖括号、引号等)要"镜像"替换。
例如在 RTL 层中,一个 "(" 应该显示为 ")",一个 ")" 应该显示为 "("。
4)阿拉伯连字处理
用一个新字符替换相邻的阿拉伯字符,并确定每个阿拉伯字符的位置和形状。
5)按嵌入级别反转子串以生成视觉顺序
对每行(line)分别处理(因为段落可能跨多行),假设最高嵌套等级为EL_h
,最低奇数级别 为EL_l
,从EL_h递减到EL_l,在每一级别就地反转子串
递归处理完后,由高层级到低层级反转嵌套子串,就能得到最终每行的视觉顺序。
详细逻辑可以参考:cs.uwaterloo.ca/~dberry/ATE...
下面以几个例子说明:
scss
// case-1
逻辑顺序:car means CAR.
段落等级:0(第一个强字符为LTR,所以段落等级为0)
嵌套等级:00000000001110
反转level 1: car means RAC.
// case-2
逻辑顺序:[RLE]car MEANS CAR.[PDF]
段落等级:1(RLT开启一个新的嵌套等级,嵌套等级提升到1,段落等级为1;PDF为表示嵌套终止)
嵌套等级: 22211111111111
反转level 2:rac MEANS CAR.
反转level 1~2:.RAC SNAEM car
// case-3
逻辑顺序:he said "[RLE]car MEANS CAR[PDF]."
段落等级:0
嵌套等级:000000000 2221111111111 00
反转level 2:he said "rac MEANS CAR."
反转level 1~2:he said "RAC SNAEM car."
四、字体匹配与Fallback
字体匹配与Fallback是一个复杂的过程,我们后续的塑形与测量都依赖字体文件。
由于任何一个字体都不可能覆盖 Unicode 的所有字符,比如:Times New Roman 渲染拉丁字母没问题,但遇到中文 "你" 就会变成"豆腐块"(小方块:是操作系统在没找到合适字体来显示字符时,会兜底到占位符,比如☐或�等);所以排版系统实际要做的就是:确保每个字符都有合适的字体来渲染,同时尽量保持风格一致。
每个字符都有对应的code point,在Font文件中有什么一文中,我们知道了字体文件中有各种各样的表,其中cmap表存储了code point与glyphID的映射,通过cmap表我们可以精确的查到该Font是否支持某个code point,但是仅通过cmap查询是不够的,主要有两个原因:
- 操作系统一般安装了成百上千种字体,如果对每个code point都去遍历所有Font的cmap表,那开销会非常大
- 不同Font支持的Unicode范围是有交集的,一个code point可能匹配出多个Font,为了渲染风格的统一,我们期望相同Script的字符尽量用同一种Font
Q:相同Script的字符如果使用了不同的Font,会有什么问题
Unicode 为了节省码点空间,将许多中、日、韩来源相同但字形有细微差异的汉字合并到了同一个码点上,也就是所谓的中日韩统一表意文字(CJK Unified Ideographs);如下,同一个code point在不同语言下样式不同,如果不处理Script,那可能会在一个日文段落里显示出中文的"房"字形,这在专业排版上是不可接受的。另外,不同的Font格设计风格(字宽、基线、形态)也不同,如果一个段落里穿插不同的Font,那最终排版看起来也会很奇怪。

现代操作系统做字体匹配与Fallback的方式一般是:
1)通过前面的分段,将一段字符串按Script分成不同的run
2)检查用户指定的主字体是否支持
检查用户指定字体(主字体)的cmap是否支持对应字符(code point),如果支持则命中主字体,如果不支持则进入Fallback流程。
3)Fallback时按OS_2表中的ulUnicodeRange掩码初步筛选支持的Unicode范围
注意OS_2表只是一个大概范围,并不代表完全支持该范围的Unicode,如果要精确查询是否支持还是要查cmap表。
4)通过GSUB
/GPOS
表精确查找支持哪些Script
GSUB
/GPOS
中定义了ScriptList
,明确声明字体为哪些Script提供了shaping规则;排版引擎通过GSUB
/GPOS
表来处理复杂的排版规则,比如字形替换、连字、上下标对齐等,排版引擎会优先选择明确支持对应Script的Font。
5)通过cmap表验证支持的code point
如果匹配出多个Fallback字体,那系统可能会根据用户设置的主字体风格,系统语言、字体优先级等来选择最优的字体。
当然,操作系统中一般会对Script的Fallback字体表有缓存,上面的3、4步骤一般不用每次都做,Fallback表类似于:
json
{
"scripts": {
"hans": ["Microsoft YaHei", "SimSun", "Source Han Sans SC"],
"hant": ["Microsoft JhengHei", "Source Han Sans TC"],
"latn": ["Arial", "Times New Roman", "Verdana", "Microsoft YaHei", ...],
"kana": ["Meiryo", "Yu Gothic", "Source Han Sans JP"]
},
"families": {
"Arial": { "regular": "arial.ttf", "bold": "arialbd.ttf" },
...
}
}
我们后续也会逆向探究下CoreText中的字体级联(Fallback)机制,更细节的这里不再展开。
Q:像👨👩👧
这种由多个code point组成的字符,是怎么匹配Font的
像👨👩👧
这种由多个code point组成的字符(如下),一般称之为Grapheme Cluster(字素簇):
scss
👨 (U+1F468, MAN)
+ U+200D (ZWJ, Zero Width Joiner)
+ 👩 (U+1F469, WOMAN)
+ U+200D (ZWJ, Zero Width Joiner)
+ 👧 (U+1F467, GIRL)
Unicode Emoji 标准里规定了哪些序列可以组合成单个 emoji(如 👨👩👧、👩❤️👩 等),排版引擎会根据Emoji data(来自Unicode数据表)来判断这是不是一个合法的ZWJ Sequence,识别成功会将其视为一个不可分割的单元,在匹配字体时会做如下处理:
1)用组合序列的第一个非ZWJ code point 去查找字体
ZWJ:Zero Width Joiner,零宽度连接符,它的作用就像"胶水",告诉排版引擎两侧字符不可分割。
对于👨👩👧
来说,就是用 U+1F468
(👨),去查找字体,查找和Fallback过程同上。一般而言会匹配到系统内置的彩色表情符号字体:macOS/iOS上一般是Apple Color Emoji
,Windows上是Segoe UI Emoji
,Android上一般是Noto Color Emoji
2)用匹配的字体进行字形替换
这一步其实发生在下面的塑性阶段,在查到的字体表中通过 GSUB表把多个 code point 映射成一个彩色的glyph,也就是字形替换。
如果这一步没找到合法的可替换字形,那就Fallback到单独显示👨 👩 👧
五、字形选择与Shaping
这一步的目标是将抽象的字符转换成具体的glyphIDs和布局信息,以供下一步排版使用;输入是一段单一 Script、单一字体的文本 run和上面匹配出的字体,输出是一个字形序列,包括glyphIDs、x_advance、y_advance、x_offset、y_offset等。
大致分为两步:
1)code point映射到glyphID
每个code point会通过字体的cmap表映射成一个glyphID。
2)文本塑形:应用GSUB、GPOS规则
Shaping引擎会读取字体文件中的GSUB表,进行字形替换,比如连字,emoji替换等;读取GPOS表,调整字形的位置,比如上下标位置、字间距(kerning)、阿拉伯文的连写等。
六、测量与排版
这一步的目标是将字形序列按自定义布局规则排版到二维坐标系下,简单讲就是确定每个glyph的位置、大小信息,以供下一步绘制使用。
大致分为两步:
1)获取字形的metrics
从字体文件的hhea/OS_2表中读取出每个glyph的ascent、descent等信息,用于确定baseline、lineHeight等信息。
2)自定义布局确定每个glyph位置
从上面得到的baseline、lineHeight等信息,以及第六步得到的advance宽度(glyph前进量)等,可以计算出每个glyph的宽高、对齐基线,这样我们就能像前端一样自定义文档流布局(Inline、Inline-Block、Block)来精确的排版每个glyph的位置。
七、渲染上屏
经过上面的塑形、排版过程,我们已经能得到按显示顺序排列且带有精确位置的glyph序列,绘制阶段就是将这些抽象的glyph序列上屏显示出来。
这一步一般有软光栅、硬光栅等多种选型,在macOS/iOS上,可以通过CoreText来绘制字形序列,比如:CTFontDrawGlyphs;本文主要讲解排版引擎流程,渲染部分不再展开,后续有时间再单独开篇研究。
总结
至此,我们自定义文字排版引擎的原理篇告一段落;相信通过以上讲解,我们对文字排版的流程有了一个大致了解,下一步我们将结合ICU、HarfBuzz等来实战实现一个小型的自定义文字排版引擎。
更多精彩内容,欢迎关注🌍公众号:非专业程序员Ping