技术文章

在 Delphi PDF 中使用 HotPDF 处理阿拉伯语和 RTL 文本成形

把阿拉伯语短语 يوضح ملف PDF 传给 TextOut,再打开输出:字母方向错误,而且每个字母都处在孤立形式,与相邻字母断开。阿拉伯语读者看到的效果,类似把英语倒着输入并在每个字母后加一个空格。没有任何东西失败,没有异常,也没有警告,只是两种独立的文本变换根本没有运行。理解这两种变换是什么,以及哪个 API 会应用它们,就是复杂文字 PDF 输出的关键。

本文讨论 HotPDF 中的从右到左文字和复杂文字处理。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件。文章也会说明它的成形支持真正停止在哪里,因为在评估它是否覆盖你的 locale 时,这一点同样重要。

字符串和打印行之间隔着两种变换

Unicode 以逻辑顺序存储文本:也就是输入、存储和朗读的顺序。渲染器按视觉顺序绘制。对于从右到左脚本,两者并不相同;对于混合内容,例如包含拉丁 token “PDF” 或数字价格的阿拉伯语句子,Unicode Bidirectional Algorithm(UAX #9)定义了从左到右片段如何嵌入从右到左的行中。这是第一种变换:重排序。

第二种变换是上下文成形。阿拉伯字母根据它处在词首、词中、词尾还是独立位置,会采用不同字形;codepoint 不会改变,只有渲染形态改变。把 codepoint 直接映射到默认字形的文本管线,就会产生上面那种断开的输出。希伯来语不需要连写,但仍需要重排序;阿拉伯语两者都需要,所以它是标准测试用例。

桌面开发会隐藏这套机制。当 VCL 应用把阿拉伯语绘制到屏幕上时,操作系统文本栈会在背后完成重排序和成形,这也是同一个字符串在 TEdit 中完美显示,却在朴素 PDF 中错误输出的原因。PDF 内容流存储的是定位后的 glyph,而不是可编辑文本 run;谁写入内容流,谁就负责成形,这正是 RtLTextOut 要弥补的缺口。

RtLTextOut:一次调用完成重排序和连接

HotPDF 在 API 层面区分拉丁路径和复杂文字路径。TextOut 会按收到的顺序绘制收到的内容;RtLTextOut 会先执行重排序和上下文分析。SetFont 的 charset 参数告诉引擎应用哪种脚本规则:178 选择阿拉伯语处理,177 选择希伯来语处理。

// Arabic: pass logical order; RtLTextOut reorders and joins
Pdf.CurrentPage.SetFont('Arial Unicode MS', [], 12, 178);
Pdf.CurrentPage.RtLTextOut(400, 700, 0, 'يوضح ملف PDF');

// Hebrew: reordering only, no contextual joining
Pdf.CurrentPage.SetFont('Arial Unicode MS', [], 12, 177);
Pdf.CurrentPage.RtLTextOut(400, 660, 0, 'קובץ PDF זה');

最耗调试时间的陷阱是:RtLTextOut 自己执行反转。把预先反转过的文本传给它,通常是以前尝试普通 TextOut 时留下的“修复”,会导致整行被二次反转。它甚至可能在一个纯阿拉伯语测试字符串上看起来正确,却在第一条包含拉丁字母或数字的行上出错,因为混合 run 已经不再遵循 UAX #9 顺序。始终传入逻辑顺序,让 API 完成工作。

混合方向内容也是审阅中最容易误判的地方:在从右到左的行里,数字和嵌入的拉丁词仍然从左到右阅读。不熟悉双向排版的审阅者经常把这报成 bug;实际上这是符合规范的行为,值得在首轮母语审阅前写入验收说明。

字形覆盖在成形运行前就决定结果

成形只会选择 glyph;字体必须真的包含它们。典型部署失败是:报表在开发者工作站上完美显示,因为那里碰巧安装了 Arial Unicode MS;到了客户服务器上,Windows 静默替换成没有阿拉伯语覆盖的字体,于是出现空方块。修复方式是停止依赖已安装的系统字体,注册你随程序交付的字体文件:

// Ship a known font instead of relying on installed system fonts
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoSansArabic.ttf');
Pdf.CurrentPage.SetFont('NotoSansArabic', [], 12);

// Audit coverage for the codepoints your data actually uses
GID := Pdf.GetUnicodeGlyphForCodepoint($0628);  // U+0628 ARABIC LETTER BEH
LogGlyphAudit($0628, GID);

这里有两个版本边界。以这种方式注册的字体必须嵌入,而 HotPDF 的嵌入式 Unicode 字体处理要求文档 PDF 版本为 1.5 或更高;只有当某个下游系统强制输出 PDF 1.4 时这才相关。另外,字体许可证必须允许嵌入:TrueType 文件带有嵌入权限位,一个在屏幕上能正常渲染的字体,可能从法律上不适合分发到客户文档中。

GetUnicodeGlyphForCodepoint 是审计钩子:在服务启动时遍历数据实际使用的 codepoint 范围并记录解析出的 glyph ID,这样覆盖缺口会在部署期间以日志行出现,而不是以客户发票里的缺字出现。

对于不是从右到左的 Unicode 文本,例如 CJK 字符串、越南语变音符、混合欧洲文字,普通管线即可使用:TextOut 接收 WideString,并通过已注册字体绘制,不做双向分析。在报表代码中明确保留两条调用路径,一条用于 RTL run,一条用于其他所有文本,可以让 locale 行为显式化,而不是埋在没人记得设置的标志里。

阅读顺序也是文档属性

glyph 级正确并不是工作的终点。ISO 32000-1 §12.2 定义了一个查看器首选项 /Direction,用于声明文档的主阅读顺序。它不改变任何 glyph;它告诉查看器如何排列双页展开、面对页布局从哪边推进,以及 UI 应假定哪个方向。这些细节对小册子和任何需要用户翻阅的文档都很重要。

// Declare right-to-left reading order at the document level
Pdf.Direction := RightToLeft;  // adds vpDirection to ViewerPreferences

只给 Direction 赋值就足够了,属性 setter 会自动把 vpDirection 加入 ViewerPreferences,所以一行代码就能把该首选项写入文件。需要防范的遗漏是完全跳过声明,这一点很容易发生,正因为单页上没有任何可见变化;问题只会在有人打印双面小册子,发现跨页方向镜像时才暴露。

HotPDF 成形支持在哪里停止

诚实的能力边界可以节省一周评估时间。RtLTextOut 会自动处理双向重排序和阿拉伯语上下文连接。可选排版连字和更广泛的 OpenType feature 应用不是自动的:GetSingleSubstituteGlyph(GID, 'liga') 一次解析一个替换,输入 glyph ID,旁边给 feature tag。它适用于你自己应用的已知有限连字列表,但不是通用 GSUB feature 引擎。对于成形需求更进一步的脚本,典型例子是带有重排序元音符号的 Indic scripts,在承诺支持该 locale 之前,应使用真实客户字符串跑一次试点,而不是从阿拉伯语结果外推。

验证必须端到端,因为页面可以看起来正确,却在所有下游用途里失败。三项检查可以抓住大多数问题:从 Acrobat 把文本复制出来,并把 codepoint 与源字符串比较;在文档内搜索页面上出现的词;在没有安装开发字体的机器上审阅输出。让能阅读该语言的同事看一份真实文档,胜过任意数量的合成测试数据。应在格式发布前安排这次审阅,而不是第一起投诉之后。

测试字符串要有意选择,而不是复用去年翻译人员随手给的文本。每个 locale 至少应包含:纯脚本句子、嵌入拉丁品牌名的句子、带数字和货币的行,以及带变音符或组合标记的人名。真实客户姓名会击穿填充文本永远碰不到的成形假设;每次支持案例暴露新模式时,回归语料都应增加一条。

字体注册、子集化和通用文本绘制 API 详见关于使用 HotPDF 输出报表、字体和图像的文章;如果同一批文档还必须满足无障碍配置,PDF/A 和 PDF/UA 验证文章中的语言标记和结构要求会叠加在这里描述的成形工作之上。

本文中的从右到左和 Unicode 字体 API 随面向 Delphi 和 C++Builder 的 HotPDF Component 提供;产品页链接了完整文本输出参考。