一个多年来一直输出 .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 入口 - 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 属性会报告是哪个分支被触发的
用 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 的循环,出去时忠实写下一切,回来时丢掉样式和公式,尽管两步各自都没出错
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 文件分诊
当一个端点接受上传时,列出工作表名比完整解析便宜得多,还能及早抓到结构上的意外:
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 产品页