技術文章

使用 HotXLS 在 Delphi 中複製 XLSX 工作表

您已經將一個工作表建立得完美無瑕。標題帶已合併,欄寬與資料相符,頂端兩列已凍結,列印範圍和邊界也為了乾淨的 A4 匯出而設定好了,而且工作表索引標籤也上了色,讓財務部門能輕鬆找到。現在這份報表需要 12 個這樣的工作表,每個地區一個,且每個都要從相同的版面配置開始。如果用程式碼將那個工作表重建 12 次,就是讓細微偏差悄悄潛入的方法:第 7 區的某一欄窄了一個點,第 11 區的凍結窗格不見了,而且直到 PDF 放在經理的桌上之前都沒人會注意到。您實際想要的,是程式碼版本的 Excel 右鍵選單:移動或複製、建立副本。也就是拿著完成的工作表,印出獨立的複本

HotXLS 中的 XLSX 引擎,是一個適用於 Delphi 和 C++Builder 的原生程式庫,可以在不自動化 Excel 本身的情況下讀寫 Excel 檔案,它已經能夠移動工作表、刪除工作表,以及跨工作表複製儲存格範圍。直到 v2.91.0 為止,它所不能做到的,就是透過單一呼叫複製 (clone) 整個工作表。該版本加入了兩個進入點:TXLSXWorksheet.CopyFrom,它可以將工作表層級的狀態從一個工作表複製到另一個工作表,以及 TXLSXSheets.Duplicate,它會新增一個工作表並為您執行 CopyFrom。有趣的部分不在於它複製了東西。而在於它在「哪些會被深拷貝」和「哪些不會」之間畫了一條刻意的界線,以及為什麼這條界線會劃在那裡

單一呼叫複製完成的工作表

高階操作是 Duplicate。傳遞給它來源工作表的 1-based 索引,它就會傳回一個全新的工作表,該工作表會反映出原始的版面配置和資料。這個索引慣例與 XLSX 端的 Items[] 相符,所以工作表一的索引是 1,而不是 0;傳遞一個超出範圍的索引,您會得到 nil 而不是例外狀況,這與其餘 XLSX 工作表集合所使用的失敗合約 (failure contract) 相同

var
  Book: TXLSXWorkbook;
  Template, Copy: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Template := Book.Sheets.Add('Template');
    Template.Cells[1, 1].Value := 'Quarterly Statement';
    Template.Range['A1:C1'].Merge;
    Template.ColWidth[1] := 18;
    Template.FreezePanes(2, 1);          // freeze top row + first column
    Template.TabColorIsAuto := False;
    Template.TabColor := $FF1F4E79;

    // Clone with an explicit name...
    Copy := Book.Sheets.Duplicate(1, 'Region-North');
    // ...or let it pick the Excel-style default name.
    Copy := Book.Sheets.Duplicate(1);    // -> "Template (2)"

    Book.SaveAs('regions.xlsx');
  finally
    Book.Free;
  end;
end;

這段程式碼中有兩件事值得放慢腳步來看。首先,FreezePanes 接收的參數是列 (row) 優先的,即 FreezePanes(ARow, ACol),這樣它就與 Cells[Row, Col] 的索引方式一致;複本會繼承完全相同的凍結分割。其次,這個方法命名為 Duplicate,而不是更明顯的 Copy,這不是風格偏好。CopySystem 單元中的標準常式 (routine),經常被用於字串和動態陣列。如果在類別上宣告一個名為 Copy 的方法,它會在方法主體內遮蔽 (shadow) 原本的 Copy,並產生那種在六個月後反咬您一口的解析模糊性。Duplicate 避開了整個問題,且在呼叫端讀起來很正確

預設名稱遵循 Excel 自家的規則

當您呼叫單一參數的多載,或傳遞一個空的名稱字串時,新的工作表會以來源工作表的名稱命名,並加上一個 (2) 的後綴,且這個後綴會不斷增加直到名稱唯一為止。複製 Template 工作表一次,您會得到 Template (2);再複製一次,您會得到 Template (3),因為 Template (2) 已經被佔用了。這反映了 Excel 從其自身的「建立副本」命令產生名稱的方式,因此您的程式碼產生的活頁簿看起來就像使用者預期中手動複製的活頁簿。唯一性檢查會針對當前使用中的工作表集合進行,這意味著它也會跨越您手動建立的名稱,而不僅僅是來自先前複製的名稱

如果您正在為每個地區或每個月產生一個工作表,請依賴明確名稱的多載。一個可預測的 Region-NorthRegion-South 命名方案,在日後處理起來比一串 (2)(3) 的後綴更容易,而且它能保持您定義的名稱和跨工作表的公式易於閱讀

CopyFrom 進行深拷貝的內容

在底層,Duplicate 會新增工作表然後呼叫 CopyFrom(ASource),當您想要複製到已經建立好的工作表上時,也可以直接呼叫它。CopyFrom 首先會防範兩種退化狀況 (degenerate cases):從 nil 複製,或是將工作表複製到自己身上,這兩者都會立即回傳且不做任何事。在那之後的所有操作就是複製本身,且其涵蓋範圍刻意做得很廣

儲存格資料是第一個。CopyFrom 會向來源詢問其 UsedRange(這是一個包含了已填寫儲存格和合併區域的緊密邊界方塊),並重複使用現有的 CopyRangeTo 機制,將每一個值、公式以及每個儲存格的樣式索引帶到目標工作表,從 A1 開始。在儲存格之上,它會重播 (replay) 讓範本看起來很完整的那一整層工作表層級狀態:

  • 合併範圍:按座標重新建立,所以橫幅會跨越相同的矩形
  • 欄寬與列高,加上隱藏的、折疊的以及大綱層級的清單:逐字複製,所以非預設的列和欄能精確地對齊
  • 凍結窗格與檢視狀態:縮放比例、格線和零值顯示、由右至左方向,以及檢視類型
  • 帶有每個動作允許選項的保護狀態:所以被鎖定的範本會以相同的方式保持鎖定
  • 整個版面設定區塊:邊界、方向、紙張大小、縮放比例與適合頁面大小、列印範圍、列印標題、頁首與頁尾,以及列印格線和列印標題旗標
  • 自動篩選範圍、索引標籤色彩,以及工作表的可見性

結果是一個能列印、篩選,並且呈現出與其來源完全相同的工作表。而且因為儲存格、合併範圍以及維度清單是在新工作表上物理地重新建立而不是使用別名 (aliased),所以這個複本是完全獨立的。在複本的儲存格中寫入 999,來源工作表會保持其原始值;這種獨立性是為了平行區域報表所製作的副本最重要的一個屬性,隨附的 SheetCopy 示範程式明確地驗證了這一點

它保持淺拷貝的內容及其原因

現在是誠實的部分。圖表、內嵌圖片、XLSX 表格、資料驗證以及設定格式化的條件規則都不會被複製。這是一個有記錄的、刻意的邊界,而不是疏忽,且了解其背後的理由是值得的,這樣您才能圍繞著它進行規劃,而不是被它嚇到

這些集合中的每一個都帶有身分 (identity) 和參考,這在天真的欄位複製 (naive field copy) 中是無法存活的。圖表指向一個來源資料範圍,並在 OOXML 套件中擁有一個繪圖關聯 (drawing relationship);複製該物件而不重新對應其關聯和數列參考,會產生一個針對錯誤資料渲染的圖表,或是一個會被 Excel 標記為需要修復的套件。表格擁有一個在活頁簿內必須唯一的名稱、一個綁定到特定欄位的標題列,以及它自身自動產生的關聯。設定格式化的條件和資料驗證會附加到座標範圍上,並且在驗證的情況下,可以透過公式參考其他範圍。正確地深拷貝這些內容意味著重寫參考並鑄造全新的身分,這是具有真實失敗模式 (failure modes) 的實質工作。半吊子地做,也就是複製物件卻不複製其參考,比根本不複製還要糟糕:它會產生一個在開啟時會提示修復並默默丟棄內容的檔案。因此,引擎會乾淨地複製那些它可以複製的東西,而將帶有參考的集合留給呼叫者,因為呼叫者知道目標應該指向哪裡

在實務上,這意味著對於一個更豐富的範本,其工作流程是:複製工作表以取得儲存格、版面配置和列印設定,然後使用當初建立它們的相同 API,在複本上重建圖表、表格、驗證或設定格式化的條件。因為您是針對複本自己的範圍來重建它們,所以參考一建立出來就會是正確的。對於一個讀取 A1:C10 的圖表,在複本上新增一個指向複本 A1:C10 的圖表;對於一個您希望維持運作的自動篩選,請注意篩選範圍確實會帶過來,所以您只需要重新套用欄位條件即可。您可以透過在有關合併儲存格和報表範本配置的文章中所描述的相同呼叫來重新加入設定格式化的條件和資料驗證規則,該文章走訪了複本所繼承的合併表格和範圍模型

複製功能在報表管線中的位置

工作表複製是佔位符驅動生成 (placeholder-driven generation) 的天作之合。在 Delphi 中以範本驅動的報表產生指南裡,以權杖為錨點 (token-anchored) 的方法解決了將資料寫入由其他人編輯的版面配置的問題;而複製功能則解決了在一個活頁簿中需要多次使用該版面配置的問題。將它們結合起來,模式就很乾淨了:保留一個包含了權杖、合併和列印設定的原始 Template 工作表,然後針對每個區域或期間呼叫 Duplicate,用該部分的資料填滿複本的權杖,然後繼續下一個。原始的範本永遠不會被改變,所以它對於下一個複本來說始終是一個可靠的來源,且每個輸出的工作表都是從一個逐位元 (byte-for-byte) 相同的版面配置開始的

關於排序的一個注意事項可以省去一類的混淆。在您將資料倒入工作表之前,而不是之後,複製工作表。一個範本應該保持結構和格式,而不是上一季的數字,複製一個有樣式的空白工作表意味著每個複本都有一個乾淨的開始。如果您複製了一個已經帶有資料的工作表,那些資料也會跟著過來,因為 CopyFrom 會忠實地複製已使用的範圍;這偶爾是您想要的,但對於一個扇出式 (fan-out) 的報表來說通常不是

一個快速的驗證習慣

因為深拷貝與淺拷貝的劃分在您去尋找之前是看不見的,所以與其相信所有的東西都已經複製過來了,不如在工作中建立一個五行程式碼的檢查。在複製之後,讀回複本理應繼承的結構信號,並斷言 (assert) 它們與來源相符

Copy := Book.Sheets.Duplicate(1, 'Region-North');
WriteLn(Format('merged=%d  colA=%.1f  freezeRow=%d  tabAuto=%d',
  [Copy.MergedCells.Count, Copy.ColWidth[1],
   Copy.FreezeRow, Integer(Copy.TabColorIsAuto)]));
// Prove independence: mutate the copy, confirm the source is untouched.
Copy.Cells[2, 2].Value := 999;
// Template.Cells[2, 2].Value is still whatever it was.

合併的數量、欄寬、凍結的列,以及索引標籤色彩旗標,會告訴您那些會被複製的層面實際上已經成功複製過去了。除此之外,在任何帶有圖表、表格、驗證或設定格式化條件的工作表中,請將它們視為在複本上的「待重建」清單:它們的缺席是設計使然,且修復方式只需要幾個呼叫,而不是提交錯誤報告。這種心智模型(在安全的地方進行深拷貝,在參考會損壞的地方進行淺拷貝),就是如何善用這個功能的完整故事

這裡所描述的工作表複製與 CopyFrom 工作表狀態複製功能,已隨附於適用於 Delphi 與 C++Builder 的原生 HotXLS 試算表元件 v2.91.0 中,並同時提供了一個可執行的 SheetCopy 範例,能端到端地執行「複製後修改 (clone-and-mutate)」的循環