從 Delphi 產生一份有樣式的 Excel 報表,可靠的做法是從一份設計師已經建好的活頁簿開始。財務部門的某個人在 Excel 裡把發票排好:標誌、欄標題、明細帶上的邊框、粗體的總計列、貨幣格式。你的程式碼開啟那個檔案,把活資料丟進設計師為它保留的儲存格,並儲存結果。外觀是他們的;數字是你的。HotXLS 是一個原生 Delphi 與 C++Builder 函式庫,能在不驅動 Excel 的情況下讀寫 XLS 與 XLSX 活頁簿,它提供了這個做法所需的三項操作:依文字搜尋儲存格、連同樣式與公式完整地複製一個範圍,以及插入列,好讓下方的所有東西跟著資料一起下移
區分一個能在範本編輯後存活下來的產生器、與一個第一次就壞掉的產生器,唯一的規則就是絕不要用字面的列號與欄號來定址儲存格。範本是一份其他人會編輯的文件。財務團隊加了一列稅額、調高了標誌列的高度、重新排列地址區塊,而檔案格式一點也幫不上你:無論第 10 列是否還代表上季那個意思,BIFF 或 OOXML 的儲存都會成功。一個把第一條明細寫到寫死列號第 10 列的產生器,在某人第一次於明細區上方插入一個區塊時,就會把細項蓋到錯誤的儲存格上,並對一個不再涵蓋資料的總計範圍加總。什麼都不丟,每次儲存都回傳成功,唯一的訊號是客戶注意到一張錯誤的發票
把每個座標都錨定到一個佔位符記號
修正之道,是讓範本帶著它自己的座標。設計師把諸如 {{CUSTOMER}}、{{DATE}} 與 {{DETAIL_START}} 的記號寫進產生器必須碰的儲存格,產生器就在執行階段根據它找到這些記號的位置算出每個位置。版面編輯不再有影響,因為記號會跟著它所在的儲存格一起移動。這份合約的另一半是失敗規則:如果某個必要的記號不見了,這件工作就在任何客戶資料進到檔案之前停下來。一份已經漂移的範本應該產生一張失敗的工作單,而不是一份已交付的文件
尋找記號:FindText 與 ReplaceText
HotXLS 的兩個類別家族都公開了工作表層級的搜尋。FindText 回傳第一個文字相符的儲存格的列與欄,並有一個能加上大小寫區分的多載。ReplaceText 替換每一個出現處,並回傳它改了幾個。這兩者涵蓋了你通常會有的兩種記號。像客戶名稱這種單一錨點,你定位一次然後在旁邊寫入;像報表日期這種應該只出現一次的記號,你替換它並檢查計數。在 XLSX 那一側,一個這樣自我錨定的填入看起來像這樣:
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
R, C: Integer;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('invoice-template.xlsx') <> 1 then
raise Exception.Create('Cannot open invoice template');
Sheet := Book.Sheets[0]; // TXLSXSheets.Items 以 0 為基準
if not Sheet.FindText('{{CUSTOMER}}', R, C) then
raise Exception.Create('Template drift: {{CUSTOMER}} anchor missing');
Sheet.Cells[R, C].Value := 'ACME Corp';
if Sheet.ReplaceText('{{DATE}}',
FormatDateTime('yyyy-mm-dd', Date)) = 0 then
raise Exception.Create('Template drift: {{DATE}} token missing');
// 接下來的內容是明細展開與儲存
finally
Book.Free;
end;
end;
有兩個細節很重要。第一,FindText 與 ReplaceText 比對的是儲存格的文字值;一個嵌在公式字串裡的記號對它們來說是隱形的,所以佔位符記號屬於單純的儲存格,絕不要放在公式裡。第二,替換計數是你的漂移偵測器。一份應該只包含一個 {{DATE}} 記號、卻回報零次替換的範本,已經被編輯過了,而在那一刻引發例外,恰恰就是把無聲的版面漂移變成一個可見失敗的動作
複製明細列而不丟失樣式或公式
發票的明細區會隨著資料增長。把數值直接寫進範本列下方的空白列,會把設計師準備好的一切丟掉:邊框、數字格式、每列的公式。能保留這一切的 pattern,是在範本裡留下一列完整格式化的範本列,再為每個項目複製它。CopyRange 在單一呼叫裡複製樣式與公式,之後產生器只覆寫數值儲存格
const
DetailRow = 10; // 範本中已格式化的範例列
var
I: Integer;
begin
// 先在小計區塊前騰出空間,讓明細帶下方的
// SUM 範圍與資料一起伸展。
if Length(Items) > 1 then
Sheet.InsertRows(DetailRow + 1, Length(Items) - 1);
for I := 0 to High(Items) do
begin
if I > 0 then // 從範例列複製樣式與公式
Sheet.CopyRange(DetailRow, 1, DetailRow, 5, DetailRow + I, 1);
Sheet.Cells[DetailRow + I, 1].Value := Items[I].Name;
Sheet.Cells[DetailRow + I, 2].Value := Items[I].Qty;
Sheet.Cells[DetailRow + I, 3].Value := Items[I].UnitPrice;
Sheet.Cells[DetailRow + I, 4].Formula :=
Format('B%d*C%d', [DetailRow + I, DetailRow + I]); // 不帶 '=' 前綴
end;
end;
仔細看公式指派。XLSX 的 Formula 屬性接受的是沒有前導等號的運算式,而 XLS 外觀則預期透過 Value 指派 '=B10*C10'。混淆這兩種慣例是兩個類別家族之間最常見的移植錯誤,而且它毫無怨言地失敗:儲存格就只是持有一個 Excel 顯示為文字的字面字串。如果範本以合併的標題列來裝飾明細帶,請記住合併區域只有左上角儲存格帶有數值。版面驅動報表範本合併儲存格的配套文章裡的版面規則,解釋了為什麼合併區域應該完全位於資料帶之外
InsertRows 移動了什麼,又留下了什麼
在總計區塊之前插入列,正是讓一個 SUM 範圍隨著明細區增長而伸展的原因。在 XLSX 那一側,InsertRows 連同儲存格一起把一長串相依結構帶下來:合併範圍、列高、超連結、註解、凍結窗格、自動篩選範圍、條件式格式設定、資料驗證、表格、定義名稱,以及圖片與圖表錨點。那份清單裡有一個邊界值得記住。公式改寫只觸及同一張工作表內的參照。一張摘要工作表上一個指向被移動區域的公式,會保留它的舊座標並悄悄讀到錯誤的儲存格,這正是為什麼跨工作表拉過來的總計,用活頁簿層級名稱來表達更安全。定義名稱與跨工作表公式的配套文章走過了那個 pattern
舊式 XLS 格式把線畫在一個更硬的地方。HotXLS 在 BIFF 檔案裡把樞紐分析表、查詢表與外部資料連線保留為原始位元組區塊。它們在開啟與儲存時原封不動地存活,但它們沒有被建模,所以插入列從不碰它們。一份把樞紐分析表停在一個不斷擴張的明細區下方的範本,會在樞紐來源矩形漂離資料的同時毫無警示地儲存。出路是結構性的,而非防禦性的:把樞紐與查詢內容放在產生器永遠不會插入列的工作表上,陳舊就不可能發生
交付前重新計算,否則就要知道為什麼你跳過它
HotXLS 在 SaveAs 期間不評估公式。當一個人開啟檔案時,Excel 會重新計算一切(如果你需要引導它,XLS 外觀公開了 CalculationMode 與 RecalcOnSave),所以一份送往人類收件匣的報表不需要你多做什麼。當活頁簿餵給另一個程式的那一刻,情況就變了。CSV 匯出把公式以其字面文字寫出去,從不計算它們,而任何信任快取數值的下游解析器會讀到陳舊的數字或空白。對那些路徑,要在伺服器上用 Calculate 計算,它會針對載入的活頁簿評估一個任意運算式並交回結果:
var
Total: Variant;
LastDetail: Integer;
begin
LastDetail := DetailRow + Length(Items) - 1;
Total := Book.Calculate(Format('SUM(Invoice!D%d:D%d)',
[DetailRow, LastDetail]));
if (not VarIsNumeric(Total)) or
(Abs(Total - ExpectedTotal) > 0.005) then
raise Exception.Create('Invoice total does not match the order record');
if Book.SaveAs('invoice-2026-0611.xlsx') <> 1 then
raise Exception.Create('Save failed: check output path and permissions');
end;
在儲存之前,把計算出來的總計拿來對照訂單紀錄,是成本很低、回報很好的保險。它把一張錯誤的發票變成一件失敗的工作。操作員能在幾秒內重試一件失敗的工作;一張已經在客戶信箱裡的錯誤發票,則要花一位客戶經理一次道歉和一次更正
兩個類別家族,一套演算法
同樣的邏輯能在格式之間移植,但同樣的程式碼不行。用於舊式 .xls 的 TXLSWorkbook 是介面導向且參考計數的,工作表索引是 1 起算,而且你絕不手動釋放它。用於 .xlsx 的 TXLSXWorkbook 是一個你必須在 try..finally 裡釋放的單純物件,工作表索引是 0 起算,並使用上面展示的公式慣例。FindText、ReplaceText、CopyRange 與 InsertRows 全都存在於兩側,所以「錨定、複製、重新計算」的形狀能乾淨地帶過去。務實的建議是每條管線只認一種格式,或把兩個物件生命週期藏在你自己的一層薄介面卡後面,而不是把差異散布在整個產生器裡
對這個 pattern 產生的那種報表來說,大小鮮少有影響。把一列有樣式的列複製幾千次,對目前的硬體來說不算什麼。只有當明細帶跑到六位數的列時,儲存路徑才會成為瓶頸,而在那一刻,把 StreamingWrite 設上去會把工作表 XML 直接送進輸出封包,而不是緩衝它;伺服器批次工作串流寫入的文章涵蓋了何時值得做這個取捨。圖表的行為跟其餘版面一樣:在 XLSX 那一側,當 InsertRows 在它們上方執行時,圖表錨點與其數列參照都會移動,所以總計列下方的圖表會保持繫結到正確的資料,而在 XLS 那一側,圖表坐在自己的圖表工作表上,而且跟樞紐分析表一樣從不移動。這是另一個把展示用工作表與產生器擴充的那張工作表分開的理由
這套錨定、複製、重新計算的做法,讓設計師擁有一份活頁簿看起來的樣子,同時你的程式碼擁有它說的內容,而這通常就是讓生成的 Excel 輸出值得維護的原因。這裡展示的搜尋、複製與插入呼叫,連同用於交付前總計檢查的公式引擎,隨HotXLS Delphi Component(供 Delphi 與 C++Builder 使用)一起出貨