技術文章

用 HotXLS 將 Delphi 資料集匯出為 Excel 報表

把一份查詢結果變成 Excel 報表,是三個問題穿著同一件外衣。每一個 Delphi 欄位型別都必須以正確的 Excel 型別落在一個儲存格裡、標題列讀起來要像報表而不是綱要傾印、而數字、日期與金額都必須帶著能安然度過這趟旅程的格式。漏掉任何一個,檔案還是會開啟、看起來還是合理,而且在某個財務使用者選取某欄、等一個永遠不出現的加總的那一刻失敗。值是以文字寫入的,Excel 把它們當成標籤,而從未有任何例外提出來警告你

HotXLS 是一個原生的 Object Pascal 試算表程式庫,直接從 Delphi 與 C++Builder 寫出 XLS 與 XLSX 檔案,完全不涉 Excel 自動化。它從 TDataset 到活頁簿提供了兩條路:即插即用的 TDataToXLS 元件,以及針對活頁簿 API 的手寫迴圈。兩者不可互換。這個元件是一個建立在 XLS 外觀層上的 VCL 公民,所以正確的選擇取決於程式碼在哪裡執行、以及消費者期望哪種檔案格式。以下說明兩條路、這個元件不再是正確工具的那條界線,以及無論你選哪一條都要如何保持欄位型別完好

從 Delphi TDataset 出發的兩條 HotXLS 匯出路徑圖解:以 VCL TDataToXLS 元件寫 BIFF8 檔,或手寫 TXLSXWorkbook 迴圈產生 XLSX
TDataToXLS 是 VCL 桌面工具寫 .xls 的一次呼叫路線,手寫的 TXLSXWorkbook 迴圈則服務無人值守工作與原生 .xlsx

欄位型別才是真正的匯出合約

在任何 API 呼叫之前,先決定每個 Delphi 欄位型別如何落到儲存格裡。一個接收 Delphi 字串的儲存格仍是字串。HotXLS 不會去猜 '1,234.50' 本意是個數字,它也不該猜,因為依語系重新解析正是德文的小數逗號在英文伺服器上變成千分位分隔符的原因。可靠的模式是透過具型別存取子來指派:數值欄位用 AsFloatAsCurrency、日期用 AsDateTime 讓儲存格持有真正的 Excel 日期序數而非格式化字串、而 AsString 只用在實際是文字的欄位

Null 的處理值得明確決定,而非依賴預設值。用 VarToStr 轉換欄位值會把 SQL NULL 變成空字串,也就是一個文字儲存格,而跳過指派則讓儲存格真正為空,這才是 AVERAGECOUNT 與樞紐表消費者所期望的。對金額欄位,請在迴圈寫好之前先決定 NULL 代表零還是未知。一旦有人把欄位格式化,兩者顯示得一模一樣,而這個差異會改變下游算出的每一個彙總

元件路線:在 VCL 應用程式中使用 TDataToXLS

對於一個已經把查詢接進資料模組的經典 VCL 應用程式,TDataToXLS 是一條單次呼叫的路。它走過任何 TDataset 後代——無論 FireDAC、ADO、IBX,或任何其他實作了抽象資料集介面的東西——並產生一份帶樣式的工作表,帶有標題標籤、字型、框線、選用的分組小計,以及針對大型結果集的自動工作表分割

var
  Exporter: TDataToXLS;
begin
  Exporter := TDataToXLS.Create(nil);
  try
    Exporter.Dataset := OrdersQuery;          // 任何 TDataset 的子類
    Exporter.WorksheetName := 'Orders';
    Exporter.HeaderSource := hsDisplayLabel;  // 是標題,不是原始欄位名稱
    Exporter.GroupFields.Add('CustomerID');   // 每個客戶一個小計區塊
    Exporter.RowsPerSheet := 50000;           // 保持在 BIFF8 的列數上限之下
    Exporter.VisibleFieldsOnly := True;             // 尊重 Field.Visible
    Exporter.SaveDatasetAs('orders.xls');
  finally
    Exporter.Free;
  end;
end;

這裡有兩個屬性扛下了大部分的生產份量。HeaderSource := hsDisplayLabel 會寫入每個欄位的 DisplayLabel 而非原始的 SQL 欄名,所以活頁簿寫的是「Customer Name」而不是 CUST_NMRowsPerSheet 之所以存在,是因為這個元件寫的是 BIFF8,其格線在 65,536 列乘 256 欄處停止;把它設成 50,000,會在格式上限截斷大型結果集之前,先把它分到多個工作表上。外觀由 HeaderFontDetailFontGroupColor 與框線樣式屬性處理,而 DisableFormat 集合則在消費者想要純粹儲存格時關掉整類格式化。對任何訂製需求,AfterCellAfterRow 事件會把剛寫好的範圍交給你做後處理

元件在哪裡止步

有三個限制是設計進 TDataToXLS 裡的,事先知道它們,能避免兩個 sprint 之後一次尷尬的重新設計

以 HotXLS 把 Delphi 資料集欄位存取器映射到 Excel 儲存格類型的圖解:對比 VarToStr 的 NULL 處理與真正的空白儲存格
匯出契約就在欄位型別:具型別存取器把數字與日期落成真正的 Excel 值,VarToStr 卻默默把 SQL NULL 變成文字儲存格
  • 它在完整意義上是一個 VCL 元件。它的單元拉進了 FormsControlsDialogs,所以把它連結進一個主控台工作或一個 Windows 服務,會把 VCL 拖進二進位檔。核心活頁簿單元沒有這種相依。它們只需要 WindowsClassesSysUtilsVariants,這也是為什麼伺服器端程式碼應該改用下面那個迴圈
  • 它是建立在 XLS 外觀層之上。這個元件填入一個 IXLSWorkbook 並寫出 .xls(BIFF8)。沒有任何屬性能把它切換成 OOXML 輸出
  • 它的事件說的是 XLS 方言。AfterCell 中的 Cell: IXLSRange 參數屬於 XLS 物件模型,所以在那裡寫的逐儲存格自訂是 XLS 風格的程式碼,即使檔案之後被轉成 .xlsx 也一樣

從元件的輸出產生 .xlsx

當消費者堅持要 .xlsx,但匯出邏輯已經活在 TDataToXLS 裡時,lxXlsxExport 單元中的橋接函式能一次呼叫轉換填好的活頁簿:

uses lxXlsxExport;

Exporter.SaveDatasetAs('orders.xls');
// 元件公開了它填入的 IXLSWorkbook
SaveXLSWorkbookAsXLSX(Exporter.Workbook, 'orders.xlsx');

請把這座橋當作表格資料的載具,而不是一個完整保真的轉換器。它複製值、公式、數字格式、填色、字型屬性、欄寬與檢視設定。它刻意不複製框線、合併區域、註解、圖表或條件格式。對於標題加列的平面格線,這剛剛好。對於一份帶樣式的報表則不夠,而誠實的修復是直接產生 XLSX,而不是去修補轉換後的檔案

圖解對比 TDataToXLS 拉進 Delphi 執行檔的 VCL 單元,與 HotXLS 核心活頁簿程式碼所需的四個 RTL 單元
把 TDataToXLS 連進服務會拖來 Forms、Controls 與 Dialogs,核心活頁簿單元卻只需要 Windows、Classes、SysUtils 與 Variants

供服務與批次工作使用的手寫迴圈

伺服器端程式碼應該直接瞄準 TXLSXWorkbook。在複製任何範例之前,請先留意兩個外觀層的生命週期差異。XLS 端的 TXLSWorkbook 透過參考計數介面持有,絕不能手動釋放,而 TXLSXWorkbook 是一個需要 try..finally Free 的普通類別。混用這兩種慣例,是製造洩漏或重複釋放的可靠方法

procedure ExportOrders(Q: TDataSet; const FileName: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Row: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Orders');
    Sheet.Cells[1, 1].Value := 'Order No';
    Sheet.Cells[1, 2].Value := 'Customer';
    Sheet.Cells[1, 3].Value := 'Ordered';
    Sheet.Cells[1, 4].Value := 'Amount';

    Row := 2;
    Q.First;
    while not Q.Eof do
    begin
      Sheet.Cells[Row, 1].Value := Q.FieldByName('OrderNo').AsInteger;
      Sheet.Cells[Row, 2].Value := Q.FieldByName('Customer').AsString;
      if not Q.FieldByName('Ordered').IsNull then
        Sheet.Cells[Row, 3].Value := Q.FieldByName('Ordered').AsDateTime;
      Sheet.Cells[Row, 4].Value := Q.FieldByName('Amount').AsFloat;
      Inc(Row);
      Q.Next;
    end;

    Book.StreamingWrite := True;  // 將工作表 XML 直接串流進 zip
    Book.SaveAs(FileName);
  finally
    Book.Free;
  end;
end;

要緊的是那幾行具型別指派與 IsNull 守衛。日期以日期序數到達、金額以倍精度浮點數到達、而 NULL 的訂單日期保持真正為空,而不是變成空字串。StreamingWrite := True 只改變儲存路徑:工作表 XML 直接串流進 zip 容器,而不是先被組裝成一個大字串,這對六位數的列數能壓平 SaveAs 時的記憶體尖峰。每一個儲存方法也都有 TStream 多載,所以活頁簿可以直接進到 HTTP 回應而不碰磁碟。串流寫入與批次工作一文走過了那個部署模式,而大型活頁簿效能一文則涵蓋了當列數進一步攀升時該怎麼辦

這個迴圈也是能跨執行緒擴充的那條路。兩個引擎都是原生 Object Pascal 寫入器,一邊是 BIFF8 記錄串流,另一邊是 OOXML 的 zip 加 XML,所以匯出的任何一部分都不碰 COM 自動化,也不需要伺服器上有 Excel 授權。這買到的是沒有單一執行個體瓶頸的並行,前提是每個執行緒都建置自己的活頁簿。活頁簿物件不是可供共用使用的執行緒安全物件,所以規則是每次匯出一個執行個體,絕不是一個用鎖護衛的共用執行個體

有一個限制值得在你圍繞它設計之前先知道。XLSX 格線在 1,048,576 列乘 16,384 欄處停止,所以 RowsPerSheet 在 XLS 端處理的工作表分割在這裡很少需要。一份百萬列的活頁簿也很少是人類消費者想要的。當結果集真的那麼大時,一份分隔符號檔案通常是更好的合約,而CSV 與 TSV 匯出一文涵蓋了分隔符號、BOM 行為,以及在那裡適用的公式求值注意事項

挑選起點

如果匯出存在一個 VCL 桌面工具中,而 .xls 輸出可接受,那就從 TDataToXLS 及其分組支援開始。它的程式碼最少,而且當有人之後要求 .xlsx 時,透過 SaveXLSWorkbookAsXLSX 的橋接就在那裡——只要你接受已描述的保真限制。如果程式碼是無人值守執行,或消費者從一開始就要求 .xlsx,那就寫迴圈。兩條路都隨附可運作的展示專案,並且是HotXLS Delphi Component 套件的一部分