Uniscribe 做的工作比大多数调用者意识到的多。ScriptItemize 一遍完成双向分析与文字系统切分,ScriptLayout 产出所得运行段的视觉顺序。人们顺手持来的可移植替代品 HarfBuzz 两样都不做:它整形单个运行段,而其方向与文字系统早已被别人决定。所以把 Windows 上的 PDF 文本管线带上 Linux 或 macOS,难点不在绑定一个整形引擎,而在于补上 Uniscribe 一直在悄悄提供的双向算法——在 PDFium 组件里,这正是 FPdfBidi 的职责
该单元直接实现 UAX #9:P2 与 P3 规则定段落方向,X1 到 X10 处理显式嵌入与隔离,W1 到 W7 处理弱类型,N0 到 N2 处理中性字符与括号,I1 与 I2 处理隐式级别,L1 与 L2 负责最终重排序。两个函数承载它:PdfResolveBidiLevels 为每个 UTF-16 代码单元返回一个嵌入级别,PdfBidiVisualOrder 把这些级别变成从左到右摆放代码单元的置换
算法给你什么,不给你什么
它给你数字。偶数级别从左到右,奇数级别从右到左,每个字符的级别编码了该字符所处的方向运行段嵌套。L2 从这些数字推导置换。算法刻意不做的是:决定用哪个字体、形成连字、或在簇内重排字形;那些是整形的关注点,属于这个阶段之后
uses
FPdfBidi;
var
Levels: TPdfBidiLevels;
Order: TPdfBidiOrder;
ParagraphLevel: Byte;
Text, Visual: WideString;
I: Integer;
begin
Text := SourceLine;
// pbdAuto 应用 P2-P3:第一个强字符说了算
if PdfResolveBidiLevels(Text, pbdAuto, Levels, ParagraphLevel) then
begin
Order := PdfBidiVisualOrder(Text, Levels);
SetLength(Visual, Length(Order));
for I := 0 to High(Order) do
Visual[I + 1] := Text[Order[I] + 1];
// Visual 现在从左向右可读;Levels[] 仍标明哪些
// 运行段是 RTL,整形器因此能拿到正确方向
end;
end;
字符类表是生成的,不是手写的
每个码点都有 Bidi_Class 属性,算法不停地查询它,所以这张表是其他一切立足的地基。它从 Unicode 字符数据库生成,而不是手工维护:UnicodeData.txt 的第五个字段给出已分配的类,DerivedBidiClass.txt 的 @missing 声明给出数据库未分配码点的默认值——未分配区块因此正确地默认为 R、AL、ET 或 BN,而不是 L
压缩技巧是只输出类不为 L 的区间。落在所有区间之外的都是 L——它既是 Unicode 默认,也是绝大多数码点的类。这让原本会扩展到数千条目的表缩到 745 个区间、约 6.7 KB。运维上的后果值得说破:迁移到新的 Unicode 版本时,重新跑生成器。手工编辑 include 文件也能用,但它会在下次升级时悄悄偏离数据库
L2 必须重排码点,而不是 UTF-16 代码单元
这是产出真正损坏输出的那个错误,第一版实现就栽在这里。L2 说:从最高级别向下到最低奇数级别,反转每一级别上的连续运行段。对着 UTF-16 字符串写,"反转一个运行段"自然意味着反转其中的代码单元。对基本多文种平面的字符没问题。对星形平面上的 RTL 字符——例如 U+10800 附近塞浦路斯或古南阿拉伯区块里的那些——则不行:该字符是一个代理对,反转运行段会让低代理跑到高代理前面,字符串从此含着两个不成对的代理,而不是一个字符。下游没有任何东西能救回它
修法是在码点单元上做 L2。实现先把代码单元合并成码点单元,在这些单元上执行反转,最后再把结果展开回代码单元索引。这正是 PdfBidiVisualOrder 要接收文本而不只是级别数组的原因:仅凭级别它判断不出代理边界在哪。同样的代理对纪律贯穿整个文本 API,emoji、CJK 与代理对一文有所叙述
级别下降必须包含并未出现的级别
第二个错误更隐蔽,不崩溃,只是文本没有被重排。L2 说从出现的最高级别开始,向下走到最低奇数级别。一个自然的优化是收集实际出现的级别集合,然后遍历该集合。这是错的
设想一行处于从右到左嵌入中的拉丁文本。段落级别是 0,嵌入把拉丁字符推到级别 2,没有任何字符位于级别 1。遍历出现的级别只会找到 0 和 2,根本没有奇数级别,于是循环一次反转也不做。这个答案是对的,但理由是优化不知道的:级别 2 的反转接级别 1 的反转恰好完全抵消,所以两者都不做才是正确结果。把输入稍作改动,使级别 1 与级别 3 的字符都存在而级别 2 没有,基于集合的循环就会跳过算法要求的级别 2 反转
// 正确:从最高级别逐级走到最低奇数级别,
// 包括没有任何字符实际具有的级别
Level := MaxLevel;
while Level >= LowestOddLevel do
begin
ReverseRunsAtOrAbove(Level); // 无运行段满足条件时即空操作
Dec(Level);
end;
写成普通的递减循环,行为免费奉送,空操作迭代的开销测不出来。这里显而易见的优化不是略有偏差,而是以依赖输入的方式出错——小型测试语料永远暴露不了它
括号:BD16 配一张务实的表
规则 N0 与 BD16 括号对算法的存在,是为了让混合方向文本中的括号解析为它所包内容的方向,而不是碰巧相邻的方向。这需要一张括号对表。实现携带的是通用括号对,而不是 Unicode 括号文件的全部内容:ASCII、CJK、全角、数学与装饰括号
未列入表的括号不是错误。它作为普通中性字符经 N1 与 N2 解析——这正是 Unicode 6.3 引入 N0 之前所有实现的行为。所以边界是"对罕见括号不够精细",而不是"不正确"。有一个细节确实需要显式处理:U+2329 与 U+232A 的尖括号和 U+3008 与 U+3009 的尖括号之间的规范等价,匹配括号对时必须折叠,否则一种写法的开括号将与另一种写法的闭括号配对失败
如何测试三十条相互作用的规则
不是靠大语料,至少一开始不是。有效的方法是十六个人工核验的用例,每个都为演练某条具体规则而选,并对照 UAX #9 声称应产出的级别逐一核验:P2 与 P3 下的段落方向检测,弱类型规则 W2、W3 与 W7,隐式级别规则 I1 与 I2,经 X2 与 X7 的显式嵌入,经 X5a 与 X6a 的隔离,L1 对尾随空白与分隔符的重置,一个 N0 括号用例,以及一个带星形平面字符的用例来锁定代理处理
十六个预期级别已知正确的用例,抓住的问题比一千六百个输出貌似合理的用例更多,因为双向实现的失败模式是"读起来几乎正确"的文本。这些通过之后,语料才对发现表格缺口与性能问题有用——那是另外几类缺陷
在 PDFium 组件内部,级别供两个消费者使用。写入侧,它们告诉整形后端每个运行段的方向——那正是 HarfBuzz 需要的输入。读取侧,它们支撑选择几何与阅读顺序,因为 RTL 文本中的点击必须映射到逻辑位置而不是视觉位置;该映射见可视行选择一文,阅读顺序模型见结构化文本块与阅读顺序。组件的平台支持细节见 PDFium Delphi component 产品页