把阿拉伯语短语 يوضح ملف PDF 传给 TextOut,然后打开结果文件。字母方向是错的,每个字母都停留在孤立字形,前后之间还留着明显空隙,看起来就像有人把英文倒着打出来,再在每个字符之间敲了一个空格。没有抛异常,没有输出警告,结果就是错的,而且错在阿拉伯语依赖的两种独立变换根本没有发生。理解这两种变换是什么,以及哪个调用负责执行它们,基本就掌握了复杂文字在 PDF 中输出的关键
HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,它通过一个单独的调用帮你处理从右到左文本。它也会在几个明确的边界处停下来,这些边界在你为某个 locale 做承诺前最好先知道,因此本文会把概念和真实边界讲清楚。至于这个调用本身的实操设置,则放在RtLTextOut 参考文章里
为什么字符串明明正确,打印出来却是错的
Unicode 按逻辑顺序保存文本,也就是你输入和朗读它时的顺序。渲染器则必须按视觉顺序把字形放到页面上。对从左到右脚本来说,这两个顺序恰好一致,所以没人会特别在意。对阿拉伯语和希伯来语来说却不是这样;当一行内容混合方向时,比如阿拉伯语句子里夹着拉丁 token“PDF”或一串数字,Unicode Bidirectional Algorithm,也就是 UAX #9,会精确决定这些从左到右片段如何嵌在整条从右到左的行里。这就是第一种变换,也就是重排,跳过它就会把整行方向翻错
第二种变换是上下文成形。阿拉伯字母会根据它出现在单词里的位置而改变写法,可能是词首、词中、词尾,或者单独站立。整个过程中 codepoint 并不会改变,变化的只是字形。一个管线如果把每个 codepoint 直接交给默认字形,就会得到开头那种断开、孤立的输出。希伯来语不需要这一步,因为它的字母不连写,但它仍然需要重排。阿拉伯语两者都需要,这也是为什么拿阿拉伯语而不是希伯来语做测试最有代表性
在桌面程序里,这些通常都不是你的问题。当 VCL 窗体把阿拉伯语绘制进 TEdit 时,操作系统的文本栈会悄悄替你完成重排和成形,所以同一个字符串在屏幕上看着完全正确,到了朴素 PDF 里却会坏掉。内容流存的不是可编辑文本,而是已经定位好的字形,因此谁生成内容流,谁就接手了原来由操作系统负责的成形工作。RtLTextOut 就是把这份工作接回来的那个调用
RtLTextOut 会替你完成哪些成形工作
HotPDF 把拉丁文本路径和复杂文字路径拆成了两个不同方法。TextOut 会按你给它的顺序直接输出,RtLTextOut 则会先执行两种变换,也就是整行双向重排和连写脚本的上下文分析,然后再输出。具体采用哪套脚本规则,并不是由调用本身推断,而是通过字体的 charset 传入,因此方向选择会在每个调用点显式决定,而不是根据字符内容去猜。参数逐项设置、charset 数值、字体注册步骤以及完整可编译示例,都放在RtLTextOut 参考文章里;本文只聚焦这些变换本身、它们会停在哪里,以及该如何证明它们确实生效了
即便只站在这个抽象层面,也有一条使用规则必须记住:输入必须保持逻辑顺序,因为 RtLTextOut 会自己完成反转;如果你手工先把字符串翻了一次,再传给它,结果就会被二次反转。参考文章演示了这个陷阱以及如何清理。这里之所以专门提它,是因为这种问题很容易混过测试。一个被双重反转的纯阿拉伯语字符串,看起来仍可能完全正确,只有当一行里夹了拉丁单词或数字时才会暴露,因为那些嵌入片段已经不再按 UAX #9 要求的方式嵌套。问题不在渲染器,而在于你给算法喂入了已经被半处理过的文本
同样的混合方向行为,坑审阅人员往往比坑代码更多。在一行从右到左文本里,数字和嵌入的拉丁单词依旧应该从左到右阅读。没接触过双向排版的人看到渲染出来的发票,往往会觉得账号方向和周围阿拉伯语“不一致”,于是报成 bug。实际上这是符合规范的结果。在第一次让母语审阅者过稿之前,把这一点写进验收标准,能省掉一轮来回
什么时候重排和连写就够了,什么时候还不够
对于阿拉伯语和希伯来语的连续正文,例如报表、发票、合同和信函,重排加上下文连写基本就是全部工作,RtLTextOut 单独就能完成。边界出现在排版要求超过连写的时候。HotPDF 在阿拉伯语这边提供的是一个可选、位于生成器侧的成形器:把 AutoShapeArabic := True 打开后,组件会先把逻辑顺序的文本运行重写成 Unicode Presentation Forms,再进入双向处理阶段,这样连写字形会根据逻辑相邻关系预先计算出来,连字折叠也会直接烘进 PDF 最终携带的 codepoint,而不是交给查看器自行处理。这个开关默认关闭,保持关闭时输出字节是稳定的,因此是否开启应当由具体文档管线显式决定,而不是默认全局升级。其他会连写的从右到左脚本也沿用相同模式,包括 Syriac、N'Ko、Adlam 和 Hanifi Rohingya,它们各自都有对应的自动成形开关
可选 OpenType 特性又是另一套机制。像裁量连字这样的单替换特性通过 GetSingleSubstituteGlyph(GID, 'liga') 处理,一次只解析一个替换,先给输入字形 ID,再给 feature tag;如果该特性不适用,它就会原样返回输入字形。这足够驱动一份由你自己维护、已知且有限的连字列表,但它并不是完整的 GSUB 引擎。这一点恰恰是很多激进 locale 计划最容易误判的地方:一个把阿拉伯语处理得很好的成形管线,只证明它完成了重排和连写,仅此而已
不同脚本上的覆盖范围
阿拉伯语同时会触发这两种变换,所以它既是最值得测试的字符串,也是证明整条管线可用的最强单点证据。希伯来语需要重排,但不需要连写,因为它的字母本来就各自独立;如果希伯来语输出正确,而阿拉伯语仍然断开,就说明双向那一半没有问题,出错的是上下文成形根本没跑。波斯语和乌尔都语都使用阿拉伯字母体系,因此会继承相同的行为,不过乌尔都语偏好的 Nastaliq 风格属于字体选择问题,可读性最好由母语读者来判断
泰语则位于整条边界的另一侧。它从左到右书写,所以不需要双向处理;字母也不连写,所以不需要上下文分析。泰语字符串会像拉丁文本一样走普通 TextOut 路径。泰语真正的难点是叠放标记,也就是位于基底辅音上下的元音和声调符号,这些能否摆对,取决于所选字体能否在没有成形引擎帮助的情况下正确组织组合标记。大多数专门的泰文字体都可以,但你必须用将要嵌入的那一款字体实测,不能拿看起来像的替代品想当然
天城文以及整个 Indic 家族,才是需要诚实划线的真正硬边界。它们的元音符号会围绕辅音簇重排,连字也依赖一整串上下文替换,这已经属于完整 GSUB 的能力范围,超出了单纯重排和连写。如果路线图里包含 Indic locale,就必须先用真实客户字符串做一次试点,再决定是否承诺支持,不能因为阿拉伯语工作正常就推断天城文也会正常。CJK 文本、带叠加变音符的越南语以及混合欧洲语言,都走普通路径而不需要双向分析。在报表代码里最好把两条路径物理分开,一条例程专门处理 RTL 文本段,另一条处理其他所有内容,这样 locale 逻辑会明确暴露在调用点,而不是藏在一个容易被忘记设置的标志后面
字形覆盖在成形运行之前就已经决定
成形做的是从字体里选字形。如果字体里根本没有这些字形,那就无从选择。这也是为什么最典型的部署故障,也就是开发机上完全正常、客户服务器上却因为静默字体替换变成空方块,本质上是覆盖问题,而不是成形问题。参考文章已经分步骤讲了正确做法,也就是注册你随程序交付的字体,而不是相信目标机器上碰巧装着什么。这里更重要的概念是,只有先确认覆盖范围,后面的成形讨论才有意义,而且这种确认完全可以程序化完成,而不必靠肉眼盯输出猜
// After RegisterUnicodeTTF, audit coverage for the
// codepoints your data actually uses
GID := Pdf.GetUnicodeGlyphForCodepoint($0628); // U+0628 ARABIC LETTER BEH
LogGlyphAudit($0628, GID);
字体注册本身还带着两个限制,一是嵌入 Unicode 字体时 PDF 版本至少要到 1.5,二是字体自身的嵌入许可位要允许嵌入,这两点都已经在RtLTextOut 参考文章里连同设置步骤讲清楚了。这里真正值得记住的是审计习惯:GetUnicodeGlyphForCodepoint 是你的早期预警系统。让服务在启动时扫描实际会出现的数据 codepoint 范围,并记录返回的 glyph ID。这样覆盖缺口会在上线期以启动日志中的一行暴露出来,而不是等到发票已经发给客户之后,才以缺字的形式出现
阅读顺序属于文档,而不属于字形
即便每个字形都正确,仍然还有一件事没有做完。ISO 32000-1 §12.2 定义了一个查看器首选项 /Direction,用来声明整份文档的总体阅读顺序。它不会碰任何字形,它做的是告诉查看器如何排列双页展开、面对页布局应该从哪一边开始,以及阅读界面应当偏向哪个方向。这些在单页上看不出来,也正因为看不出来,所以最容易被忘掉
// Declare right-to-left reading order at the document level
Pdf.Direction := RightToLeft; // adds vpDirection to ViewerPreferences
设置 Direction 就是全部工作:属性 setter 会把 vpDirection 加进文档的 ViewerPreferences,因此一行代码就能把这个偏好写入文件。如果文本是通过 RtLTextOut 输出的,你通常会自动得到它,因为该调用会把文档方向作为副作用一起切到右向左;参考文章也说明了什么时候混合方向文档又需要把它切回来。真正需要你手工设置的场景,是文档本身是从右到左,但文本用了别的方式生成,例如你在上游已经做过预成形,然后通过普通路径绘制。此时如果省掉它,眼前的单页证明文件看起来仍然一模一样;直到几周后有人把它打印成双面小册子,发现跨页方向镜像,根因才会回溯到这条当初没写上的一行代码
如何验证成形后的输出
验证必须贯穿端到端,因为页面看起来正确,并不等于下游所有用途都可用。三项检查能抓住大多数问题。第一,把文本从 Acrobat 里复制出来,并将 codepoint 与源字符串逐个比较。第二,在查看器里对页面上看得见的词做文档内搜索。第三,在一台没有安装你开发字体的机器上打开输出,因为那是最容易暴露字体替换问题的环境。这些都不能替代母语读者实际看一份真实文档,因为只有真实读者能发现合成测试料想不到的问题。在格式发布之前就把这次审阅排进日程,而不是等到上线后才想起它
测试字符串也应该有意识地挑选,不要继续复用去年某位译者顺手发来的那几句。每个 locale 的最低配置,至少应包括一条纯脚本句子、一条夹着拉丁品牌名的句子、一条带数字和货币的行,以及包含变音符或组合标记的人名。真实客户姓名最容易打破填充文本永远碰不到的假设,因此每次支持案例暴露出新的模式,都值得把那条字符串补进回归集
字体注册、子集化以及日常文本绘制 API,已经在使用 HotPDF 处理报表输出、字体和图像的文章里讲过。如果同一批文档还必须满足无障碍规范,那么PDF/A 和 PDF/UA 验证文章中的语言标记与结构要求,就会叠加在本文介绍的成形工作之上
上面提到的从右到左文本和 Unicode 字体 API,都是HotPDF Component面向 Delphi 和 C++Builder 提供的一部分,产品页也链接了完整的文本输出参考