技术文章

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

Excel 显示为 37 的总计,在一个程序打开 .xls 文件、一个单元格都没改就保存之后,可能变成 67。多出的 30 不是随机的,恰好是总计覆盖范围里那两个分组小计 10 和 20 的和。要让这个数字移动,得两件事同时出错:保存必须在写出路上重算每条公式,而执行重算的那一方必须忘了 SUBTOTAL 会跳过其他 SUBTOTAL 单元格。去掉任何一条,文件都能活下来——这正是第一个问题能藏在第二个背后不被发现的原因

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

当写出器把存储在每条公式旁边的数字算出来、而不是照抄加载进来的那个时,保存就改变了公式的值。每条 BIFF8 Formula 记录都带一个八字节的 FormulaValue 字段([MS-XLS] §2.4.127,值布局见 §2.5.133),装着 Excel 最后为该单元格显示的结果。写出器必须往这八个字节里放点什么,而最短的代码路径就是把每条公式交给计算引擎。一旦这么做,每次保存都变成一次没人要求的完整重算,引擎与 Excel 分歧的每一处都变成一个用户以为没动过的文件里被改掉的数字

SUBTOTAL 的分歧是个好例子,因为表格规则很简单,陷阱却藏在 Delphi 的 Variant 类型里。Excel 定义每一种 SUBTOTAL 函数号都忽略自身公式是另一条 SUBTOTAL 的单元格。对一张明细行合计 37 的表(两组分别小计 10 和 20 的行,再加组外的 7),总计就是 37。认不出嵌套小计的引擎把 10 和 20 再数一遍,落在 67 上。认出它们通常意味着对公式文本做一小段检查,而显然的 Delphi 写法能编译、能运行、永远匹配不上任何东西:

// 自己写的辅助函数,比如在扫描一张表、
// 找出外层 SUBTOTAL 必须跳过的公式时
function IsNestedSubtotal(const F: Variant): Boolean;
begin
  // 诱人的写法在 Unicode Delphi 上永远不成立:赋给
  // Variant 的 String 是 varUString,不是 varOleStr
  //   Result := (VarType(F) = varOleStr) and ...

  Result := False;
  if VarIsStr(F) then  // 同时接受 varString、varOleStr 和 varUString
    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(');
end;

// 在 HotXLS 中的用法(表、行、列都从 1 起):
//   if IsNestedSubtotal(Book.Sheets[1].Cells[12, 4].Formula) then ...

VarIsStr 正是 HotXLS 求值器对同一问题采用的测试,它也照 Excel 的方式把 AGGREGATE 单元格排除在外层 subtotal 之外。不过修好谓词还不是全部。检查正确时,重算的保存之所以写出 37,只是因为引擎恰好在这份特定文件上与 Excel 一致;引擎下一个处理得略有不同的公式还是会移动。SUBTOTAL 和 AGGREGATE 跳过哪些单元格(包括隐藏行)的规则,见SUBTOTAL 与 AGGREGATE 隐藏行一文。而有了缓存优先保存,一份你从没要求计算的文件会带着单元格里的 37 回到磁盘,无论引擎本来会把它算成什么

经典 XLS 保存往每条 Formula 记录里写什么

HotXLS 保存 BIFF8 工作簿时缓存优先:对每个公式单元格,只要 TryGetCachedFormulaValue 报告有缓存值,TXLSWorkbook.SaveAs 就写缓存值,只在没有时才运行求值器。缓存状态说了算。状态为 xlfcsLoaded(值来自源文件)或 xlfcsCalculated(HotXLS 在本次会话算出的)的单元格,其 CacheInfo.Value 逐字节写出。状态为 xlfcsMissing 或 xlfcsInvalidated 的单元格在保存时求值。这与 Excel 对保存的处理一致:保存的是每个单元格当前显示内容的快照——在手动计算模式下可能严重过时——也照样忠实写出

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

求值路径留在那是有正当理由的。你在代码里经 Cells[Row, Col].Formula 赋的公式还没有缓存;给已加载的单元格赋新公式会把它移到 xlfcsInvalidated,因为旧结果属于旧表达式。两类都在保存时计算,所以从零生成的工作簿在 Excel 里打开显示的是算好的数字而不是零。连求值器都产不出值时(不支持的函数、坏掉的外部链接),写出器存零并置上 fAlwaysCalc——§2.4.127 记录标志的第 0 位——让 Excel 打开时重算该单元格,而不是信任占位值

有一个属性看着相关,其实不是。TXLSWorkbook.RecalcOnSave 对应 Excel「保存前重算工作簿」选项,来自 CalcSaveRecalc 记录([MS-XLS] §2.4.37)。它只对 Excel 有意义、只在手动计算模式下,HotXLS 只是把它写进文件。SaveAs 决定 FormulaValue 里放什么时并不咨询它。下面的往返测试是在你自己的文件上看清这份契约的最快办法:

procedure CheckRoundTrip(const Source, Target: string;
  Row, Col: Integer);
var
  Book: TXLSWorkbook;
  Before, After: TXLSFormulaCacheInfo;
begin
  Book := TXLSWorkbook.Create;
  try
    if Book.Open(Source) <> 1 then
      raise Exception.Create('Cannot open ' + Source);
    // 表、行、列都从 1 起
    if not Book.TryGetCachedFormulaValue(1, Row, Col, Before) then
      raise Exception.Create('Cell carries no usable cached value');
    if Book.SaveAs(Target) <> 1 then   // 缓存单元格按存储原样写出
      raise Exception.Create('Save failed');
  finally
    Book.Free;
  end;

  Book := TXLSWorkbook.Create;
  try
    Book.Open(Target);
    Book.TryGetCachedFormulaValue(1, Row, Col, After);
    // 期望:After.Value 等于 Before.Value,且 After.State
    // 又是 xlfcsLoaded,因为它来自保存出的文件
    Writeln(VarToStr(Before.Value), ' -> ', VarToStr(After.Value));
  finally
    Book.Free;
  end;
end;

这份契约的读取侧——每种状态的含义、以及缓存的空值或 False 为什么也算值——见在 Delphi 中不重算读取 Excel 缓存公式值。对带嵌套小计的工作簿的总计单元格跑上面的检查,两行会打印同一个数字

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

放在它自己的 Formula 记录里,与其他公式单元格一样——但表达式晚一条记录才到,这个间隙就是粗心的阅读器丢缓存的地方。BIFF8 把共享公式存为 ShrFmla 记录([MS-XLS] §2.4.260),紧跟在组的左上角单元格的 Formula 记录之后。每个成员单元格(包括根)的 rgce 都由单个 PtgExp token(§2.5.198)构成:字节 $01 后跟根的行与列。从属单元格好办,等它们出现时根的表达式已经存在,它们自己的 FormulaValue 也装在各自的记录里。根不好办:它的 Formula 记录被读取时,带着缓存值 37、还没有表达式

给单元格安装表达式会清掉它的缓存,这一点必须保持成立,因为被替换的公式确实让旧结果失去意义。但把同一条规则盲目套在 ShrFmla 记录上,表达式一到就把根的 37 扔掉,TryGetCachedFormulaValue 报告根为 xlfcsMissing,保存恰好在用户最可能看的那个单元格上回退到求值器。HotXLS 阅读器在等 ShrFmla 记录期间先持有根的已解码值,安装表达式,然后以 xlfcsLoaded 状态把加载值放回去。数组公式(Array 记录,§2.4.4)遵循同样的记录顺序、得到同样的处理。缓存的 String 形式从来没有这个问题,因为它的文本装在单独的 String 记录里、按行与列匹配到单元格,它在流里的位置无关紧要

BIFF 共享公式的根单元格如何在 HotXLS 里保住缓存的 37:根的 Formula 记录带着 PtgExp token 和已解码的缓存,ShrFmla 表达式晚一条记录到达,安装表达式会把缓存重置为 xlfcsMissing,所以阅读器先持有加载值,在表达式装好后立刻恢复
Array 记录有同样的一条记录间隙,阅读器以同样方式恢复根值;String 缓存变体按单元格坐标匹配,从不依赖记录顺序

如果你也处理 OOXML 侧,XLSX 共享公式 si 展开一文解释了包格式为何没有记录顺序间隙、却自带展开陷阱

共享公式的从属单元格拥有一份位移过的表达式副本

ShrFmla 记录里的表达式是相对根单元格书写的,所以每个从属都需要一份按自己与根的距离位移过的副本。逐字复制的话,根在 B1、公式 =A1*3 的组会把同样的 =A1*3 交给 B2 和 B3。HotXLS 给每个从属一份相对引用按从属的行列距离偏移过的副本,于是 B2 真正拥有 =A2*3,B3 拥有 =A3*3

共享公式的从属在 HotXLS 中需要相对位移:根在 B1、公式 =A1*3、输入 2、4、6 的组,若逐字复制根表达式,B2 会重算 A1*3 并在 Excel 显示 12 的地方显示 6;按从属偏移平移的副本让 B2 拥有 =A2*3、B3 拥有 =A3*3
对加载文件,缓存优先保存把陷阱藏起来,因为每个从属都带着自己的缓存值,只有编辑输入后显式 Recalculate 才让它现形

这也是为什么普通的往返测试无法告诉你从属表达式对不对。从属在保存中保住加载值,逐字副本照样能过。能证明问题的检查必须改一个输入再重算。下面的例程在 UseSharedFormulas 打开的情况下写一个共享组(默认为 False,即每单元格一条 Formula 记录),重新打开文件、改 A2、盯着 B2:

procedure CheckSharedFollower(const FileName: string);
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Info: TXLSFormulaCacheInfo;
  Row: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    for Row := 1 to 3 do
    begin
      Sheet.Cells[Row, 1].Value := Row * 2;                 // 2, 4, 6
      Sheet.Cells[Row, 2].Formula := '=A' + IntToStr(Row) + '*3';
    end;
    Book.UseSharedFormulas := True;   // B1:B3 变成一个 ShrFmla 组
    Book.SaveAs(FileName);            // 还没有缓存:求值成 6、12、18
  finally
    Book.Free;
  end;

  Book := TXLSWorkbook.Create;
  try
    Book.Open(FileName);
    Book.Sheets[1].Cells[2, 1].Value := 10;   // 改从属的输入

    Book.TryGetCachedFormulaValue(1, 2, 2, Info);
    Writeln('B2 before: ', VarToStr(Info.Value));   // 期望:12,仍是加载值

    Book.Recalculate;
    Book.TryGetCachedFormulaValue(1, 2, 2, Info);
    Writeln('B2 after: ', VarToStr(Info.Value));    // 期望:30,来自 =A2*3
  finally
    Book.Free;
  end;
end;

复用了 =A1*3 的从属会在第二行打印 6。第一行同样要紧:它显示编辑一个字面量不会动依赖公式的加载值,直到有什么东西重算

缓存优先保存留给你的是什么

缓存优先保存保留加载到的东西,但不追踪加载到的东西是否仍然为真。改动公式依赖的单元格会把依赖图标记给下一次重算,但依赖单元格保住它的 xlfcsLoaded 值,SaveAs 会写出那个陈旧数字,除非你先调 Recalculate、或读取单元格的 Value——后者会计算公式并把状态推进到 xlfcsCalculated。Recalculate 返回它无法求值的公式单元格数量,所以结果非零时值得在保存前记进日志

常见的管线是程序打开别人构建的工作簿、填几个标签或一个表头、保存。这个文件里什么都不该变值,缓存优先保存下也确实什么都不变。编辑输入的程序则由自己拥有重算步骤,这一步应该放在紧挨编辑的一处可见的 Recalculate 调用里,让任何读代码的人都看得见。Excel 在手动计算模式下做的是同样的取舍。XLSX 写出器有自己的策略:RecalcBeforeSave,带 xrmManual、xrmIfDirty 和 xrmAlways 三种模式,设在 XLSX 工作簿类上,与上面的一切无关

设计带来两条边界。缓存路径只帮到状态为 xlfcsLoaded 或 xlfcsCalculated 的单元格,所以写出几千条新公式却从不求值的生成器,保存时仍要为每个单元格付一次求值。而且这份契约管的是保存写什么,不是显式重算算什么:原样保存的文件保住 Excel 存的数字,而 Recalculate 写入 HotXLS 求值器的结果。把管线切到重算保存之前,先在几份有代表性的工作簿上把两者对一对

HotXLS Delphi Spreadsheet Component 在 Delphi 和 C++Builder 里读写经典 BIFF8 与 XLSX 工作簿,无需安装 Excel 或 OLE 自动化。其产品页文档收录了这些示例用到的 workbook、缓存读取与重算调用