从0到1自定义文字排版引擎:原理篇

引言

前面我们讲解了字符与编码,知道了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 的多种表示方法导致排版不一致,比如避免é被分开成é排版渲染。

预处理一般步骤是:

  • 编码转换:将字符统一成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左右字符(逻辑顺序)分别为bd

  • 如果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

相关推荐
星光不问赶路人2 小时前
理解 package.json imports:一次配置,跨环境自由切换
前端·npm·node.js
3Katrina2 小时前
GitLab 从入门到上手:新手必看的基础操作 + 企业级应用指南
前端
圆肖3 小时前
[陇剑杯 2021]简单日志分析(问3)
前端·经验分享·github
王嘉俊9254 小时前
Django 入门:快速构建 Python Web 应用的强大框架
前端·后端·python·django·web·开发·入门
2501_915918414 小时前
Video over HTTPS,视频流(HLSDASH)在 HTTPS 下的调试与抓包实战
网络协议·http·ios·小程序·https·uni-app·iphone
IT_陈寒5 小时前
Redis性能翻倍的5个冷门技巧,90%的开发者从不知道第3点!
前端·人工智能·后端
WebGIS开发5 小时前
新中地三维GIS开发智慧城市效果和应用场景
前端·人工智能·gis·智慧城市·webgis
鱼樱前端6 小时前
uni-app快速入门章法(一)
前端·uni-app
zhangxuyu11186 小时前
flex布局学习记录
前端·css·学习