技术文章

在 Delphi 中用 HotXLS 打开和保存 ODS 文件

一个多年来一直输出 .xlsx 的 Delphi 报表后端接到了新需求:某个公共部门客户的采购规定要求输出 OpenDocument 电子表格,而该客户的分析师把改好的内容以 LibreOffice 保存的 .ods 文件发回来。于是同一套代码现在既要写 ODS 也要读 ODS。HotXLS 是 losLab 面向 Delphi 与 C++Builder 的原生 Object Pascal 电子表格库,两个方向它都能处理,且任何地方都不需要装 Excel 或 LibreOffice。两个方向已经接近,但并不相同。自 HotXLS 2.88.229 起,导入路径把公式作为活公式带回来,与缓存结果并肩存放;当前的发行版还能读回样式、数字格式、合并区域、批注、图片、图表和条件格式。仍然无法存活的是完整的 OpenFormula 语义和少数包级细节,所以假定完美镜像的团队,仍会在客户的修订与下一份报表之间的某处撞上边缘情况,而且通常没有任何错误可指

ODS 支持挂在 XLSX 门面上,而不是 XLS 门面

HotXLS 在一个包里发布两套彼此独立的类层次:lxHandle 单元中的 TXLSWorkbook 处理二进制 BIFF8 的 .xls 文件,lxHandleX 单元中的 TXLSXWorkbook 处理 OOXML 的 .xlsx 包。每一个 OpenDocument 入口 - OpenODS、SaveAsODS、GetODSSheetNames - 都挂在 TXLSXWorkbook 上。这种归属不是随意安排的。按 OASIS ODF 1.3 的规定,一个 ODS 包是一个 zip 归档,带有 mimetype 成员、一份清单和一份 content.xml 正文,这使它在结构上是 OOXML zip 的表亲;而 BIFF8 是 1990 年代的二进制记录流,和它毫无共通之处

这种归属有一处现实的锋刃:一个遗留的 .xls 工作簿没法一次调用就变成 .ods。你得先用 lxXlsxExport 单元里的 SaveXLSWorkbookAsXLSX 把 BIFF 内容桥接进 XLSX 模型,再通过 TXLSXWorkbook 重新打开结果,然后从那里导出。这座桥不是无损的,在依赖它之前值得先知道缺口在哪。它复制值、公式、数字格式、字体、填充、边框、合并区域、列宽和行高。它丢掉批注、图表和条件格式。一份重格式的 .xls 源文件到了 ODS 会比出发时素净,而这是桥的属性,不是 ODS 写入器的

导入一侧的检测是自动的。普通的 Open 方法通过 mimetype 成员识别 ODS 包,该成员缺失时则回落到检查顶层的 content.xml,因此一条通用的“用户传什么就打开什么”的代码路径不需要自己嗅探扩展名。打开之后,SourceFormat 属性会报告是哪个分支被触发的

示意图:Delphi 中 HotXLS 的类布局,所有 ODS 入口都挂在 TXLSXWorkbook 上,BIFF8 的 .xls 内容则由 SaveXLSWorkbookAsXLSX 这座桥搬过来
每一个 OpenDocument 入口都挂在 TXLSXWorkbook 上,而遗留的 .xls 只能经由有损的 BIFF 到 XLSX 之桥抵达 ODS

用 TODSExportOptions 导出到 ODS

导出调用本身只有一行;围着它的那个选项对象承载的,才是审阅者日后会追问的那些决定:

var
  Book: TXLSXWorkbook;
  Opts: TODSExportOptions;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    Opts := TODSExportOptions.Create;        // 由调用方持有并释放
    try
      Opts.Generator := 'ReportService 4.2'; // 覆盖 meta:generator
      Opts.IncludeCharts := True;
      Opts.IncludeImages := True;
      Book.SaveAsODS('quarterly-report.ods', Opts);
    finally
      Opts.Free;
    end;
  finally
    Book.Free;
  end;
end;

选项对象归调用方所有。HotXLS 不会释放它,这正是里层那个 try..finally 存在且不可省略的原因。真正会改变输出、而不只是给输出贴标签的那两个属性值得细看。把 IncludeCharts := False 设上去,做的不止是隐藏图表:它会把图表子文档及其清单条目从包里剥掉,而当消费方是一条会被图表绊倒的数据流水线时,这正是你想要的。Generator 覆盖 ODF 的 meta:generator 字符串,该字符串否则读作 HotXLS/<version>;当下游工具靠指纹识别文件生成方来分流支持请求时,就覆盖它。如果这些都不适用,索性别用选项对象。调用 SaveAs(FileName, xlsxOpenDocumentSpreadsheet) 等同于用默认值调 SaveAsODS,而两者的流重载都能让你把整个包直接写进 HTTP 响应,不留临时文件

导入路径读什么 - 又还跳过什么

在向任何人承诺往返保真度之前,先仔细读这一节。HotXLS 的 ODS 导入起初是一条只保留标量值和公式缓存结果的轻量路径,如今早已远超当初。当前的发行版会连同 styles.xml 和 settings.xml 一起读 content.xml:标量值与公式缓存结果、展开进网格的重复行列、合并区域、富文本、超链接、批注、内嵌图片和图表、继承与默认单元格样式、数字与日期格式、行高与列宽、命名区域、冻结窗格、AutoFilter 区域、数据验证以及条件格式。OpenODS 仍未转换的是完整的 OpenFormula 语义、ODS 保护密钥、批注几何信息、批注高级格式,以及电子表格内容之外的包特性

公式是最可能咬人的部分。一个 ODF 单元格并排存着两样东西:以 ODF 1.3 第 4 部分定义的 OpenFormula 方言书写的公式表达式,以及产出它的应用最后一次为它算出的值。早期发行版只读缓存值。自 HotXLS 2.88.229 起,OpenODS 也会导入表达式本身:以 of:= 或 oooc:= 前缀书写的单元格会保留其缓存值,并得到一个规范化后的 Excel 公式,诸如 [.A1:.B1] 的方括号引用和分号参数分隔符都会转换成 Excel 语法。自 2.384.32 起,导入还能转换内联数组行({1;2|3;4} 变成 {1,2;3,4})和 ~ 引用联合操作符;自 2.384.55 起,这样的联合读回时不再多出一对括号。把 OpenFormula 翻译成 Excel 语法仍然是个方言转换问题,在函数词汇、引用语法和错误模型上都有真实的边缘情况——这正是缓存值随行的原因:你导入的数字,恰恰就是发送方最后看到的数字

要围绕它做设计的失败模式也随之收窄。在 2.88.229 之前,一份在 LibreOffice 最后保存时合计正确的表,导入后数字是对的,但那些数字已经成了常量:改一个输入单元格再重算,什么都不会动。在当前发行版上,公式会以活公式形态回来,于是剩余的风险只剩翻译器没有建模的构造——它可能让某个公式重算出与产出应用不同的结果,而缓存值显示的仍是发送方的结果。把你流水线依赖的那些公式重算一遍,与导入的缓存值对比,凡是不一致的,就按你自己的业务规则通过 Cell.Formula 重建,该属性在 XLSX 门面上接受不带前导等号的表达式

围绕残留的往返缺口来做设计

导出是从完整的内存工作簿模型渲染出来的:值、公式、样式,以及在你要求时的图表和图像。公式导出在逐个发行版里持续收紧。自 2.384.55 起,内联数组、引用联合和绝对引用在 OpenFormula 里保持原义;自 2.384.56 起,HotXLS 为其公式前缀声明命名空间,LibreOffice 因此会真正计算公式,而不是在每个公式单元格里显示 #VALUE!;自 2.384.65 起,整列与整行引用按 OpenFormula 形式 [.A:.A] 写出,Excel 打开时也就不再报 #NAME?。条件格式也跟了上来:自 2.384.66 起,基于公式的规则连同其基准单元格一起写成 formula-is(...),LibreOffice 认得这种形式;自 2.384.69 起,值规则和公式规则还会写成标准的 style:map 条件——那是 Excel 唯一读的形式——而导入一侧两种形式都读。于是在当前发行版上,一次完整的 .xlsx 到 .ods 再到 .xlsx 的循环能保住值、公式、样式和条件格式;仍可能丢掉的东西,就落在上一节列出的那些缺口里

示意图:从 Delphi 出发的 HotXLS ODS 往返,导出自内存工作簿模型,导入在 HotXLS 2.88.229 之前则只取值、把公式留成常量
早期发行版只导入值和缓存结果,一次完整的 .xlsx 到 .ods 再到 .xlsx 循环因此丢掉样式和活公式;当前发行版两者都能读回
Book := TXLSXWorkbook.Create;
try
  Book.Open('vendor-revision.ods');          // 格式自动检测
  if Book.SourceFormat = xlsxOpenDocumentSpreadsheet then
  begin
    // 自 HotXLS 2.88.229 起,公式会以活公式形态随缓存
    // 结果一起回来;在保存之前,先把下游流水线无法
    // 放心当作已正确翻译的公式重建出来。
    Book.Sheets[0].Cells[2, 5].Formula := 'SUM(B2:D2)';
    Book.SaveAs('vendor-revision.xlsx');
  end;
finally
  Book.Free;
end;

由此落出的架构模式是:把进来的 .ods 文件当作数据馈送,而不是要就地编辑的文档。把权威工作簿保持为 .xlsx,从客户修订里读出值,需要时再从权威副本生成新的 ODS。验证要在两个阵营里都做 - 在 LibreOffice Calc 这个 ODF 参考消费方里打开导出的文件,也在 Excel 里打开,后者读 ODS 已有多年,但在图表和样式支持的边缘处与 LibreOffice 意见不一。表数量、若干关键单元格和图表是否存在,就足以构成每种导出配置的冒烟检查

在决定导入之前先给 ODS 文件分诊

当一个端点接受上传时,列出工作表名比完整解析便宜得多,还能及早抓到结构上的意外:

示意图:Delphi 中的 HotXLS 上传分诊关卡,GetODSSheetNames 在完整导入之前就拒绝不可读的 ODS 包和缺失的工作表
一次 GetODSSheetNames 探测的代价远低于完整解析,还能在错误消息仍能点名文件时抓到改名工作表这类故障
Names := TStringList.Create;
Book := TXLSXWorkbook.Create;
try
  if Book.GetODSSheetNames('incoming.ods', Names) <= 0 then
    raise Exception.Create('not a readable ODS package');
  if Names.IndexOf('Data') < 0 then
    raise Exception.Create('revision is missing the Data sheet');
finally
  Book.Free;
  Names.Free;
end;

返回值约定会绊倒不少人:HotXLS 的调用通常在成功时返回一个正数计数或 1,失败时返回 -1,并在失败时清空列表,所以要检测 <= 0,而不是拿它跟某个特定正值比较。GetODSSheetNames 既不重置也不填充工作簿实例,因此单个探测对象就能审查一整个目录的来件。这类结构检查能在关卡处抓住现实中最常见的故障 - 分析师在把修订发回之前重命名或删除了某张表 - 而在关卡处,错误消息还能点出文件名和缺失的表名,不至于到三层之下才以空引用的形式冒出来

如果你要围绕这些搭一条更宽的转换流水线,工作簿审计与转换工作台模式展示了如何在选定目标格式之前先盘点一个文件的功能,而大型工作簿性能指南能让批量导出留在合理的内存边界内

HotXLS 是一个带完整源码的原生 Delphi 与 C++Builder 电子表格库;完整功能清单与授权详情见 HotXLS Delphi Component 产品页