技術文章

用 HotXLS 在工作表內嵌 BIFF8 圖表物件

HotXLS 可以把圖表直接放在工作表上、錨定到某個儲存格範圍,而不是丟到一張專用的圖表工作表。用 BIFF8 的話來說,這意味著寫出一個帶 type 5 OBJ 記錄的繪圖形狀,並把圖表子串流停進工作表記錄串流的尾端——這正是 Excel 產出的版面,也正是讀取端預期找到它的位置

這個區分對任何在產營運報告的人都要緊。單一張壓軸視覺,圖表工作表是很好的家。每月分區拆解要的是圖表就在它摘要的那些數字旁邊,同一張工作表,大小貼著它所屬的那塊儲存格,讀者捲一次就好,不必切分頁籤、把上下文弄丟

讀取早就在了,寫出還沒有

這個不對稱值得點名,因為它決定了工程的形狀。HotXLS 本來就讀得了內嵌圖表:當工作表記錄串流裡出現一個標記為圖表子串流的 BOF,解析器切換上下文,收集圖表記錄,並在收尾的 EOF 把它們交回給 OBJ 記錄引進的那個繪圖形狀。這條路徑被程式庫開過的每一本 Excel 製作的工作簿反覆鍛鍊過

缺的是產生那一側,而有用的後果是:新的寫入端有一個精確的規格要命中——產出既有讀取端已經會重新接回的位元組版面。二進位格式功能能有的最佳驗收標準,莫過於一個獨立寫成、而且您無權更改的讀取端

內嵌圖表由什麼構成

有三個部件必須互相對上。繪圖層貢獻一個宿主控制項形狀,物件層貢獻一個 OBJ 記錄,其共同物件資料宣告物件類型 5,記錄串流則貢獻圖表子串流本身。OBJ 記錄上的選項旗標,就是 Excel 為圖表框寫的那幾個:positioned、locked、automatic line 與 automatic fill,使用者點下去時內嵌圖表像原生圖表一樣反應,靠的就是它們

HotXLS 透過三個互相對上的部件,把 BIFF8 圖表子串流錨定到 Delphi 工作表:繪圖層的宿主控制項形狀、共同物件資料宣告物件類型 5 的 OBJ 記錄,以及停在工作表記錄串流尾端的圖表記錄鏈——圖表 BOF 在此切換解析器上下文,收尾的 EOF 把記錄重新接回
三個層扛著一張內嵌圖表:繪圖形狀負責錨定,OBJ 記錄負責把它定型為圖表宿主,工作表串流尾端的圖表子串流供應讀取端重新接回的那些記錄

錨點值得加註,因為它是 off-by-one 錯誤的常見來源。HotXLS API 收列與欄的編號從 1 起算,與程式庫其他部分一致,而寫進檔案裡的 client anchor 是從 0 起算。轉換發生在 AddChartObject 內部,呼叫端因此可以留在自己到處都在用的座標系統;但任何拿十六進位傾印對照自己呼叫的人,都得記住自己讀的是這條界線的哪一邊

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Series: array[0..1] of TXLSChartSeriesInfo;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('regional-sales.xls');
    Sheet := Book.Sheets[0];

    FillChar(Series, SizeOf(Series), 0);
    Series[0].Name := 'Actual';
    Series[0].Categories := 'Data!$A$2:$A$13';
    Series[0].Values := 'Data!$B$2:$B$13';
    Series[0].DataLabels.ShowValue := True;
    Series[0].HasDataLabels := True;

    Series[1].Name := 'Target';
    Series[1].Categories := 'Data!$A$2:$A$13';
    Series[1].Values := 'Data!$C$2:$C$13';
    Series[1].SecondaryAxis := True;

    // 錨定在本工作表的 E2:M20,索引從 1 起
    Sheet.AddChartObject(xlsChartTypeColumn, 'Regional sales',
      'Month', 'Amount', Series, 2, 5, 20, 13);

    Book.SaveToFile('regional-sales-charted.xls');
  finally
    Book.Free;
  end;
end;

系列陣列上那個 FillChar 不是裝飾。TXLSChartSeriesInfo 帶著好幾個選用的子記錄——資料標籤、逐系列樣式、趨勢線與誤差線——各自由一個布林把關,堆疊上一個只初始化了一半的記錄,會把沒人設過的旗標交到發射器手上。先把陣列歸零,再設定您真的要的欄位

內嵌路徑接受哪些系列參照?

同一本工作簿內的純 A1 樣式範圍,而這個限制是刻意的,不是疏漏。每一個參照都會對著工作簿的 sheet 清單解析,轉成圖表記錄需要的外部參照索引。具名範圍或外部工作簿參照會退回成一個帶零長度已解析運算式的佔位符,所以圖表照樣乾淨寫出,只是那個系列在您把它指到某個範圍之前沒有資料來源

HotXLS 在內嵌 BIFF8 圖表路徑上的系列參照接受範圍:同一工作簿內 Data!$B$2:$B$13 這類 A1 樣式範圍會對 sheet 清單解析成圖表記錄需要的外部參照索引;具名範圍與外部工作簿參照則退回帶零長度已解析運算式的佔位符,兩者都由 AddChartSheet 覆蓋
只有同一工作簿內的純 A1 樣式範圍會編譯成圖表系列參照;其他一切先以佔位符乾淨寫出、直到被重新指位,完整路徑則在 AddChartSheet 上

理由是一筆直來直往的工程交易。完整的參照編譯路徑在圖表工作表路線上,包在工作表集合層裡,乾淨地把它抽出來,意味著為一個實務上不常見的案例複製一百行解析邏輯。內嵌圖表幾乎總是在畫自己這張 sheet 或隔壁資料 sheet 上的儲存格。具名與外部參照在圖表工作表路徑上經 AddChartSheet 都有覆蓋,所以沒有東西是不可用的,只是要從另一個入口進去

系列模型的其他一切在兩條路線上行為完全相同。副座標軸繫結、逐系列的線條、填滿與標記樣式、趨勢線、誤差線與資料標籤,全是 TXLSChartSeriesInfo 的一部分,也全以同樣方式發射,所以一個圖表定義可以在內嵌物件與圖表工作表之間搬動,只有呼叫本身要改。副座標軸旗標背後的軸群機制,記錄在BIFF 寫出的副座標軸群組

為什麼圖表標題只讀出兩個字元?

因為在該收位元組數的位置收到了字元數,而 BIFF Unicode 字串讓這個錯誤特別好寫、特別難看見。短的 BIFF Unicode 字串以一個字元數加一個旗標位元組開頭,旗標位元組帶著 high-byte 位元,說明酬載是每字元一個位元組還是兩個。拿字元數當位元組長度去讀一個 16 位元酬載,您剛好得到半個字串:名叫 Sales 的系列回來變成 Sa,圖表標題也以同樣方式被截斷,因為標題與系列標籤共用同一條解碼路徑

這個缺陷值得記一筆的地方,是它在同一族記錄裡復發了三次:一次在趨勢線名稱,一次在樞紐分析圖名稱,一次在圖表標題。每一次看起來都像新功能裡的新蟲。三次全是同一個漏掉的乘法。最終把它徹底了結的規則是機械式的,應該不帶判斷地套用:每當要讀這類字串,先查 high-byte 旗標,把字元數乘上酬載寬度,然後才碰緩衝區。記錄層級的細節在解碼 XLUnicodeString 字元數與 high-byte 旗標

// 內嵌圖表與影像及形狀共用繪圖層,
// 所以工作表上既有的繪圖會被保留。AddChartObject
// 回傳新建物件的索引
var
  ObjIndex: Integer;
begin
  ObjIndex := Sheet.AddChartObject(xlsChartTypeLine, 'Trend',
    'Week', 'Units', Series, 2, 8, 18, 16);
  if ObjIndex < 0 then
    raise Exception.Create('chart object was not created');
end;

內嵌圖表相對於其他選項的位置

路線有三條,回答的是不同的問題。內嵌圖表物件屬於工作表上資料的旁邊,是大多數報告要的。圖表工作表適合單一張簡報視覺,並給您完整的參照編譯路徑。把載入檔案裡既有的圖表原樣保留、一個位元組都不動,當工作簿來自 Excel、帶著沒人想讓程式庫重新詮釋的格式時,這才是對的答案;那個透傳行為記錄在保留的 ChartML 與組合圖

因為內嵌圖表搭的是繪圖層,它與同一張 sheet 上的影像與形狀共存,而不是取而代之,那個圖層的一般模型記錄在HotXLS 的圖表、影像與繪圖。三條路線都隨 HotXLS Delphi spreadsheet component 出貨,所以選擇的依據是報告該長什麼樣,而不是程式庫能表達什麼

值得留下的是方法論那一點。當一個二進位格式功能已經有讀取端,就對著讀取端建寫入端,而不是對著您對規格的解讀。讀取端編碼了多年與真實應用程式實際產出檔案的交手,包括規格寫得含糊的那些部分;滿足它的寫入端,滿足 Excel 的機率也大得多