技术文章

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

一个多年来一直输出 .xlsx 的 Delphi 报表后端接到了新需求:某个公共部门客户的采购规定要求输出 OpenDocument 电子表格,而该客户的分析师把改好的内容以 LibreOffice 保存的 .ods 文件发回来。于是同一套代码现在既要写 ODS 也要读 ODS。HotXLS 是 losLab 面向 Delphi 与 C++Builder 的原生 Object Pascal 电子表格库,两个方向它都能处理,且任何地方都不需要装 Excel 或 LibreOffice。它没有做到的,是让这两个方向对称。导出携带的东西远多于导入能恢复的,而一个假定两者对等的团队,会眼看着公式和格式在客户的修订与下一份报表之间某处蒸发掉,且没有任何错误可指

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

HotXLS 在一个包里发布两套彼此独立的类层次:lxHandle 单元中的 TXLSWorkbook 处理二进制 BIFF8 的 .xls 文件,lxHandleX 单元中的 TXLSXWorkbook 处理 OOXML 的 .xlsx 包。每一个 OpenDocument 入口 - OpenODSSaveAsODSGetODSSheetNames - 都挂在 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 导入是一条刻意做轻的路径。它保留标量单元格值以及每个公式在保存时携带的缓存结果,并把重复的行和列展开进网格。它不带入样式、ODS 公式表达式或图形

关于公式的取舍最可能咬人,而它是有意为之的。一个 ODF 单元格并排存着两样东西:以 ODF 1.3 第 4 部分定义的 OpenFormula 方言书写的公式表达式,以及产出它的应用最后一次为它算出的值。把 OpenFormula 翻译成 Excel 公式语法本身就是一个方言转换问题,在函数词汇、引用语法和错误模型上都有真实的边缘情况。改读缓存值则绕开了整整一类静默误译,因此你导入的数字,恰恰就是发送方最后看到的数字。代价是它们以数字的形态到达,而不是产出它们的活公式

要围绕它做设计的失败模式由此直接推出:一份在 LibreOffice 最后保存时合计正确的表,导入后数字是对的,但那些数字如今是常量。改一个输入单元格再重算,什么都不会动 - 公式已经没了,只剩它最后的结果。如果工作流在导入后需要活的公式,就按你自己的业务规则通过 Cell.Formula 以编程方式重建它们,该属性在 XLSX 门面上接受不带前导等号的表达式

围绕不对称的往返来做设计

导出是从完整的内存工作簿模型渲染出来的:值、样式,以及在你要求时的图表和图像。导入只返回值。所以 .xlsx 到 .ods 这一程是高保真的,而 .ods 到 .xlsx 这一程带回值和缓存结果,却没有样式,也没有活的公式。把两程串起来,不对称就会叠加。一次完整的 .xlsx 到 .ods 再到 .xlsx 的循环,出去时忠实写下一切,回来时丢掉样式和公式,尽管两步各自都没出错

示意图:从 Delphi 出发的 HotXLS ODS 往返并不对称,导出自内存工作簿模型全保真,导入则只取值并把公式留成常量
导出渲染的是完整的内存模型,导入返回的是值和缓存结果,因此一次完整的 .xlsx 到 .ods 再到 .xlsx 循环会悄悄丢掉样式和活公式
Book := TXLSXWorkbook.Create;
try
  Book.Open('vendor-revision.ods');          // 格式自动检测
  if Book.SourceFormat = xlsxOpenDocumentSpreadsheet then
  begin
    // ODS 导入之后,值和公式的缓存结果都在;样式和活的
    // 公式则不在。在保存之前,先把下游流水线依赖的
    // 那些东西重建出来。
    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 产品页