Delphi 和 Lazarus 编译的是同一种 Object Pascal,而正是这份表面上的相似,让在两者之间移植查看器变得有欺骗性。这两条工具链在三个对 PDF 工作要紧的地方分道扬镳:原生的 string 类型在 Delphi 里是 UTF-16,在 LCL 应用里是 UTF-8;VCL 和 LCL 是两套不同的可视框架,各有自己的控件、对话框和窗体流式格式;而 Delphi 的二进制目标是 Windows,FPC 的二进制则可能是奔着 Linux 或 macOS 去的。这些差异没有一个会在编译期现身。一个建立在 PDFium Component 之上的查看器——它从单一源码树同时提供 VCL 与 LCL 版本——在换掉几个单元名、加上几个 {$IFDEF FPC} 块之后,在 Lazarus 下会干干净净地编译通过。故障来得更晚,等真实数据和真实部署把 Delphi 构建一直悄悄做着的那些假设暴露出来
其中四个假设占掉了大部分被浪费的时间:界面边界上的文本编码、维护两份窗体副本的诱惑、原生引擎二进制在运行期的解析方式,以及 SAPI 一旦不在、文本转语音就没了平台的那一刻。每一个只要你知道它要来,处理起来都很便宜;不知道的话,追查起来就很贵
同样的 Pascal,不同的字符串载荷
Delphi 的原生 string 自 2009 年起就是 UTF-16。Lazarus 和 Free Pascal 在 LCL 应用中默认用 UTF-8。组件面向文本的 API 通过 WString 类型说 UTF-16,FPC 版本把它别名为 WideString,所以文本在你的 LCL 界面和 PDF 引擎之间穿过的每一个边界,都是一个转换点
在直白的赋值里转换是自动发生的,大多数代码从来不需要想它。有两个习惯能把编码缺陷挡在门外。让文本直接穿过去,不要做字节级别的摆弄:按字节偏移切分搜索词的代码在 Delphi 里能用,那里一个 Char 就是一个 UTF-16 单元,而在 LCL 里它会毁掉多字节的 UTF-8。还有,从第一次运行起就用非 ASCII 数据测试。一个德语文件名、一个西里尔字母的搜索词、文档元数据里一个带重音符的作者名:纯 ASCII 的测试数据会藏起每一个编码缺陷,因为 ASCII 正是 UTF-8 和 UTF-16 逐字节一致的那一段。缺陷自始至终都在;只是 ASCII 让它保持隐形,直到慕尼黑的某位客户打开一份你从没试过的文件
一个条件编译块,而不是按 IDE 分叉
写到第十几个 IFDEF 之后,代码库开始让人觉得像是两个项目穿着同一个仓库,按 IDE 分叉看起来很诱人。那是错的一步。真正的差异可以收拢进一个共享的声明块,而一次分叉会把从此以后每一处缺陷修复的代价翻倍。把条件层保持在这么小:
{$IFDEF FPC}
uses
LCLType, Forms, Graphics, Controls;
type
WString = WideString; // 组件的文本 API 是 UTF-16
TBytes = array of Byte;
{$ELSE}
uses
Winapi.Windows, Vcl.Forms, Vcl.Graphics, Vcl.Controls;
{$ENDIF}
那个块以下的一切在两个 IDE 里编译出来都一样。文档处理、页面导航、渲染调用:TPdf 和 TPdfView 在 VCL 与 LCL 版本中暴露同样的接口,所以查看器的绝大部分从来见不到一条编译器条件。让它保持这样是一种结构性纪律,而不是什么小聪明。共享的 PDF 逻辑住在不引入任何框架专属对话框或面板的单元里。少数真正有差别的东西,比如带着各自平台习惯的打印对话框和文件选择器,藏在一个薄接口后面,每个框架实现一次。IFDEF 块于是成了未来平台分歧唯一被允许落脚的地方,而不是让编译器指令渗进四十个单元
用代码构建窗体,而不是在两个设计器里画
窗体流式化是双 IDE 项目悄悄腐烂的地方。一个 .dfm 和一个 .lfm 声称描述的是同一个窗体,却一个属性一个属性地漂移开来,直到两个构建的行为不同而谁也 diff 不出原因,因为这两个文件连格式都不一样。在运行期构建查看器可以完全绕开这个问题。只有一条构造序列,作为普通代码纳入版本控制,而且它在两个平台上读起来一模一样:
procedure TViewerForm.FormCreate(Sender: TObject);
begin
Pdf := TPdf.Create(Self);
PdfView := TPdfView.Create(Self);
PdfView.Parent := Self;
PdfView.Align := alClient;
PdfView.Pdf := Pdf;
PdfView.FitMode := pfmFitWidth;
if ParamCount > 0 then
begin
Pdf.FileName := ParamStr(1);
Pdf.Active := True; // 打开文档;此后 PageCount 才有效
end;
end;
那些赋值的确切顺序,远不如真正干活的那一行要紧。PdfView.Pdf := Pdf 把可视控件绑到文档组件上,从这一刻起,通过 PageNumber 做页面导航、通过 FitMode 做适配,在 VCL 和 LCL 下的反应完全一致。有一个跨框架的怪癖值得在用户把它当缺陷报上来之前就知道:手工给 Zoom 赋值会在两个框架上都把 FitMode 弹回 pfmNone。所以如果你的工具栏把"适合宽度"当成一个粘性偏好,那么每次程序化缩放之后你都得重新设一遍适配模式,否则代码第一次碰到缩放级别时,这个偏好就悄悄不粘了
IDE 从来没警告过你的那个二进制
组件封装的是 PDFium 引擎,它以原生平台二进制的形式发布,而那个二进制几乎是每一份"在 IDE 里能跑,从安装好的快捷方式启动就失败"报告的来源。三条规则能覆盖其中大部分。位数必须完全一致。一个 32 位可执行文件加载不了 64 位的 pdfium 库,而操作系统交回来的消息(某些 Windows 版本上是"找不到模块")主动误导人,因为那个文件明明就躺在可执行文件旁边。库路径要相对于可执行文件解析,绝不要相对于工作目录;从 IDE 启动和从命令行启动的差别恰恰就在这一点上,这正是这个缺陷在开发期藏得住的原因。还有,要在第一份文档打开之前就捕获加载失败,然后把预期路径和架构都写清楚地报出来。一张写着"PDFium 64 位二进制缺失,预期位于 <path>"的支持工单几分钟就能关掉。一张写着"查看器一启动就崩"的,会变成一周的来回拉扯
顺手把引擎二进制和可执行文件一起纳入版本管理。PDFium 走得很快,一个更新了应用却在磁盘上留下一个过期库的安装程序,产出的崩溃你办公室里没人能复现,原因很简单:你办公室里每台机器恰好都持有配套的那一对。把这个库当作构建产物的一部分,与它所服务的可执行文件用同一个安装程序、同一个版本戳、同一条回滚路径
在 Lazarus IDE 中注册组件
运行期构建根本不需要任何设计期注册,对一个用代码搭自己界面的查看器来说,这是最干净的配置。当你确实想把组件放进 Lazarus 的组件面板做设计期工作时,安装那个包,让它专门的注册单元——Lib/FPC/PDFiumLaz.lpk 里的 PDFiumLazReg——来处理。那个单元被特意标记为设计期:它引用了绝不能链进你发布的可执行文件里的 IDE 属性编辑器接口
做错了,症状就是一个莫名其妙依赖 IDE 包的应用,它会在第一台从没装过 Lazarus 的客户机器上表现为部署失败
Windows 之外的语音与屏幕阅读器
文本转语音是唯一一处跨平台故事会断掉的功能,而且断在操作系统上,不在组件上。SAPI,也就是 Windows 上惯常的 TTS 后端,只存在于 Windows。一个仍以 Windows 为目标的 Lazarus 构建保留了完整的 SAPI 输出,以及 Delphi 原版那套兼容 NVDA 的行为,所以从 Windows 移植到 Windows 在这里什么也不会失去,NVDA 用户也分辨不出这两个构建
以 Linux 或 macOS 为目标就是另一回事了。没有 SAPI 可调,所以音频输出必须重新接到一个原生语音服务上,而它上面那些朗读 API 原地不动。这条分界正是从第一次提交起就把语音放到一个接口后面的理由:阅读顺序分析和词跟踪光标是与平台无关的,可以原封不动地带过去,只有真正发出声音的那一薄层需要按平台改写。无障碍阅读器一文深入讲了那套朗读机制
宣布移植完成之前的对等性清单
下面这一趟检查抓到过真实的回归,大致按故障浮现的顺序排列。打开一份路径中含非 ASCII 字符的文档。搜索一个含非 ASCII 字符的词,确认命中高亮落在该落的地方。在你发布的每一套 widget set 上都试一遍滚轮滚动、拖拽选择和键盘翻页,因为焦点处理和滚轮行为是 LCL 里最依赖 widget set 的角落。在 100%、150% 和 200% 的显示缩放下检查渲染。最后,在一台从没装过 IDE 的机器上运行安装好的构建,而不是 IDE 构建,因为那是唯一一个诚实地检验二进制解析的测试。别的都可以通过,而它悄悄失败
渲染吞吐在两个版本之间原样通用,所以渲染缓存与缩放性能一文里的缓存做法,对 LCL 查看器就按它为 VCL 写的那样适用
这一切都不会让 LCL 版本低人一等。核心接口两边完全相同:TPdf、TPdfView、渲染、表单、文本提取和无障碍 API,不管是哪个 IDE 编译的,行为都一样。每一处值得追踪的差异都绑在平台上,而不是绑在版本上。SAPI 语音只有 Windows 才有,对话框各随各框架的习惯,二进制必须与被加载进的架构匹配。把编码边界、运行期窗体和二进制解析做对,移植剩下的部分就是编译器早已替你干完的机械活
这里描述的 VCL 与 LCL 版本作为 PDFium Component 一同发布,附带源代码,并为 Delphi、C++Builder 和 Lazarus/FPC 提供完全一致的公开 API