技術文章

HotXLS 在 Delphi 中跨活頁簿複製與公式重新繫結

HotXLS 的 AddCopy 方法會將工作表中的每個公式反組譯成 A1 樣式文字,再在目的活頁簿內重新編譯這些文字,藉此將工作表從一個 Excel 活頁簿複製到另一個活頁簿,而不是直接複製已編譯的公式樹,因為圖表數列參照、豐富文字字型索引與外部連結編號都是在每個活頁簿檔案內獨立配置

這個失敗會在你最意料之中的活頁簿中出現:月末工作會從每間分公司的報表取出一張工作表,並附加到彙總檔案。開啟結果後,某個小計圖表繪製的是完全不同分公司的數字,來源中原本粗體紅色的備註變回普通黑色文字,而原先從搭配的查詢活頁簿取得稅率的公式,現在顯示出沒有人能解釋的固定數字。這裡不會擲出例外——檔案能開啟、數字看起來也合理,而損害會一直留在那裡,直到有人注意到旁邊的圖表標題錯了

AddCopy 為什麼不能直接複製已編譯的公式樹

AddCopy 不能原封不動地搬移已編譯的公式樹,因為已編譯的 BIFF 公式不是獨立存在的文字——它是一串權杖,而其中幾個權杖是只有在產生它的活頁簿內才能正確解析的小整數。像 Sheet2!A1:A10 這樣的 3D 參照在編譯後不會帶著字面上的 Sheet2;它帶的是 BIFF 規格稱為 ixti 的欄位(HotXLS 在自己的編譯樹中以欄位名稱 FExternID 保留相同值),這個欄位是該活頁簿私人 EXTERNSHEET 表格的索引,而編號方式取決於該活頁簿註冊工作表與外部活頁簿的順序。把權杖原封不動移到 EXTERNSHEET 表格順序不同的活頁簿中,索引 3 就不再代表 Sheet2——它代表另一邊恰好位於第 3 個位置的任何工作表,而 Excel 沒有辦法標示這個錯誤,因為就檔案格式而言,這個公式完全格式正確。這正是 TXLSWorksheets.AddCopy 存在要避免的失敗:在 Delphi 或 C++Builder 程式碼中從任一活頁簿自己的工作表集合呼叫它時,它會將工作表——儲存格值、格式、公式、圖表、註解、合併儲存格、頁面設定及更多內容——從可能不是呼叫來源的活頁簿複製出來,並以你選擇的名稱或原始名稱的消歧複本,將結果附加到目的地

var
  Summary, Branch: IXLSWorkbook;   // interface-counted: do not Free
begin
  Summary := TXLSWorkbook.Create;
  Branch := TXLSWorkbook.Create;
  Branch.Open('branch-east.xls');

  // Appends a copy of Branch's first sheet onto Summary, renamed to
  // stay unique inside the destination workbook
  Summary.Sheets.AddCopy(Branch.Sheets[1], 'East Detail');
  Summary.SaveAs('consolidated.xls');
end;

修正方式:反組譯成文字,再在目的地重新編譯

HotXLS 透過不讓已編譯的樹本身跨越活頁簿邊界來解決索引問題。對於跨活頁簿複製中的每個公式儲存格,AddCopy 會先將來源公式反組譯成使用者在 Excel 公式列中會看到的相同 A1 樣式文字,再把文字交給目的活頁簿;目的活頁簿會從頭使用自己的表格將文字解析回樹——此時像 Data!D2:D100 這樣的工作表限定參照只是一個字串,而字串在任何活頁簿中都代表相同意思,所以只要目的地已有名為 Data 的工作表,參照就能正確解析,完全不需要索引轉換,因為過程中從未有原始索引需要轉換。HotXLS 只在必要時付出這次往返的成本:在同一活頁簿內複製工作表時,會採用較便宜的路徑,直接在記憶體中複製已編譯的樹,因為其中每個索引在它留存的位置都已有效,而文字迂迴路徑只會在 AddCopy 偵測到來源與目的地確實是不同活頁簿實例時執行一次。這裡也值得精確說明這次重寫不是什麼。它與在單一工作表中插入或刪除資料列時執行的列欄位移完全無關,另一篇配套文章 詳細介紹了那項功能——該引擎會在同一活頁簿內以原地重寫 A1 文字的方式追蹤向上或向下移動幾列的儲存格,而本文介紹的功能則是在公式完全離開編譯它的活頁簿時執行,問題不在移動的列,而在活頁簿私有的編號

// Conceptually, this is what AddCopy does for each formula cell: turn
// the compiled tree back into text using the source workbook's own
// tables, then let the destination workbook parse that text back into
// a tree using its own tables, from scratch
FormulaText := SourceBook.GetUnCompiledFormula(SourceFormula, Row, Col, SourceSheetID);
DestFormula := DestBook.GetCompiledFormula(FormulaText, DestSheetID);

如果目的地還沒有那張工作表或那個名稱,會怎樣

AddCopy 只有在目的活頁簿已經具備公式文字所參照的所有內容時,重新編譯才會成功;實務上會出現的兩個缺口,是同名工作表尚未在這批次中複製過來,以及目的地從未存在過以活頁簿為範圍的定義名稱。HotXLS 不會在工作表複製到一半且重新編譯失敗時擲出例外——儲存格的 Value 指派會安靜地將公式文字改存為普通字串,這是刻意設計且可檢查的失敗模式,而不是靜默失敗,因為公式儲存格意外顯示 =SUM(Q1!B2:B12) 這類字面文字而非計算數字,正好就是上游複製未能解析的訊號。在放棄之前,AddCopy 會嘗試一次修復:它會走訪失敗公式的語法樹,收集公式碰到的每個定義名稱 ID,並針對來源存在但目的地尚未存在的每個活頁簿範圍名稱,將名稱複製過去,再將相同文字重新編譯第二次。工作表範圍名稱不在這項修復能處理的範圍內,因為只在來源活頁簿某張工作表上的公式可見的名稱沒有可遷移的對應位置,而目的地若已擁有相同拼寫的名稱,則會保持不變,不予覆寫,前提是呼叫者刻意預先建立的名稱就是他們希望保留的名稱。在單一活頁簿內,跨工作表公式的名稱查詢會自動從工作表範圍向上查到活頁簿範圍,HotXLS 定義名稱與跨工作表公式文章介紹的就是這項機制;跨越真正的活頁簿邊界後,這層安全網會完全消失,名稱必須刻意帶過去,否則依賴它的公式就會退化成文字

圖表數列參照也需要相同修正,但會走不同的程式碼路徑

繪製儲存格範圍的 HotXLS 圖表數列,會遇到與普通儲存格公式完全相同的編號問題,因為圖表的資料範圍參照也是已編譯的公式權杖串流——BIFF 規格將承載它的記錄稱為 BRAI([MS-XLS] 第 2.4.51 節)——但 AddCopy 不能重用普通圖表載入路徑來修正它,因為正是那條路徑造成了錯誤。檔案正常開啟時,圖表記錄從磁碟解析,公式樹會透過負責解析的計算器實例轉換原始位元組而建立;如果將來源圖表的原始 BRAI 位元組改由目的活頁簿自己的普通記錄載入器處理,這些位元組內嵌的 ixti 就會依目的地的 EXTERNSHEET 表格解析,因此數列會靜默指向另一邊佔據該位置的工作表——這和原封不動複製儲存格已編譯樹屬於同一類錯誤,只是更難察覺,因為沒有人會像閱讀儲存格公式那樣閱讀圖表數列公式。HotXLS 以專用複製路徑避開這個陷阱:TXLSCustomChart.AssignFrom 會逐一原樣複製每筆圖表記錄自己的非公式標頭位元組,然後使用普通儲存格相同的反組譯與重新編譯基礎功能,重建附加的範圍,使新樹從頭依目的地的 EXTERNSHEET 表格建立,而不是事後依該表格重新解讀

相同的編號問題,一次處理一個字型索引

圖表或豐富文字儲存格中的每個活頁簿私有數字不一定都是公式,而字型索引就是這類問題的縮小版。豐富文字執行範圍,以及另外兩種承載標題或座標軸字型的圖表記錄類型,都會將字型參照儲存為擁有該內容的活頁簿自身字型表中的原始整數索引;該索引在另一個活頁簿的表格中毫無意義——它同樣可能指向完全不同的字體、大小或色彩。HotXLS 會依值而非依數字解析這個問題:它在來源表格中以該索引查找實際字型屬性,在目的地字型表中尋找或建立相符項目,並重寫儲存的索引使其指向新的位置。一個格式特性讓查找本身變得棘手——檔案中的索引會跳過第 4 個位置,這是 [MS-XLS] 第 2.5.339 節記錄的編號間隙,因此程式碼必須先將索引減一再比較字型,寫入結果前再加回一

// The file-numbered font index skips slot 4 (MS-XLS section 2.5.339);
// shift into the in-memory slot, migrate the font by value if the
// destination differs, then shift back before writing the result
if Ifnt >= 5 then
  Dec(Ifnt);
if DestFonts.Key[Ifnt] <> SourceFonts.Key[Ifnt] then
  Ifnt := DestFonts.SetKey(0, SourceFonts.Key[Ifnt]);
if Ifnt >= 4 then
  Inc(Ifnt);

如果公式原本就指向活頁簿外部,會發生什麼事

在呼叫 AddCopy 之前就延伸到第三個活頁簿的公式,是文字往返無法攜帶的唯一情況,因為 HotXLS 自己的公式轉文字反組譯器刻意不會為外部參照合成 [Book]Sheet! 括號文字,而另一端的編譯器也不接受這種語法作為輸入——因此這個情況會透過完全不碰文字的第二種機制處理。當上述名稱遷移修復仍讓儲存格保持字串,且來源活頁簿具有真實檔名時,AddCopy 會切換策略:它會深層複製已編譯的公式樹,而不是複製文字,接著將複本交給專用的重新繫結階段 RebindExternRefsInTree,逐節點走訪公式樹。對於找到的每個範圍參照,該階段會將來源的 EXTERNSHEET 項目解析回一對工作表名稱,並在目的地自己的外部參照表格中註冊或重用等效項目;如果目的地之前從未參照該來源檔案,就會建立全新的外部活頁簿連結

這裡的活頁簿私有編號問題最為直接,因為外部參照權杖將三個獨立座標綁在同一欄位中,而每個座標都只對寫入它的活頁簿私有:是哪一本外部活頁簿,這是目的地自己的外部活頁簿清單中的一個位置,編號順序取決於該活頁簿註冊它們的先後;該外部活頁簿中的哪張工作表,這是限定於該外部活頁簿的工作表清單中的 1 起始索引,與目的地自己的內部工作表 ID 完全屬於不同編號領域;以及儲存格範圍本身,也就是不需要轉換的普通列欄位座標,因為它們從一開始就不是相對於活頁簿的座標。前兩項只要有一項錯誤,Excel 仍會開啟檔案、仍會顯示公式,並且毫無提示地以錯誤的外部儲存格計算。就連這種樹層級重新繫結也有一種節點無法處理:指向定義名稱的參照,它是自己活頁簿私人名稱表中的索引,正如工作表索引只屬於自己的 EXTERNSHEET 一樣,沒有可用的樹層級修復——重新繫結走訪在樹中任何位置遇到名稱參照時,就會放棄整個公式,而不會寫出部分正確的公式。即使重新繫結成功,目的地儲存格也不會顯示剛重新計算的數字;它會顯示來源儲存格在複製時已持有的值,並保存在快取位置中,就像 Excel 自身會快取任何外部參照的最後已知值,直到你明確重新整理連結為止,這是正確的預設行為,因為跨檔案即時連結重新計算正是應該刻意觸發一次,而不是每次開啟都觸發的操作

這項設計的成本

AddCopy 的反組譯與重新編譯機制並非免費,因此在撰寫大型彙整工作之前就值得規劃成本,而不是事後才處理。在同一活頁簿內複製工作表會採用便宜路徑,直接在記憶體中複製已編譯的樹,因為其中每個索引在它留存的活頁簿中都已有效;跨活頁簿複製則會為每個公式儲存格付出真正解析的成本,先反組譯成文字,再從空白重新編譯文字,幾十個公式的工作表不值得測量這個差異,但批次工作中若將擁有數萬個公式儲存格的來源活頁簿作為數十張工作表之一複製,就應預期重新編譯會主導執行時間,而不是周邊的檔案 I/O。複製順序還有速度以外的第二個重要原因:參照 AddCopy 尚未複製到的工作表之公式,會因與參照真正不存在工作表的公式相同的原因而重新編譯失敗,因此先複製工作表 B,再複製依賴它的工作表 A 公式時,就會看到該公式依上述方式退化,變成字串文字或指回剛才來源檔案的外部連結備援。而且由於彙整批次中的每個來源活頁簿通常都是獨立製作,值得明確測試單一來源檔案不可能警告你的失敗模式——五個分公司活頁簿各自合計同業分公司的數字,可能在彙總活頁簿內組合成真正的循環參照,而任何單一來源檔案都不曾包含循環參照;這個循環只有在所有工作表都落到同一處並對合併集合重新計算後才存在

跨活頁簿工作表複製是 AddCopyHotXLS Delphi Excel 元件 中針對 Delphi 與 C++Builder 提供的標準行為;產品頁面提供完整的工作表與活頁簿 API 參考,包括本文說明的圖表、豐富文字與外部參照行為