技術文章

用 Delphi 與 HotXLS 匯出 Excel 為 CSV、TSV、HTML

想像一個夜間工作:用程式碼建置一份發票活頁簿,再以 CSV 寫出,供下游系統匯入。數字在 Excel 裡看起來沒問題。CSV 在文字編輯器裡開啟也很乾淨。然後匯入器在第 42 列的合計欄上嗆住,因為金額欄位讀到的是 =SUM(D2:D41),也就是公式的字面文字,而不是它本應算出的數值。沒有東西壞掉。這是有文件記載的行為,而這正是關於從 HotXLS 匯出時必須先理解的第一件事:寫入器會完全按照儲存格模型當下的狀態進行序列化,而一個其值從未被計算的公式儲存格,手上只有它的公式文字可以交出去

為什麼你的 CSV 裡是公式而不是數字

HotXLS 把公式文字與計算後的值當作兩件分開的事。SaveAsCSV 在輸出途中不會執行計算引擎,這是刻意的設計:匯出不應改變活頁簿,也不該冒險卡在一條病態的公式鏈上。Excel 自己存的檔案在公式旁帶有快取結果,所以重新匯出那些檔案會如你預期般運作。陷阱專屬於你自己程式碼產生的活頁簿——公式被寫入卻從未求值。修復方法是在你匯出之前讓那些值先存在,用的是同一個會解析跨工作表參照與自訂函數的 Calculate 引擎:

圖解:Delphi HotXLS 活頁簿儲存格只存放公式文字,直到 Book.Calculate 算出值,CSV 匯出因此輸出數字而非 =SUM 文字
SaveAsCSV 序列化的是儲存格模型的現狀 — 沒有 Calculate,金額欄位帶的就是公式字面文字,匯入端會拒收
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  R: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('invoice-run.xlsx');
    Sheet := Book.Sheets[0];

    // 具體化公式結果,讓 CSV 攜帶數字而非 '=...' 文字
    for R := 2 to 41 do
      if Sheet.Cells[R, 4].Formula <> '' then
        Sheet.Cells[R, 4].Value := Book.Calculate(Sheet.Cells[R, 4].Formula);

    Book.SaveAsCSV('feed.csv', 0, ',');    // 工作表 0,逗號分隔符號
    Book.SaveAsCSV('feed.tsv', 0, #9);     // 以相同工作表作為 TSV
  finally
    Book.Free;
  end;
end;

注意這個迴圈實際做了什麼:它用計算後的值覆寫了公式儲存格。對於一次拋棄式匯出,這完全正確;但如果你打算之後再把活頁簿存成 .xlsx,那就錯了,因為你剛剛把活的公式換成了凍結的數字。請從副本匯出,或把回寫的範圍限定到只觸及這次匯出。Calculate 背後的引擎能做得更多,包括註冊你自己的函數,那是HotXLS 公式引擎與自訂函數的主題

分隔符號寫入器保證什麼

CSV 路徑產生帶有位元組順記(BOM)的 UTF-8、CRLF 換行,以及 RFC 4180 引號。任何含有分隔符號、引號或換行的欄位都會被包起來,內嵌的引號則會被重複一次。日期無論儲存格的顯示格式為何,都呈現為 yyyy-mm-dd hh:nn:ss。對機器消費者來說這是正確的選擇,不過這會讓原本以為螢幕格式會延續過來的人感到意外。富文字儲存格會透過串接其文字段落而被壓平

HotXLS 在 Delphi 的單一分隔文字寫入器圖解:CSV 用逗號、TSV 用 #9;兩種輸出共用 UTF-8 BOM、CRLF 行尾與 RFC 4180 引號規則
CSV 與 TSV 出自同一個寫入器,UTF-8 BOM、CRLF 行尾與 RFC 4180 引號規則對兩者原樣適用

那些預設值會在爭論開始之前就擺平大多數與匯入器的爭執,但其中有兩項仍該寫進你的介面合約。第一項是 BOM。它正是讓 Excel 能以重音符字元完好開啟檔案的關鍵,但有少數嚴格的解析器會把那三個位元組當成資料;如果你的解析器是其中之一,就在交接時把它們剝掉。第二項是 TSV。它根本不是一個獨立的功能,只是同一個寫入器以 #9 作為分隔符號來呼叫,所以上面所說的一切對它都適用不變。要匯出哪個工作表,是在多引數多載中以從 0 起算的索引來選擇的,而單引數的 SaveAsCSV(FileName) 簡寫會取作用中的工作表

HTML 匯出是快照,不是交換格式

CSV 會丟掉值以外的一切,而 SaveAsHTML 則試圖保留外觀:每個工作表一個 <table>,合併區域以 colspanrowspan 表達,基本儲存格樣式以行內 CSS 呈現。相對於佈景主題的顏色會被略過而不被解析,所以一個倚賴佈景主題位置的範本,出來會比在 Excel 中看起來樸素得多。對任何必須安然度過這趟旅程的東西,請設定明確的 RGB 顏色。選項物件控制了外殼:

var
  Opts: TXLSXHtmlExportOptions;
begin
  Opts := TXLSXHtmlExportOptions.Create;
  try
    Opts.Title := 'Weekly settlement';
    Opts.TableClass := 'report-grid';     // 主機頁面樣式表的掛接點
    Opts.WriteDocument := True;           // 完整頁面,而非片段
    if Book.SaveAsHTML('settlement.html', 0, Opts) <> 0 then
      raise Exception.Create('Sheet index out of range');
  finally
    Opts.Free;
  end;
end;

這段程式碼裡有兩個細節值得留意。把 WriteDocument 翻成 False,輸出就會變成一份單純的表格片段而非完整頁面,這正是你要把預覽注入既有版面時所想要的:設定 TableClass,讓宿主頁面的樣式表來做主題化。回傳值的慣例也與大多數 HotXLS 呼叫相反。SaveAsHTML 在成功時回傳 0,在錯誤的工作表索引時回傳 -1,所以一個出於習慣檢查 = 1 的寫法,會把每一次成功的匯出都回報為失敗。當你需要的是某個區域而非整個工作表,也許是為了寄出或內嵌單一區塊,TXLSXRange.SaveAsHTML 能在同樣的繪製規則下匯出任何矩形範圍

RTF 輸出,以及它仍有一席之地之處

第四個目標寫的是 RTF 1.6 表格,每次呼叫透過 SaveAsRTF 寫一個工作表。欄寬以每個欄寬字元約 96 twips 來近似。需要知道的結構限制是:合併儲存格在輸出中不會跨格——只有錨點儲存格帶著它的內容,被覆蓋的儲存格會以空白送出。這排除了把 RTF 用在版面密集的範本上。但它仍有一席之地,作為把表格結果丟進文書處理器,或丟進一個早於 HTML 攝取的舊式文件管理系統時,最省力的路徑

往返:匯入 CSV 在設計上就是破壞性的

把 CSV 讀回來有它自己的合約。OpenCSV 會清空整個活頁簿,並把它重建為一個名為 Sheet1 的單一工作表。它本質上是個建構函式,不是合併,所以絕不要在仍持有未儲存內容的活頁簿上呼叫它。傳入 #0 作為分隔符號會觸發自動分隔符號偵測。ADetectTypes 旗標控制型別提升:開啟時,數值字串會變成數字、ISO-8601 字串會變成日期、true/false 會變成布林。當來源資料帶有前導零的識別碼、郵遞區號或產品代碼時請關閉它,這些東西都會被提升悄悄弄成數字(前導零在 00123 變成 123 的那一刻就不見了)。兩個外觀層都公開同一個匯入。把它與上面的匯出呼叫配對,你就有一座不需在管線任何地方安裝 Excel 的格式橋樑,也就是以 HotXLS 從資料庫產生 Excel 報表所涵蓋的情境

直接匯出到串流

這裡每一個寫入器,在檔名版本旁都有一個串流多載:CSV、HTML、RTF,以及活頁簿格式本身。在伺服器程式碼中,那些多載才是你該伸手去拿的。一個提供 CSV 下載的 Web 端點可以寫進一個 TMemoryStream,再直接交給回應物件,沒有暫存檔、沒有清理工作,也不會有兩個恰好選了同一個產生名稱的請求互相碰撞。把匯出推進 blob 儲存或附加到外寄郵件時也是一樣。檔案系統完全從畫面中消失

這個模式會和程式庫的部署方式疊加。兩個外觀層都是原生的 Object Pascal 讀寫器,所以沒有 Excel 安裝、沒有 COM 自動化,也沒有把請求序列化在伺服器上的每個程序瓶頸。每個請求都能擁有自己的活頁簿物件、執行第一節那種計算回寫,並與鄰近的請求並行串流匯出。記憶體是唯一需要留意的資源。活頁簿模型在匯出期間全程駐留在 RAM 中,所以一個只為了把極大檔案重新發出為 CSV 而開啟它們的服務,應該限制並行工作的數量,或把過大的排入佇列,而不是讓流量尖峰來決定工作集

還有一個較小的旋鈕:當片段會被存成獨立檔案、而下游某個工具會去嗅探編碼時,請在 HTML 選項上設定 IncludeBOM。當你直接透過 HTTP 提供 HTML 時,請把字元集宣告留給回應標頭

當位元組還是出不對

關於 CSV 匯出最常見的支援問題,是開啟問題換了件外衣:Excel 顯示亂碼而非重音符字元。直覺是怪罪寫入器,但它正是為此才發出 UTF-8 BOM,而當檔案離開你的程式碼時幾乎總是正確的。在它與 Excel 之間有某個東西吃掉了 BOM。一次文字模式的 FTP 傳輸、一次跳過前三個位元組的串流複製、一個在途中重新編碼的代理:任何一個都會剝掉標記,留下 Excel 去猜編碼,而它猜得很糟。請在邊界診斷這個問題,而不是在匯出呼叫裡。在十六進位檢視器中開啟送達的檔案,確認 EF BB BF 仍是裡面的第一個東西

圖解追蹤 HotXLS 在 Delphi 的 CSV 匯出寫入的正確 UTF-8 BOM,如何被 FTP 文字模式傳輸或重新編碼的代理剝除,讓 Excel 顯示亂碼
寫入器輸出的 EF BB BF 完全正確 — 亂碼只會在傳輸層剝掉標記後出現,請用十六進位檢視器檢查送達的位元組

這就是所有四種格式的貫穿主題。匯出呼叫是簡單的部分,而 HotXLS 在寫入器面對的每個決策上都做出站得住腳的選擇。失敗活在接縫處——公式文字遇上一個想要數字的解析器、BOM 遇上一個不保留它的傳輸、合併儲存格遇上 RTF 的扁平表格模型。每一個都是一個你必須寫進你的匯出器與其消費者之間合約的事實,因為消費者無法從位元組裡讀出你的意圖。關於兩個活頁簿外觀層的完整方法清單,HotXLS Delphi Component 產品頁帶有完整參考