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

这篇文章是从0到1自定义富文本渲染的原理篇之一,此外你还可能感兴趣:

更多内容欢迎关注公众号:非专业程序员Ping

引言

前面我们讲解了字符与编码,知道了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,在每一级别就地反转子串

递归处理完后,由高层级到低层级反转嵌套子串,就能得到最终每行的视觉顺序。

详细逻辑可以参考:https://cs.uwaterloo.ca/~dberry/ATEP/Slides/UnicodeBiDiAlgorithm.pdf

下面以几个例子说明:

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

相关推荐
黑化旺仔11 小时前
【OC】Runtime:类元数据、加载初始化与消息派发
开发语言·macos·ios·objective-c
songgeb12 小时前
iOS App支持设置RTL语言的实践
ios·swift·ui kit
软泡芙12 小时前
【iOS】swift启动屏显示黑屏
ios
用户693717500138414 小时前
当你向领导申请 AI 编程工具预算时,如果领导追问“能提效多少,有量化数据吗”,你该怎么回答?
android·后端·ios
Layer14 小时前
一些脱离 Mac 依赖的 Agent 操作 iPhone 实现
ios·ai编程·测试
用户693717500138416 小时前
当我说 Claude 搭配 GLM-5.3-Flash 太好用了,同事却提醒我:你可能正在失去对代码的掌控
android·后端·ios
茶底世界之下16 小时前
一键裁方会丢掉什么?用 Awaking 给园林照片做两种画幅
ios
Marco_Marco13517 小时前
定位SDK接入实战:Android/iOS/鸿蒙三端怎么选、怎么接
android·ios·harmonyos
老李IT笔记1 天前
关掉设备上的 AI 扩展:两个键各管一层,配错了等于没配
人工智能·ios·智能手机
2501_915921431 天前
iOS应用耗电测试:使用Energy Log、Keymob和Instruments进行性能监控与能源消耗分析
android·ios·小程序·https·uni-app·iphone·webview