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 儲存格視為被外層小計排除。不過,修好判斷式並不是故事的全部。有了正確的檢查,重算式存檔之所以寫出 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 記錄旗標的 bit 0),讓 Excel 開檔時重算那個儲存格,而不是相信佔位值
有一個屬性看起來相關,其實無關。TXLSWorkbook.RecalcOnSave 對應 Excel「儲存前重新計算活頁簿」選項,來自 CalcSaveRecalc 記錄([MS-XLS] §2.4.37)。它只對 Excel 有意義、而且只在手動計算模式下,HotXLS 只是原樣把它寫進檔案。SaveAs 決定 FormulaValue 放什麼時,不會看這個屬性。下面的 round trip 是在自己檔案上驗證這份契約最快的方式:
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 也算一個值——在 Read Excel Cached Formula Values in Delphi Without Recalc 一文有描述。在含巢狀小計的活頁簿的總計儲存格上跑上面的檢查,兩行會印出同一個數字
BIFF 共用公式的根儲存格把快取值放在哪裡?
放在它自己的 Formula 記錄裡,跟其他公式儲存格一樣,但運算式晚一個記錄才到,而這個時間差正是粗心的讀取器丟掉快取的地方。BIFF8 把共用公式存成一條 ShrFmla 記錄([MS-XLS] §2.4.260),緊跟在群組左上角儲存格的 Formula 記錄之後。每個成員儲存格——包括根儲存格——的 rgce 都由單一個 PtgExp 記號構成(§2.5.198):位元組 $01 加上根的列與欄。追隨者儲存格好辦,它們出現時根的運算式已經存在,自己的 FormulaValue 也放在自己的記錄裡。麻煩的是根:它的 Formula 記錄被讀到時,快取值 37 已經在手,運算式卻還沒有
給儲存格裝上運算式會清掉它的快取,這條規則必須保持,因為被替換的公式確實會讓舊結果失去意義。但把同一條規則盲目套到 ShrFmla 記錄上,運算式一到就把根的 37 丟掉,TryGetCachedFormulaValue 回報根為 xlfcsMissing,存檔於是對使用者最可能盯著看的那個儲存格退回求值器。HotXLS 的讀取器在等 ShrFmla 記錄期間先把根的解碼值收著,裝好運算式後,再把載入的值放回去、狀態設為 xlfcsLoaded。陣列公式(Array 記錄,§2.4.4)遵循同樣的記錄順序,也得到同樣待遇。字串形式的快取從來沒有這個問題,因為它的文字放在獨立的 String 記錄裡、靠列與欄對回儲存格,它在串流中的位置無關緊要
如果你同時要處理 OOXML 那一側,XLSX shared formula si expansion 那篇文章解釋了套件格式為什麼沒有記錄順序的時間差、卻有它自己的展開陷阱
共用公式的追隨者持有平移過的運算式副本
ShrFmla 記錄裡的運算式是相對根儲存格寫的,所以每個追隨者都需要一份按自己與根的距離平移過的副本。照抄的話,根在 B1、=A1*3 的群組會把同一條 =A1*3 交給 B2 和 B3。HotXLS 給每個追隨者的副本,其相對參照都按追隨者的列欄距離平移,所以 B2 持有的是真正的 =A2*3,B3 持有 =A3*3
這也是為什麼單純的 round trip 無法告訴你追隨者的運算式對不對。追隨者在存檔中保留載入值,照抄的副本也能過關。能證明任何事的檢查必須改一個輸入再重算。下面的常式在 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 會回傳它算不出的公式儲存格數量,所以存檔前如果回傳非零,值得記一筆 log
常見的管線是:程式打開別人做好的活頁簿,填幾個標籤或表頭,然後存檔。這個檔案裡不該有任何值發生變化,而快取優先存檔也確實一個都不會動。反過來,會編輯輸入的程式就得自己負責重算這一步,而且這一步應該落在緊鄰編輯處、一眼就能看見的 Recalculate 呼叫裡,讓讀程式碼的人看得到。Excel 在手動計算模式下做的是同一種取捨。XLSX 寫入器有自己的策略:RecalcBeforeSave 提供 xrmManual、xrmIfDirty 與 xrmAlways 三種模式,設在 XLSX 活頁簿類別上,與上面講的一切互相獨立
這個設計帶出兩條邊界。快取路徑只幫得到 xlfcsLoaded 或 xlfcsCalculated 狀態的儲存格,所以寫出上千條新公式卻從不求值的產生器,存檔時每格還是得付一次求值。另外,這份契約管的是存檔寫什麼,不是明確重算算出什麼:沒動過的檔案保留 Excel 存的數字,而 Recalculate 寫出的是 HotXLS 求值器的結果。把管線改成重算式存檔之前,先在幾本有代表性的活頁簿上把兩種結果比一比
HotXLS Delphi Spreadsheet Component 以原生 Delphi 與 C++Builder 讀寫傳統 BIFF8 與 XLSX 活頁簿,不需要安裝 Excel 或 OLE 自動化。其產品頁面記載了這些範例用到的活頁簿、快取值與重算呼叫