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 对保存的处理一致:保存的是每个单元格当前显示内容的快照——在手动计算模式下可能严重过时——也照样忠实写出
求值路径留在那是有正当理由的。你在代码里经 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 记录里、按行与列匹配到单元格,它在流里的位置无关紧要
如果你也处理 OOXML 侧,XLSX 共享公式 si 展开一文解释了包格式为何没有记录顺序间隙、却自带展开陷阱
共享公式的从属单元格拥有一份位移过的表达式副本
ShrFmla 记录里的表达式是相对根单元格书写的,所以每个从属都需要一份按自己与根的距离位移过的副本。逐字复制的话,根在 B1、公式 =A1*3 的组会把同样的 =A1*3 交给 B2 和 B3。HotXLS 给每个从属一份相对引用按从属的行列距离偏移过的副本,于是 B2 真正拥有 =A2*3,B3 拥有 =A3*3
这也是为什么普通的往返测试无法告诉你从属表达式对不对。从属在保存中保住加载值,逐字副本照样能过。能证明问题的检查必须改一个输入再重算。下面的例程在 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、缓存读取与重算调用