技术文章

Delphi 中的 XLSX 无损往返:主题、extLst 与 calcChain

HotXLS 是面向 Delphi 与 C++Builder 的原生 Excel 库,它就是为无损 XLSX 往返而生的:打开一个工作簿,改一个单元格,保存,而客户的自定义主题、外来的 extLst 扩展块和计算链都能存活下来。让这一切成立的是三套机制 —— 对 xl/theme/theme1.xml 的逐字节缓存、对未知 <ext> 块基于事件的重新序列化,以及在每次保存含公式的工作簿时生成一份全新且符合规范的 xl/calcChain.xml

HotXLS 在 Delphi 中实现无损 XLSX 往返的三套机制:把 xl/theme/theme1.xml 作为原始字节缓存并逐字节写回、从 XML 事件中捕获外来 extLst 块并重放、每次保存公式工作簿时生成符合规范的 calcChain.xml
每个被保住的部件在保存时都走自己的路径 —— 逐字节的主题字节、事件级的 extLst 重放,以及重新生成的计算链 —— 而工作表 XML 与样式则由模型重建

促成这三套机制的场景常见得令人沮丧。一个计费服务加载客户在 Excel 里设计的模板 —— 企业配色主题、KPI 列里的迷你图、由更新版 Excel 添加的一条条件格式规则 —— 往单元格 B3 写入一个发票合计,然后保存。客户打开结果,发现品牌配色弹回了 Office 默认蓝,迷你图不见了,而 Excel 提议“修复”这个文件。代码里没有任何一处碰过这些功能。是库碰的,仅仅因为它保存了一次

Excel 文件为什么会在库编辑后丢失格式?

Excel 文件在库编辑后丢失格式,是因为大多数库并不编辑文件 —— 它们重建文件。一个 .xlsx 包是若干 XML 部件的 ZIP:xl/workbook.xml、每张表一个的 xl/worksheets/sheetN.xml、xl/styles.xml、xl/theme/theme1.xml、xl/calcChain.xml,还有更多。典型的库在打开时把这些部件解析进一个对象模型,保存时再从该模型重新生成每一个部件。凡是模型没有表示的功能 —— 一个它从未解析过的主题、一个来自更新版 Excel 的扩展块 —— 在内存里就无处安身,于是重新生成的部件悄悄把它省略了

ECMA-376 预见到了这个问题的一半。SpreadsheetML 定义了 extLst(ECMA-376 第 1 部分的“未来功能数据存储区”,工作簿级元素见 §18.2.10)作为指定的扩展点:更新的生成方把功能停放在那里,每一项都包在一个带 uri 属性标识该功能的 <ext> 元素中,而更老的消费方被期待保留自己看不懂的东西。迷你图、切片器和更新的条件格式类型都是这样传递的。因此,一个丢弃未知 <ext> 块的库不只是有损 —— 它违背了这套格式据以设计的向前兼容契约。评估任何电子表格库时该问的问题很直白:我改一个单元格,还有什么会跟着变

HotXLS 如何逐字节保住自定义主题?

HotXLS 保住工作簿主题的办法,是在打开时缓存 xl/theme/theme1.xml 的原始字节,保存时原样写回。主题部件(ECMA-376 第 1 部分 §14.2.7)是 DrawingML 而非 SpreadsheetML —— 配色方案、字体方案、格式方案 —— 电子表格引擎没有理由对它建立深度模型。更早的 HotXLS 版本在每次保存时都重新生成一个固定的 Office 主题,这正是上面那个“品牌配色弹回去”的故障;自 v2.89.46 起,打开的包中的主题被原始存下并原封不动地再次写出,而内置 Office 主题只为从零新建的工作簿生成。原始字节是最强的保真度保证:不解析、不重新序列化、没有任何漂移的机会

逐字节复制被有意设计为优先于编程式的主题访问。TXLSXWorkbook 暴露了 ThemeMajorFont 和 ThemeMinorFont,让你为新工作簿挑选标题和正文字体,但当打开时捕获到了逐字节主题,这些设置器对保存出的文件就不起作用 —— 往返优先。如果你真的需要更改一个既有工作簿的主题,那是一个信号:应当在 Excel 里编辑模板,而不是通过面向数据的 API。日常情形则根本不需要 API:

HotXLS 在打开时缓存 xl/theme/theme1.xml 的原始字节并在保存时逐字节写回,而按模型重建的库会重新生成默认 Office 主题,把客户的品牌配色弹回去
逐字节缓存 theme1.xml 根本不需要任何主题模型,而 ThemeMajorFont 与 ThemeMinorFont 只影响不带已捕获主题的工作簿
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('branded-invoice.xlsx');
    Book.Sheets[0].Cells[3, 2].Value := 42750.00;  // 唯一的那处改动
    Book.SaveAs('branded-invoice-out.xlsx');
    // 输出中的 theme1.xml 与输入逐字节相同
  finally
    Book.Free;
  end;
end;

保存时未知的 extLst 块会怎样?

HotXLS 会捕获它没有原生建模的每一个工作表级 <ext> 块,并把它重放进保存后工作表的 extLst 中,因此由更新版 Excel 写入的功能能完整挺过这趟往返。自 v2.131.0 起,被捕获的片段可以通过只读的 RawWorksheetExts 属性看到,它是每张 XLSX 工作表上的一个 TStringList,这让这项保证能从测试代码中被审计,而不是只能靠信任:

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  i: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('from-newer-excel.xlsx');
    Sheet := Book.Sheets[0];
    WriteLn(Format('%d foreign ext block(s) captured',
      [Sheet.RawWorksheetExts.Count]));
    for i := 0 to Sheet.RawWorksheetExts.Count - 1 do
      WriteLn(Copy(Sheet.RawWorksheetExts[i], 1, 100)); // 瞥一眼每个 uri
  finally
    Book.Free;
  end;
end;

值得知道的实现细节是,这种捕获是事件级的重新序列化,而不是原始字节复制。HotXLS 的流式 XML 读取器不暴露源偏移量,因此未知子树是在 Element、Text 和 EndElement 事件流过时由它们重建的。这种做法藏着一个经典陷阱:像 <a/> 这样的自闭合元素只会触发一个标记为空的 Element 事件,永远不会触发 EndElement,因此任何只在 EndElement 上递减的深度计数器都永远等不到子树闭合。处理好它,重建出的片段在语义上就与原件等价 —— 属性引号和自闭合形式会被规范化,所以它不是逐字节相同,但 Excel 读的是含义,不是字节。Excel 自身输出的两条特性让这种重放是安全的:Excel 会把必要的 xmlns 属性声明在 <ext> 元素上或其内部,因此每个被捕获的片段在命名空间上都是自包含的,而正是这种自包含性,让在工作簿内部或跨工作簿复制工作表只需一次普通的字符串列表赋值,就能把外来块一并带走

把 calcChain.xml 写成让 Excel 信任你的公式

只要保存的工作簿含有公式,HotXLS 就会写出 xl/calcChain.xml(计算链部件,ECMA-376 第 1 部分 §12.3.1),并在两种排序之间做选择。如果公式依赖图已经建好且是最新的 —— 你在最后一次编辑之后调用了 Recalculate —— 计算链就按完整的拓扑顺序输出,依赖项在被依赖项之前,任何循环引用成员则追加在末尾。否则单元格按文档顺序列出。两者都正确:微软关于该格式的实现说明 [MS-XLSX] 把计算链视为一个提示,Excel 会在加载时验证并重排它,因此任何完整的列表都是合法的,而 HotXLS 有意拒绝在 SaveAs 内部强行构建依赖图 —— 边的构建对单元格数是二次的,对一次百万单元格的保存而言是不可接受的隐性开销

HotXLS 在 Recalculate 已建好依赖图时按拓扑顺序输出 xl/calcChain.xml,否则按文档顺序输出;Excel 把任一完整列表都当作提示,并在加载时重排
两种排序都合法,因为 Excel 在加载时会重新验证这条链,而 HotXLS 从不在 SaveAs 内部强行做那次二次开销的图构建
Book.Open('model.xlsx');
Book.Sheets[0].Cells[10, 4].Formula := '=SUM(D2:D9)';
// 此刻保存,calcChain.xml 按文档顺序列出公式单元格。
// 调用 Recalculate 之后依赖图已经存在,因此同样的保存
// 会改为输出完整的拓扑顺序:
Book.Recalculate;
Book.SaveAs('model-out.xlsx');

为什么要在意一个 Excel 只当作建议的部件?因为它的缺席本身是个信号。有些消费方 —— 修复启发式、第三方查看器、差异比对工具 —— 期待含公式的工作簿带着一条计算链,而一个在保存时悄悄丢掉该部件的库,产出的文件会与 Excel 写出的一切都有微妙的不同。输出一条有效的计算链,能让结果留在生态系统其余部分测试过的范围之内,而这正是往返工程那安静、不起眼的内核

无损往返到哪里为止

这里诚实比一个营销勾选框更重要,所以边界值得同等篇幅。HotXLS 并不逐字节复制整个包:工作表 XML、样式、共享字符串和工作簿部件都由解析后的模型重新生成,因此输出在语义上忠实,但并非二进制相同 —— 光是 ZIP 本地头就带着新的 DOS 时间戳。被捕获的 <ext> 片段如上文所述会被规范化后回来。当存在逐字节主题时,编程式的主题字体覆盖会被忽略。而这张保护网有确定的网眼:HotXLS 原生建模的功能(比如迷你图,是被解析并重写而非盲目复制的),加上外来的 extLst 内容,加上逐字节缓存的部件。既没有被建模、又不在扩展点内的部件 —— 比如某个冷门加载项的自定义部件 —— 就落在本文这三套机制之外,所以要拿你真实的模板去测,而不是想当然

相邻的保留工作补全了整幅图景。VBA 工程和外部工作簿引用按同一套“我不建模也照样留着”的哲学挺过保存,这在关于 VBA 与外部链接保留的姊妹篇中有讲,而 docProps 中的文档属性则有自己的读写 API,不会被悄悄丢掉。评估任何电子表格库时,都跑一遍单单元格测试:打开一个功能丰富的生产工作簿,改动一个值,保存,再把解压后的各部件与原件做差异比对。除了你碰过的那张表之外还有什么变了,这比任何功能对照表都更能说明这个库的成色

本文描述的往返机制 —— 自 v2.89.46 起的逐字节主题保留、自 v2.131.0 起的外来 extLst 捕获与 calcChain.xml 输出 —— 都随当前的 HotXLS Delphi Excel Component 发布,其产品页记录了面向 Delphi 与 C++Builder 的完整 XLSX 读写功能集