技術文章

在 Delphi 中的不失真 XLSX 往返 (Round-Trip):佈景主題、extLst、calcChain

HotXLS 這個適用於 Delphi 與 C++Builder 的原生 Excel 程式庫,是為不失真 (lossless) 的 XLSX 往返而建置的:開啟活頁簿、變更一個儲存格、存檔,然後客戶的自訂佈景主題、外來的 extLst 擴充區塊以及計算鏈 (calculation chain) 全都會被保留下來。有三個機制促成了這件事——對 xl/theme/theme1.xml 的逐字 (verbatim) 快取、以事件為基礎重新序列化 (re-serialization) 未知的 <ext> 區塊,以及在每次儲存公式活頁簿時產生全新、符合規範的 xl/calcChain.xml

驅使這三項機制的場景令人沮喪地常見。一個計費服務載入客戶在 Excel 中設計的範本——企業色彩佈景主題、KPI 欄位中的走勢圖 (sparkline)、由較新版 Excel 建立的條件式格式化規則——將發票總額寫入儲存格 B3,然後存檔。客戶開啟產生的結果後,品牌色彩彈回了原廠的 Office 藍色、走勢圖不見了,而且 Excel 提議要「修復」這個檔案。程式碼中沒有任何地方動到這些功能。是程式庫動到的,僅僅因為進行了存檔

為何 Excel 檔案在被程式庫編輯後會遺失格式?

Excel 檔案在被程式庫編輯後會遺失格式,是因為大多數的程式庫並不是在編輯檔案——它們是在重建它。一個 .xlsx 套件是多個 XML 檔案部分的 ZIP 壓縮:xl/workbook.xml、每個工作表一個 xl/worksheets/sheetN.xmlxl/styles.xmlxl/theme/theme1.xmlxl/calcChain.xml 等等。一個典型的程式庫會在開啟時將這些部分解析為物件模型,並在存檔時從該模型重新產生每個部分。任何模型沒有表現出來的功能——一個它從未解析的佈景主題、一個來自較新版 Excel 的擴充區塊——在記憶體中都沒有容身之處,所以重新產生的部分會無聲無息地忽略它

ECMA-376 預見了這半邊的問題。SpreadsheetML 將 extLst (ECMA-376 Part 1, the "Future Feature Data Storage Area", §18.2.10 關於活頁簿層級的元素) 定義為指定的擴充點:較新的產生器會將功能停放在那裡,每個功能都包裝在一個 <ext> 元素中,並帶有一個識別該功能的 uri 屬性,而較舊的消費者則被期望能保留他們不了解的內容。走勢圖 (sparkline)、交叉分析篩選器 (slicer) 以及較新的條件式格式類型都是透過這種方式傳遞的。因此,一個會丟棄未知 <ext> 區塊的程式庫不僅僅是失真的——它違反了這個格式設計時所圍繞的向前相容性 (forward-compatibility) 契約。在評估任何試算表程式庫時,要提出的問題很直接:如果我變更一個儲存格,還有什麼會跟著變?

HotXLS 如何逐位元組 (byte-for-byte) 地保留自訂佈景主題?

HotXLS 保留活頁簿佈景主題的方式,是在開啟時快取 xl/theme/theme1.xml 的原始位元組,並在存檔時將它們逐字寫回。佈景主題部分 (ECMA-376 Part 1, §14.2.7) 是 DrawingML,而不是 SpreadsheetML——色彩配置、字型配置、格式配置——一個試算表引擎沒有理由去深入塑模它。早期的 HotXLS 版本會在每次存檔時重新產生一個固定的 Office 佈景主題,這正是上面「品牌色彩彈回」的失敗原因;自從 v2.89.46 以來,開啟套件的佈景主題會以原始狀態儲存並原封不動地重新輸出,內建的 Office 佈景主題只有在從頭建立活頁簿時才會產生。原始位元組是所能提供最強的保真度保證:沒有解析、沒有重新序列化,就沒有發生漂移的機會

這種逐字的複製,刻意地勝過了對佈景主題的程式化存取。TXLSXWorkbook 公開了 ThemeMajorFontThemeMinorFont,讓您可以為新活頁簿挑選標題和本文的字體,但當開啟時擷取到了逐字佈景主題,這些設定器 (setter) 就不會對存檔檔案產生任何影響——往返保真度擁有優先權。如果您真的需要修改一個現有活頁簿的佈景主題,這是一個訊號,告訴您應該在 Excel 本身去編輯範本,而不是透過以資料為導向的 API。日常的情況根本不需要 API:

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> 區塊,並在儲存工作表時將它重播 (replay) 到 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 個外來的 ext 區塊',
      [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 讀取器沒有公開來源偏移量,所以未知的子樹 (subtree) 是從串流經過的 Element、Text 與 EndElement 事件重建出來的。這種方法隱藏了一個經典的陷阱:像 <a/> 這樣的自閉合元素 (self-closing element) 只會觸發一個標記為空 (empty) 的 Element 事件,永遠不會觸發 EndElement,所以任何只在 EndElement 上遞減的深度計數器,永遠不會看到子樹關閉。處理好這點,重建的片段在語意上就會等同於原本的片段——屬性的引號和自閉合形式都被正規化 (normalized) 了,所以它不是位元組一致的,但 Excel 讀取的是意義,而不是位元組。Excel 自家輸出的兩個特性使得這項重播是安全的:Excel 會在 <ext> 元素上或內部宣告必要的 xmlns 屬性,所以每個擷取到的片段在命名空間 (namespace) 上都是自我包含的,而同樣的自我包含也是為什麼在活頁簿內部或跨活頁簿複製工作表可以帶著這些外來區塊一起走,只需透過單純的字串清單 (string-list) 指派

寫入 calcChain.xml 讓 Excel 信任您的公式

只要存檔的活頁簿包含公式,HotXLS 就會寫入 xl/calcChain.xml(計算鏈部分,ECMA-376 Part 1, §12.3.1),並且它會在兩種順序之間做選擇。如果公式相依圖已經建立且是最新的——您在最後一次編輯後呼叫了 Recalculate——計算鏈就會以完整的拓樸順序輸出,前置項在相依項之前,並將任何循環參照成員附加在最後面。否則,儲存格會以文件順序 (document order) 列出。兩者都是正確的:微軟關於該格式的實作備註 [MS-XLSX],將計算鏈視為 Excel 在載入期間會驗證並重新排序的提示,所以任何完整的列表都是合法的,而 HotXLS 刻意拒絕在 SaveAs 內部強制建置圖表——邊緣 (edge) 的建構相對於儲存格數量是二次方複雜度 (quadratic),在存檔一百萬個儲存格時,這是一個無法接受的隱藏成本

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 視為建議性 (advisory) 的部分?因為它的缺席就是一個訊號。某些消費者——修復啟發法 (repair heuristics)、第三方檢視器、diff 工具——預期一個包含公式的活頁簿要帶有計算鏈,一個在存檔時默默丟棄這個部分的程式庫,產生的檔案會微妙地不像 Excel 寫出的任何東西。輸出一個有效的計算鏈,能將輸出保持在生態系其餘部分已測試過的外殼範圍內,這就是往返工程中安靜、不光鮮亮麗的核心

不失真往返的極限在哪裡

誠實比行銷上的打勾重要,所以邊界也值得同等的篇幅。HotXLS 並沒有逐位元組複製整個套件:工作表 XML、樣式、共用字串以及活頁簿部分,都是從解析後的模型重新產生的,所以輸出在語意上是忠實的,但並不是二進位一致的 (binary-identical)——單單是 ZIP 的本機標頭 (local header) 就帶有全新的 DOS 時間戳記。擷取到的 <ext> 片段會如上所述被正規化後傳回。當存在逐字佈景主題時,程式化的佈景主題字型覆寫會被忽略。而且這個保護網有其定義好的網目:HotXLS 原生塑模的功能(例如,走勢圖會被解析並重寫,而不是盲目複製)、外來的 extLst 內容,加上逐字快取的部分。一個既未被塑模,也不在擴充點內部的部分——例如某個奇特增益集的自訂部分——就落在這篇文章涵蓋的三個機制之外,所以請測試您實際的範本,而不是擅自假設

相鄰的保存工作使整幅圖像更加完整。VBA 專案與外部活頁簿參照,基於相同的「保留您未塑模的內容」哲學安然度過存檔過程,這在關於 VBA 與外部連結保存的姊妹文章中有涵蓋;而 docProps 中的文件屬性擁有它們專屬的讀寫 API,而不是被無聲無息地丟棄。當您評估任何試算表程式庫時,請跑一次單一儲存格測試:開啟一個功能豐富的正式版 (production) 活頁簿,變更單一個值,存檔,然後將解壓縮後的部分與原檔進行 diff 比對。除了您碰觸的工作表之外,還有什麼被改變了,這能告訴您關於這套程式庫的資訊,勝過任何功能比較表

這裡描述的往返機制——自 v2.89.46 起的逐字佈景主題保留,以及自 v2.131.0 起的外來 extLst 擷取與 calcChain.xml 輸出——皆隨附於目前的 HotXLS Delphi Excel 元件 中,其產品網頁記錄了為 Delphi 與 C++Builder 提供的完整 XLSX 讀寫功能集