技术文章

Delphi 中别让 XLS 保存悄悄重算公式

HotXLS 是 Delphi 和 C++Builder 的原生 Excel 库,它保存经典 BIFF8 .xls 工作簿时改成缓存优先:TXLSWorksheet.WriteFormula 先向 TXLSWorkbook.TryGetCachedFormulaValue 要 Excel 存在每条公式旁边的值,只有当缓存缺失或已失效时才调用求值器。你打开过却从未碰过的工作簿会把同样的数字写回去,而想拿到新结果得显式调用一次 Recalculate,不用再承受 SaveAs 顺手重算这种隐形副作用

把这个约定逼上台面的 bug 小得令人难堪。语料里有个叫 nested-subtotals.xls 的文件,R2C4 里放着总计,缓存值是 37。用 HotXLS 打开,向 TryGetCachedFormulaValue 询问这个单元格,得到 37。一个单元格都不改就保存,打开保存后的副本,问同样的问题,得到 67。API 里没有任何东西被要求做计算,可文件里的一个数字正好动了 30——而 30 恰好是总计所覆盖范围内那两个分组小计 10 和 20 的和

为什么保存一次 XLS 文件会改变公式的值?

37 变成 67 需要两个互不相关的缺陷刚好凑到一起,只修其中一个都会把另一个盖住。第一个是结构性的:经典写入器在每次保存时重算每条公式。第二个是一个对从磁盘加载的公式永远不可能为真的类型判断,它让求值器把嵌套的 SUBTOTAL 单元格数了两遍。那个语料文件只是第一个「保存时重算给出的答案与 Excel 不同、而且有人把两者比过」的输入。结构缺陷说起来很简单:在 v2.382.3 之前,TXLSWorksheet.WriteFormula 及其共享公式版本 WriteFormulaWithTExp 是通过调用 TXLSWorkbook.GetFormulaValue(也就是求值器)来取得每条 Formula 记录里那八个字节的 FormulaValue 字段的。ParseFormula 在加载时从源文件里仔细解出来的缓存,在写出去的路上从来没被问过。实际上,每次保存都成了一次绕过工作簿级重算 API 的完整重算,所以你在工作簿上设什么都不可能拦住它。凡是 HotXLS 求值器与 Excel 不一致的地方——无论是合法地不支持某个函数,还是单纯的 bug——都会在保存时变成静默的数据变更

第二个缺陷住在求值器用到的嵌套小计回调里。Excel 规定所有 SUBTOTAL 形式都忽略自身公式也是 SUBTOTAL 的单元格,所以 lxCalc.pas 里的计算器在聚合期间会打开 FIgnoreSubtotalCells,并通过 TXLSWorkbook.GetClassicIsSubtotalCell 询问范围内每个单元格是不是这种。那个回调把公式文本取成 Variant,然后用 VarType(f) = varOleStr 判断。而 GetUnCompiledFormula 返回的文本是 Delphi 的 String,赋给 Variant 的 String 是 varUString,永远不会是 varOleStr。于是这个谓词对每个已加载文件里的每个单元格都为假,分组小计被第二次滚进总计,而在一次重算一切的保存里,10 + 20 + 7 变成了 67

// HotXLS 2.381 及更早:由 String 构建的公式 Variant
// 是 varUString,所以这个比较永远不会成功
Result := (VarType(f) = varOleStr) and
  (SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
   SameText(Copy(f, 1, 10), '=SUBTOTAL('));

// HotXLS 2.382.0:VarIsStr 接受 varString、varOleStr 和 varUString,
// 并且像 Excel 那样把 AGGREGATE 排除在外层小计之外
if VarIsStr(f) then
  Result := SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
    SameText(Copy(f, 1, 10), '=SUBTOTAL(') or
    SameText(Copy(f, 1, 10), 'AGGREGATE(') or
    SameText(Copy(f, 1, 11), '=AGGREGATE(');

v2.382.0 交付了 VarIsStr 修复,并顺手在同一个函数里告诉回调:AGGREGATE 单元格同样被排除在外层小计之外。光这一条就让语料断言通过了,因为重算出来的 37 现在与加载的 37 一致。但这并没有让这个库变得诚实:保存时它仍然在重算,测试之所以变绿只是因为求值器恰好在那一个文件上与 Excel 意见一致。SUBTOTAL 和 AGGREGATE 跳过哪些单元格(包括隐藏行)的规则,SUBTOTAL 与 AGGREGATE 隐藏行那篇有讲;这里关键在于:对一个你没让它去算的文件,任何求值器都不该有投票权

Excel 对保存时的缓存值给了什么保证?

Excel 把保存当作一次快照,而不是一次计算事件。写进 Formula 记录的 FormulaValue 字段([MS-XLS] §2.4.127,布局见 §2.5.133)的值,就是单元格当前显示的东西——在手动计算模式下它可能已经过期好几年,Excel 照样忠实地写进去。重算是另一项操作,有自己的触发条件。HotXLS 现在对经典保存遵守同一条规则:WriteFormula 和 WriteFormulaWithTExp 先调用 TryGetCachedFormulaValue,当状态是 xlfcsLoaded 或 xlfcsCalculated 时取 CacheInfo.Value,只有碰上 xlfcsMissing 和 xlfcsInvalidated 才落到 GetFormulaValue。这个约定的读取侧——包括每个状态的含义,以及为什么缓存下来的空值或 False 也算一个值——在在 Delphi 中读取 Excel 缓存的公式值而不重算那篇里有描述

HotXLS 中每次经典 XLS 保存所做的缓存优先判定:WriteFormula 和 WriteFormulaWithTExp 调用 TryGetCachedFormulaValue,状态为 xlfcsLoaded 或 xlfcsCalculated 时逐字写出 CacheInfo.Value,xlfcsMissing 或 xlfcsInvalidated 时退回 GetFormulaValue 求值器,而求值失败时写一个零载荷并置上 fAlwaysCalc,让 Excel 在打开时重算
本次会话里赋值的公式没有缓存,被替换的公式会被标为失效,所以这两类在保存时仍然求值,生成的工作簿打开就有数字;而你打开过、从未碰过的文件则保留 Excel 存下的值

兜底路径是有意保留而不是删掉的。你在本次会话里通过 Cells[Row, Col].Formula 赋值的公式没有缓存;你在已加载单元格上替换掉的公式会被 _SetCompiledFormula 标成 xlfcsInvalidated;这两类在保存时都照旧求值,所以生成的工作簿在 Excel 里打开时仍然有数字。当连求值器都产不出值时,写入器会输出一个零载荷并置上 fAlwaysCalc(§2.4.127 里 grbit 的第 0 位),让 Excel 在打开时重算该单元格,而不是相信这个占位值

procedure RoundTripWithoutRecalc(const Source, Target: string);
var
  Book: TXLSWorkbook;
  Before, After: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open(Source);
    // 工作表、行、列都从 1 起算:第一个工作表上的 R2C4
    if not Book.TryGetCachedFormulaValue(1, 2, 4, Before) then
      raise Exception.Create('R2C4 carries no usable cache');
    Book.SaveAs(Target);        // 有缓存的单元格完全不碰求值器
  finally
    Book.Free;
  end;

  Book := TXLSWorkbook.Create;
  try
    Book.Open(Target);
    Book.TryGetCachedFormulaValue(1, 2, 4, After);
    // 对 nested-subtotals.xls,Before.Value = After.Value = 37
    // 一次会重算的保存在这里会写进 67
  finally
    Book.Free;
  end;
end;

BIFF 共享公式的根单元格把缓存值放在哪?

和别的公式单元格一样,放在它自己的 Formula 记录里——而正是这一点让共享组的根单元格成了缓存优先保存唯一还会丢东西的地方。BIFF8 里的共享公式存成一条 ShrFmla 记录([MS-XLS] §2.4.260),跟在左上角单元格那条 Formula 记录后面;包括根在内的每个成员单元格,其 rgce 都只由一个 PtgExp token 构成(§2.5.198):解析出的表达式第一个字节是 $01,后面跟着根单元格的行和列。从属单元格是自包含的——HotXLS 读取各自的 FormulaValue,并通过查根单元格的编译公式来解析表达式。根单元格不一样,因为解析它的 Formula 记录时表达式还不存在,要晚一条记录才到

缓存就丢在那一条记录的间隙里。TXLSReader.ParseFormula 解出缓存值,一旦看到坐标与单元格自身相等的 PtgExp,就把这个单元格记在 FSharedFormulaRow 和 FSharedFormulaCol 里,并把缓存发布到该单元格。等 ShrFmla 记录($04BC)到达时,ParseSharedFormula 编译表达式并用 _SetCompiledFormula 装上去,而 _SetCompiledFormula 对任何公式变更都会做它该做的事:清掉 FCachedFormulaValue 并把状态重置为 xlfcsMissing。于是根单元格加载进来的 37 在任何人读到之前就被扔掉了,TryGetCachedFormulaValue 把根报成无缓存,缓存优先的写入器尽职地退到求值器——偏偏就是大家正盯着看的那个单元格。Array 记录(§2.4.4)有同样的记录顺序,也就有同样的漏洞

v2.382.3 的修复在待处理的根坐标旁边加了第三个字段 FSharedFormulaCachedValue。ParseFormula 认出根时把解出的缓存暂存在那里,ParseSharedFormula 和 ParseArrayFormula 在装好编译表达式之后立刻通过 _SetCellCachedFormulaValue 把它重放回去,然后把暂存重置为 Unassigned。缓存的 String 变体完全不受这些影响,因为它的载荷在单独一条 String 记录里到达,按单元格坐标而不是记录顺序路由。如果你在处理同一个概念的 OOXML 那一侧,XLSX 共享公式 si 展开那篇解释了为什么包格式没有等价的记录顺序问题,却有自己的展开陷阱

HotXLS 里 BIFF 共享公式的根单元格为什么丢了缓存的 37:Formula 记录带着 PtgExp token 和解出的缓存,ShrFmla 表达式晚一条记录才到,而通过 _SetCompiledFormula 装上它会把状态重置成 xlfcsMissing;直到 2.382.3 开始暂存 FSharedFormulaCachedValue 并通过 _SetCellCachedFormulaValue 重放回来
Array 记录也有同样的一条记录间隙,ParseArrayFormula 用同样的方式重放暂存值;而 String 缓存变体按单元格坐标路由,从一开始就不依赖记录顺序

为什么共享公式的从属单元格需要相对位移?

因为 ShrFmla 里存的表达式是相对根单元格写的,逐字复用它会让从属单元格去算根单元格的引用,而不是自己的。旧的 reader 给每个从属单元格装的是 Value.GetCopy(),一份没有任何位移的深拷贝,于是以 B1 为根、内容是 =A1*3 的组,会让每个从属单元格也都是 =A1*3。缓存优先保存其实把这个问题对已加载文件掩住了,因为从属单元格有自己的 FormulaValue,正确保存根本不需要那个表达式;只要有东西一重算,它就冒出来了。reader 现在装的是 TXLSCompiledFormula.GetCopy(row - srow, col - scol),它会遍历语法树、把每个相对引用按从属单元格到根的距离做偏移,于是 B2 上的从属单元格拥有一个货真价实的 =A2*3

HotXLS 里共享公式的从属单元格需要相对位移:以 B1 为根、内容是 =A1*3 的组,输入为 2、4、6,过去逐字装 Value.GetCopy,于是 B2 重算的是 A1*3、显示 6,而 Excel 显示 12;改用按从属单元格偏移量位移的 GetCopy 之后,B2 拥有 =A2*3,B3 拥有 =A3*3
缓存优先保存把这个 bug 对已加载文件掩住了,因为每个从属单元格都带着自己的缓存值,所以只有显式 Recalculate 才能让它现形;回归测试故意种下错误的缓存 999 和 888,它们必须挺过一次保存

钉住这两处行为的回归测试值得一读,因为它拒绝让巧合蒙混过关。它构建一个带 =A1*3 和 =A2*3 的工作簿,输入是 2 和 4,然后通过 _SetCellCachedFormulaValue 注入故意写错的缓存 999 和 888,UseSharedFormulas 开一次、关一次。保存并重新加载之后,两个单元格必须仍然报 999 和 888——这证明保存既没碰根缓存也没碰从属缓存。只有在显式 Recalculate 之后它们才必须变成 6 和 12——这证明从属单元格的位移表达式是对的。种真值的测试在旧写入器下也会通过,这正是要种错值的原因

var
  Book: TXLSWorkbook;
  Info: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('quarterly-model.xls');
    Book.Sheets[1].Cells[1, 1].Value := 5;   // 改一个输入

    // 依赖公式的已加载缓存不会因为改了一个字面量而失效,
    // 所以直接 SaveAs 会保留旧数字。
    // 真正想要新结果时,请显式要求重算:
    Book.Recalculate;

    if Book.TryGetCachedFormulaValue(1, 1, 2, Info) then
      Writeln('B1 now ', VarToStr(Info.Value),
        ', state ordinal ', Ord(Info.State));   // xlfcsCalculated
    Book.SaveAs('quarterly-model-updated.xls');
  finally
    Book.Free;
  end;
end;

缓存优先这个约定不替你做什么

缓存优先保存保住的是加载进来的东西,它不跟踪加载进来的东西是否仍然成立。改动某条公式依赖的字面量,会在求值器那边把依赖图标记为脏,但依赖单元格的 xlfcsLoaded 缓存原封不动,经典写入器会高高兴兴地把那个过期值写出去——除非你先调用 Recalculate,或者先读一下该单元格的 Value(这会把它算出来并把状态推进到 xlfcsCalculated)。这和 Excel 在手动计算模式下做的取舍一样,对「打开第三方文件、改几个标签、保存」这种流水线来说是正确选择——但它意味着会改输入的工作簿必须自己显式负责重算那一步。XLSX 写入器的 RecalcBeforeSave 策略不受这次改动影响,它有自己的手动模式,精神上同样是保留缓存。由此还有两条更小的边界:缓存优先这条路径只帮得到状态为 xlfcsLoaded 或 xlfcsCalculated 的单元格;一个只写公式、从不求值的生成器在保存时仍然要为每个单元格付一次求值,和以前一模一样。而嵌套小计的修复纠正的是求值器跳过哪些单元格,不是求值器实现的每一个函数——一个公式无法被 HotXLS 算得与 Excel 完全相同的文件,现在原样往返是安全的,但对它刻意调用一次 Recalculate 仍会得出这个库的答案而不是 Excel 的答案;在信任一次重算保存之前,你应该先把两者比一比

经典保存的缓存优先、恢复的共享公式与数组公式根缓存、共享从属单元格的相对引用位移,以及修正后的 SUBTOTAL 与 AGGREGATE 嵌套规则,都在面向 Delphi 和 C++Builder 的标准 HotXLS Delphi Spreadsheet Component 里交付,完全不依赖 Excel 或任何 OLE 自动化服务器;产品页上有本文用到的工作簿、缓存读取和重算入口的完整 API 参考