任何漂浮在工作表格線之上的東西(圖表、標誌、戳記、標註方塊)都是繪圖物件,而繪圖物件由兩件事定義:它是什麼,以及它錨定在哪裡。錨點正是大家最容易弄錯的部分。圖表並非存在於儲存格中;它位於一個釘在某段列與欄上的矩形內,而它所繪製的資料是一組獨立的 A1 參照,錨點對此一無所知。移動外框,圖表內容不動;在外框下方插入列,外框會跟著向下滑動。把這兩套座標系統搞清楚,是讓繪圖程式碼正常運作的關鍵
HotXLS 是一個原生的 Object Pascal 程式庫,不需 Excel 自動化即可讀寫 XLS 與 XLSX,它攜帶兩套分開的繪圖模型,因為這兩種檔案格式儲存繪圖的方式不同。BIFF8 的 .xls 格式將圖表放在專屬的圖表工作表上,浮動形狀則放在附加於工作表的 OfficeArt 串流中。OOXML 的 .xlsx 格式可以將圖表內嵌在格線內,錨定到某個儲存格矩形,旁邊伴隨同類型的浮動圖片與形狀。物件模型反映了這種分裂,而值得探討的失誤,全都源自於把某個格式的規則套用到另一個格式上
哪種容器能容納什麼
容器的選擇必須早於任何圖表程式碼,因為兩種格式可用的物件型別並不相同:
- XLS (BIFF8):圖表存在於專屬的圖表工作表上,透過
Sheets集合的AddChartSheet建立。圖片、文字方塊、矩形、橢圓與線條則是 OfficeArt 形狀,透過工作表的Shapes集合管理。沒有任何 API 可以將圖表內嵌到一般工作表格線中 - XLSX (OOXML):圖表可以用
TXLSXWorksheet.AddChart直接內嵌到工作表中,錨定到某個儲存格矩形;或用TXLSXWorkbook.AddChartSheet放到專屬的圖表工作表上。圖片透過AddImage或AddImageFromFile加入,浮動標籤則用AddTextBox
所以一個被表述為「一張儀表板工作表,圖表就放在數字旁邊」的需求,其實就是對 .xlsx 的需求。你只能在 .xls 中用把圖表推到它自己的工作表來近似這個效果,但這會改變使用者瀏覽檔案的方式,也會改變你的程式碼必須表現的行為。XLS 端 AddChartSheet 傳回的工作表是一個圖表子串流,不是格線:用 Cells.Item 寫入它會產生不一致的繪圖串流,它不會報錯地生成,但 Excel 在開啟時會把它丟棄。圖表就這樣憑空消失,建置日誌裡什麼原因也沒說。把傳回的工作表當作僅供圖表使用,整類「圖表遺失」的回報就會消失
在 XLSX 工作表中內嵌圖表
XLSX 路徑是比較有迴旋空間的那條,開頭提到的兩套座標系統在這裡變得具體。傳給 AddChart 的錨點矩形是以工作表的列與欄表達的,決定了圖表外框的位置。數列資料則是以包含工作表名稱的絕對 A1 參照表達。兩者彼此獨立:你可以把外框移到工作表的另一頭,它依然繪製相同的儲存格
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Chart: TXLSXChart;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Sales');
Sheet.Cells[1, 1].Value := 'Region';
Sheet.Cells[1, 2].Value := 'Revenue';
Sheet.Cells[2, 1].Value := 'East';
Sheet.Cells[2, 2].Value := 1184350;
Sheet.Cells[3, 1].Value := 'Central';
Sheet.Cells[3, 2].Value := 902210;
Sheet.Cells[4, 1].Value := 'West';
Sheet.Cells[4, 2].Value := 1010675;
// 錨定到第 6..22 列、第 1..8 欄的框
Chart := Sheet.AddChart(xlsxChartColumn, 'Revenue by Region', 6, 1, 22, 8);
Chart.AddSeries('Revenue', 'Sales!$A$2:$A$4', 'Sales!$B$2:$B$4');
Chart.ValueAxisTitle := 'USD';
Sheet.AddImageFromFile(1, 5, 'logo.png');
Book.SaveAs('dashboard.xlsx');
finally
Book.Free;
end;
end;
會咬人的引數是交給 AddSeries 的範圍字串。它是一個字面值,在呼叫的那一刻被擷取下來,它完全不知道你可能之後還要再追加二十列資料。請用資料寫入「之後」算出的列數來組裝它,絕不要在寫入之前。散佈圖與泡泡圖會用不同涵義多載同樣的兩個引數:分類範圍現在供應 X 值,數值範圍供應 Y 值,泡泡半徑則來自透過傳回的 TXLSXChartSeries 之 BubbleSizeRange 設定的第三組參照。一旦你離開直條與橫條家族,就要把呼叫讀成「X、Y、大小」,而不是「分類、數值」
TXLSXChartType 涵蓋直條圖、橫條圖、折線圖、圓形圖、區域圖、圓環圖、散佈圖、泡泡圖與雷達圖,足以應付日常報表的曲目。若要沒有周圍格線的全頁圖表,Book.AddChartSheet 會傳回一個 IsChartSheet 屬性為 true 的工作表。它是舊式圖表工作表的 .xlsx 對應版本,並帶有同樣的期望:不要對它寫入儲存格內容
圖片以位元組加入,並以 EMU 計量尺寸
插入圖片有兩個多載,而把兩者搞混,正是程式碼審查中最常出現的圖片錯誤。AddImage(ARow, ACol, AData, AFormat) 要的是 AData 中已編碼好的圖片位元組:PNG、JPEG、GIF 或 BMP 的原始內容。你若傳給它一個檔案路徑,就等於存了一段沒有任何檢視器能解碼的四十個位元組字串,這正是你部署後最不想去除錯的那種「破損圖片圖示」回報。當來源是磁碟上的檔案時,請改呼叫 AddImageFromFile,讓程式庫替你讀取位元組並分類格式
接下來是尺寸。DrawingML 不以像素計量;它以 English Metric Units 計量,其中 914400 EMU 等於一英吋,而在 96 DPI 下 9525 EMU 等於一像素。TXLSXImage 物件公開了 WidthEMU 與 HeightEMU,所以一個打算顯示成 180 乘 60 像素的標誌,需要 1714500 乘 571500 EMU。把這個換算放進一個具名常數,並依此計算。散布在程式碼裡的 1714500 這種魔術數字既難讀,又在有人第一次改變目標 DPI 時悄悄出錯。順帶一提,錨點的列與欄是從 1 開始計數的,與其他儲存格 API 一致,而不是 EMU 數學的從 0 開始
舊式 XLS 檔案的圖表工作表與形狀
在 BIFF8 端,較豐富的 AddChartSheet 多載接受圖表型別、座標軸標題,以及一個 TXLSChartSeriesInfo 記錄的開放陣列,其中每筆記錄以字串持有名稱與分類、數值範圍。浮動形狀是另一回事:它們放在資料工作表本身上,透過它的 Shapes 集合,而不是放在圖表工作表上
var
Book: IXLSWorkbook;
Data, Trend: IXLSWorksheet;
Series: array[0..0] of TXLSChartSeriesInfo;
begin
Book := TXLSWorkbook.Create; // 以介面計數:不要 Free
Data := Book.Sheets.Add;
Data.Name := 'Data';
Data.Cells.Item[1, 1].Value := 'Month';
Data.Cells.Item[1, 2].Value := 'Units';
Data.Cells.Item[2, 1].Value := 'Apr';
Data.Cells.Item[2, 2].Value := 1530;
Data.Cells.Item[3, 1].Value := 'May';
Data.Cells.Item[3, 2].Value := 1721;
Series[0].Name := 'Units';
Series[0].Categories := 'Data!$A$2:$A$3';
Series[0].Values := 'Data!$B$2:$B$3';
Trend := Book.Sheets.AddChartSheet('Trend', xlsChartTypeLine,
'Units sold', 'Month', 'Units', Series);
// Trend 是圖表子串流:絕不要對它呼叫儲存格方法
Data.Shapes.AddTextBox('Source: ERP nightly export', 6, 1, 8, 4);
Data.Shapes.AddPicture('approved-stamp.bmp');
Book.SaveAs('trend.xls');
end;
這裡有兩個生命週期細節很重要,而且兩者方向相反。TXLSWorkbook 透過 IXLSWorkbook 介面持有,是參考計數的,所以你自己對它呼叫 Free 會觸發重複釋放。前面幾節的 TXLSXWorkbook 則是一個普通物件,必須在 try..finally 中釋放。同一位程式碼審查者,在 XLSX 端會標記漏掉的 Free,到了 XLS 端卻得標記多出來的那個,當你在同一個單元裡同時處理兩種格式時,這是一個真實的絆腳石。形狀輔助函式本身則是統一的:AddRectangle、AddOval 與 AddLine,加上用來清空某區域繪圖的 DeleteInRange,全部以列與欄成對錨定,所以在它們上方插入列的範本會把它們連同格線一起移動
還有一個屬性在舊式檔案上特別好用。TXLSPicture.TransparentColor 會從點陣圖中遮掉所選的背景顏色,這正是你在一個其 BIFF 繪製從未學會 PNG 透明色的格式中,把一個非矩形戳記(「Approved」封印、浮水印)疊在格線上的方法。把戳記原本製作時所用的顏色設好,周圍的矩形就會消失
佈景主題顏色無法在 BIFF8 往返中存活
OOXML 繪圖填色可以指向某個佈景主題顏色位置,這也是為什麼透過替換佈景主題就能重新上色整份 .xlsx 而代價很低。BIFF8 的繪圖記錄沒有這種位置。當 HotXLS 將某個佈景主題顏色套用到 XLS 繪圖時,它會把顏色解析成字面 RGB 值並儲存它;它所來自的佈景主題索引在檔案寫入的那一刻就不見了,重新開啟也無法復原。這特別會困住白標報表工具,那種把同一份產生的文件為許多客戶重新品牌的工具。請把佈景主題對 RGB 的對照保留在你自己的設定裡,並在每次產生時重新套用,而不是指望能從已儲存的 .xls 裡讀回來
另一個相關的決定出現在效能面上。當你對某個大型舊式檔案想要的只是它的儲存格資料時,可以告訴 XLS 外觀層完全略過解析繪圖圖層,只要把 _DisableGraphics 設為 true,這會為大量讀取省下可觀的時間。但代價是永久的:用這種方式開啟的工作表在記憶體中沒有 OfficeArt 串流,所以儲存它會讓繪圖就此消失。請把這個旗標保留給唯讀的分析工作。更廣的效能全貌請見我們對 HotXLS 大型活頁簿效能的筆記
在格線變動時保持錨點穩定
報表很少停留在它們被產生時的大小,而這正是開頭那個錨點模型發揮作用的地方。XLSX 外觀層的結構操作(InsertRows、DeleteRows,以及欄的對應版本)會把相依的圖層連同儲存格一起移動。合併區域、超連結、註解、凍結窗格、篩選範圍、條件格式、驗證、表格、已定義名稱,以及——就本主題而言——圖片與圖表錨點,全部一起移動。錨定在第 1 列的標誌,當下方插入了十列時仍留在頂端。錨定在資料區塊下方的圖表外框,會隨區塊長大而向下滑。唯一「不」會被改寫的,是你在插入發生「之前」擷取為字面值的任何範圍字串,因為它只是文字,程式庫沒有理由重新檢視它。這固定了範本填寫的安全順序:先寫入並重塑資料,最後一道才建立圖表並放置圖片,而且每一個範圍字串都從插入「之後」的列數衍生,而不是之前
兩個小工具完成了放置套件組。XLS 端的 TXLSTextBox.SetArea 會把既有的文字方塊或自動形狀重新錨定到新的儲存格矩形,這在頁尾區塊移位時,比刪除再重建來得好。而 AddPicture 的點陣圖多載接受一個現生的 TBitmap 加上選用的透明旗標,所以任何你自己的 VCL 程式碼能畫出來的東西(量錶、走勢線帶、原生清單未提供的圖表型別)都可以直接蓋到工作表上,不必先寫一個暫存檔
圖表與圖片幾乎永遠是已結構化報表上的收尾圖層,這也是為什麼基礎工作決定了它們能否乾淨落地。填入圖表將參照的資料涵蓋在範本驅動的報表產生中,而讓錨點下方的格線保持穩定則是合併儲存格與版面控制的主題。完整的類別與方法文件位於HotXLS Delphi Component 產品頁