技术文章

HotXLS 在 Free Pascal 下:Unicode、COM 槽位与 zlib

HotXLS 可以在 Windows 的 Free Pascal 和 Lazarus 下构建,这次移植取决于四个与 Object Pascal 语法毫无关系的决定:让核心保持 DELPHIUNICODE 模式;把 OLE 结构化存储接口声明为 CORBA 接口并手工管理引用计数;用 Pascal 实现替换 Win32 的 AES 对象文件;以及修一个可能把截断的 ZIP 当成完整的 inflate 循环

移植过成熟 Delphi 库的人都认识这项工作的形状。编译器第一遍几乎全收。接下来是一条长长的尾巴:行为差异,编译干干净净,结果却不对——而电子表格引擎暴露得格外厉害,因为它在一条代码路径里同时碰文本编码、COM 结构化存储、压缩和密码学

为什么核心坚持 DELPHIUNICODE 而不是普通 DELPHI?

因为公式引擎依赖 StringChar 携带 UTF-16 语义,而 ANSI 替代方案在任何东西抵达文件之前就把字符弄丢了。用 FPC DELPHI 模式构建核心很有诱惑力——那是大多数移植者伸手就够到的兼容开关——而且代码能编译。然后一份带中文工作表名或西里尔标签的工作簿经过计算路径做了一次往返,等写出器看到时字符已经没了,全程没有任何报错

这个模式在库内并不统一,而这是刻意的,不是邋遢。PNG 字节解码器和 LCL 覆盖确实需要 ANSI 签名,因为它们打交道的对象是字节,以及 widgetset 递过来的东西。这些单元启用一个独立的 LX_FPC_ANSI 开关。一个库两种模式,听起来像坏味道——直到你意识到另一种选择是一个把自己的输入当文本处理的字节解码器

还有一个会后来坑人的配套细节。在 FPC 运行时里,DELPHIUNICODE 并不会让 TFormatSettings.DecimalSeparator 变成 WideChar。携带 Unicode 小数分隔符的输入必须先在 Unicode 字符串内部归一化成 ASCII 分隔符,而任何分隔符与预期不符的输入必须被拒绝,而不是在解析器不认识的那个字符处被悄悄截断

program ExportReport;
{$MODE DELPHI}
uses
  Interfaces,          // 必须排第一:初始化 LCL widgetset
  SysUtils, lxHandle;  // 以及 UTF-8 转换层

var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('input.xls');
    Book.Sheets[0].AsString[1, 1] := 'Quarterly summary';
    Book.SaveToFile('output.xls');
  finally
    Book.Free;
  end;
end.

Interfaces 单元不是可选项,而且必须排第一。它负责初始化 LCL widgetset 和 UTF-8 转换层,而 HotXLS 在字体、文件路径或文本跨越 RTL 与 LCL 边界时同时依赖这两者。跳过它的控制台程序能编译,然后在任何非 ASCII 路径上行为失常。这也是为什么在这里「编译通过」几乎证明不了什么:移植真正可证明地工作,是在带真实字体名、真实路径的真实文档完成完整往返之后

类的 VMT 不是 COM 的 vtable

Free Pascal 不允许你把类的 VMT 当 COM 接口 vtable 递给 Windows,哪怕声明看起来与 Delphi 接受的那份一模一样。两者的布局差异足以让一次调用走进错误的槽位,表现为在与调用点毫不相干的地方崩溃。结构化存储在这里举足轻重,因为经典二进制工作簿格式就是 OLE 复合文件,读或写一个就意味着实现一份 Windows 存储 API 会回调进来的 ILockBytes

行得通的安排是:CORBA 接口加显式声明的 COM 槽位,AddRefRelease 手工管理。这意味着为这几个类型放弃自动引用计数、亲自负责生命周期——对一个活在单个单元里的少量接口来说,是笔划算的交易。这项工作里具体的陷阱是 QueryInterface:它必须返回接口指针,而不是对象指针。两种写法都能编译。其中一种递给 Windows 的地址,第一个机器字不是 vtable

Free Pascal 类 VMT 与 HotXLS 必须呈现给 Windows 结构化存储 API 的 COM 接口 vtable 的对比图:同一份 Pascal 声明对应不同的槽位顺序,外加 QueryInterface 陷阱——返回对象指针而不是接口指针,会把一次 ILockBytes 调用送进类槽位,在远离调用点的地方崩溃
Free Pascal 拒绝把类 VMT 当 COM vtable 服务,所以 HotXLS 声明带显式 COM 槽位的 CORBA 接口、手工管理 AddRef 和 Release,QueryInterface 返回 Windows 能够解引用的接口指针

FPC 特有的声明住在 lxOleInterfaces.inc,与 FPC 源目录里的 lxAESBackend.inclxZlibBackend.inc 为邻,于是编译器相关的选择集中在一处,而不是散落在引擎各处。格式本身以及库如何在其中导航,见 在 Pascal 中读取 OLE2 复合文件

还有一个同族的类型细节。LargeInt 在 FPC 分支里必须解析成 Int64,而两条工具链对 Comp 的编译器分类差异大到重载决议可能选中不同的候选。用文件流而不是 HGLOBAL 流测试大偏移行为:Windows 全局内存流在超过 4 GiB 的 seek 上自己会回绕,所以那里的测试通过,对你自己的算术什么也证明不了

一个自洽的 AES 实现能藏住什么

Delphi 构建链接的 Win32 AES 对象文件是 OMF 格式,Free Pascal 链接器消费不了,所以 FPC 分支改用 Pascal 的 AES 实现。Delphi 继续链接它一直链接的对象文件,已发布二进制对老客户保持不变

值得带进任何项目的是验证要求。用同一个实现加密再解密什么也证明不了:一个密钥排布错、块顺序错或链接模式错的对称算法完美自洽,每次都能把自己的输出成功往返。只有已知答案向量能抓住它——对照公开值检查密钥扩展、块顺序和 CBC 链接。发出去一个自洽的错误实现,症状会在客户第一次用 Excel 打开文件时出现

压缩方面的缺陷则是另一种性格。Pascal 的 inflate 后端在耗尽全部压缩输入之后仍可能有输出挂着,所以调用方必须持续调用直到流报告结束。把输入耗尽当作流结束,会截掉最后一个块。更糟的是,它会把一个损坏的归档变成一个被悄悄接受的归档——这正是 验证 ZIP end-of-central-directory 记录里那道加固要防的失败模式。规则是:无进展加未完成就是截断错误,永远不是 EOF

两个烧掉真实工时的构建系统陷阱

LCL 搜索路径必须排在 FPC 包通配路径之前,否则 Free Vision 的 Menus 单元会遮蔽 LCL 的同名单元,你会得到一个对两边都只字不提的 PPU 校验和不匹配。安装后又被移动过的 Lazarus 安装还可能在 fpc.cfg 里留下过期路径,所以构建入口显式指定单元和二进制路径,而不是继承环境给的任何东西

第二个陷阱与 Pascal 无关。用 LF 行尾写的 .cmd 批处理文件,在文件长过解释器读缓冲区之前都工作正常,越过那个尺寸之后 call :label 会失败,声称批处理标签不存在,而且失败出现在碰巧越过边界的任何一个程序上。任何重写批处理脚本的工具都必须写回 CRLF。另外 lazbuild --build-all 会在编译前清空包的单元输出目录,所以停在那个目录里的选项文件会在被读取之前就被删掉:把它放在外面,并且记住 @ 路径是相对包目录解析的,因为 lazbuild 从那里调用编译器

HotXLS 干净的 Free Pascal 编译背后的两层危险地图:保持 String 与 Char 为 UTF-16 的 DELPHIUNICODE 模式、给 PNG 字节解码器和 LCL 覆盖用的 LX_FPC_ANSI 逃生口,以及来自 Free Vision Menus 遮蔽、过期 fpc.cfg 路径、纯 LF 批处理文件和 lazbuild 输出清理的构建陷阱
首次编译证明不了什么:模式地图决定哪些字符能活到写出器,而构建系统陷阱表现为校验和不匹配、幽灵般的标签缺失、以及还没被读取就被删掉的选项文件
// Lazarus 网格导出:TGridToXLS 随 Lazarus 包提供,
// 同一份 DB 网格导出代码在 LCL 应用里照样能用
var
  Exporter: TGridToXLS;
begin
  Exporter := TGridToXLS.Create(nil);
  try
    Exporter.DBGrid := GridOrders;
    Exporter.WorksheetName := 'Orders';
    Exporter.ExportHeader := True;
    Exporter.SetColumnsWidth := True;
    Exporter.ExportDBGrid;
    Exporter.SaveAs('orders.xls');
  finally
    Exporter.Free;
  end;
end;

一条编译器警告值多少

Free Pascal 会报告 Delphi 不报的未初始化局部变量,跑一遍 FPC 构建就把这个差异变成了计算单元里两个真实缺陷。一个函数读取了一个从未赋值就使用的计数变量;另一个在一个分支里使用两个坐标,而计算它们的代码在另一个分支里。在 Delphi 下,两者都按栈里碰巧装着的东西行事——这就是那种在一台机器上复现、在另一台上不复现的 bug 的定义

务实的结论是:哪怕产品主要在第一个编译器上出货,也值得让第二个编译器留在循环里。定期扫一遍 FPC 的警告类别,相当于对 Delphi 代码库做一次廉价的静态分析,它能找到测试套件稳定够不着的那一类缺陷。它所处的更广的版本矩阵纪律,见 跨编译器构建矩阵

Windows 上的 Free Pascal 与 Lazarus 支持随 HotXLS Delphi spreadsheet component 以 Lazarus 包的形式提供,与 Delphi、C++Builder 包并列,构建自同一份源码树而不是分叉。这正是这次练习的意义:一个引擎、四条工具链,编译器相关的决定隔离在 include 文件里,一次就能读完