技術文章

Delphi HotXLS BIFF8 圖表次座標軸群組

HotXLS 透過發出第二個 AxisParent 區塊來寫入 BIFF8 圖表次座標軸群組,而不是在軸之後追加第二個圖表群組。在 Classic XLS 圖表子串流中,每個圖表群組——ChartFormat、圖表類型記錄和 CrtLink——都位於自己的軸群組區塊內,每個序列透過 SerToCrt 綁定到其中一個群組。把這個巢狀關係寫反後,無論發出多少軸記錄,都不會有第二個圖表群組可供序列綁定

為什麼軸之後的第二個圖表群組什麼也綁定不到

答案就在語法中,而且只有一行 ABNF。[MS-XLS] 2.1.7.20.1 的 CHARTFOMATS 規則寫著 AxesUsed 1*2AXISPARENT,隨後又明確寫出 AXISPARENT = AxisParent Begin Pos [AXES] 1*4CRT End。把這兩個產生式放在一起讀,結構就清楚了:圖表群組是軸群組的子項,不是軸群組的兄弟項。帶有兩對軸和一個尾隨圖表群組的圖表子串流,並不是版面有點奇怪的雙軸圖表,而是一個帶有一個圖表群組和一組孤立軸記錄的圖表。這很重要,因為位於 SERIESFORMAT 區塊中的 SerToCrt($1045)攜帶的是從 0 開始的圖表群組索引,而不是軸索引。當只存在一個 CRT 區塊時寫入 crt = 1,就會把序列指向一個從未發出的圖表群組。讓人誤判的直覺來自記錄名稱:AXESUSED($1046)聽起來像是在計數軸,於是自然的下一步就是發出更多軸。實際上它計算的是軸群組,每個軸群組都會帶來完整的繪圖區和圖表群組

把序列標記到次座標軸群組

在 HotXLS 端,這歸結為一個 Boolean。TXLSChartSeriesInfo 帶有 SecondaryAxis 欄位,在傳給 TXLSWorksheets.AddChartSheet 的陣列中為任一序列設定它,就會把整個建構器切換到雙群組模式。沒有獨立的「啟用次座標軸」呼叫,也沒有軸計數參數,因為計數可以推導:只要有任一序列需要次群組,圖表就需要兩個群組

var
  Wb: TXLSWorkbook;
  Series: array [0..1] of TXLSChartSeriesInfo;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Sheets.Add.Name := 'Data';
    // ... 用類別、收入和利潤率填入 A1:C12 ...

    Series[0] := Default(TXLSChartSeriesInfo);   // 絕不要對這個記錄使用 FillChar
    Series[0].Name := 'Revenue';
    Series[0].Categories := 'Data!$A$1:$A$12';
    Series[0].Values := 'Data!$B$1:$B$12';

    Series[1] := Default(TXLSChartSeriesInfo);
    Series[1].Name := 'Margin';
    Series[1].Categories := 'Data!$A$1:$A$12';
    Series[1].Values := 'Data!$C$1:$C$12';
    Series[1].SecondaryAxis := True;             // AXESUSED 變成 2

    Wb.Sheets.AddChartSheet('Dual Axis', xlsChartTypeLine,
      'Revenue vs Margin', '', '', Series);
    Wb.SaveAs('dual-axis.xls', xlExcel97);
  finally
    Wb.Free;
  end;
end;

Default(TXLSChartSeriesInfo) 這一行不是裝飾。TXLSChartSeriesInfo 將受管理欄位(WideString 名稱、動態趨勢線和誤差線陣列)與普通 Boolean 成員混在一起,而 Delphi 只保證會為你清除受管理欄位。如果不初始化 SecondaryAxis,它就是堆疊上的任意值,實際結果可能是同一個二進位檔在主控台宿主中執行時產生單軸圖表,在測試執行器下卻產生雙軸圖表。另一方面,類別和值範圍會在建構器看到它們之前透過活頁簿的 EXTERNSHEET 表解析——這與HotXLS 如何分類 BIFF SupBook 和 XTI 外部連結中介紹的同一索引機制有關——因此引用未知工作表的範圍會退化為一個空的 BRAI 佔位符,而不是讓建置失敗

序列為次座標軸時 HotXLS 會發出什麼

發射器改變的是形狀,而不只是數值。沒有次序列時,AXESUSED 為 1(圓餅圖和 3D 圓餅圖沒有軸群組,因此為 0),隨後跟著一個 AxisParent 區塊。有次序列時,AXESUSED 為 2,建構器執行兩次該區塊,iax——18 位元組 AxisParent($1041)載荷的第一個字——先設為 0,再設為 1。每次都會發出 Pos、類別軸和值軸(Axis,$101D)、PlotArea 標記($1035)、預設 Frame,然後是 ChartFormat($1014)、圖表類型記錄、CrtLink($1022),以及兩個 End 標記來關閉圖表群組和軸群組。次序列隨後以 SerToCrt crt = 1 綁定,主序列繼續使用 crt = 0。有一筆記錄有意不複製:圖例只在第一組發出,因為無論帶多少軸群組,Excel 都給一個圖表一個圖例。還有兩項屬性值得直說。重構發射器、把圖表群組放進軸父區塊後,普通圖表的輸出沒有改變——沒有次序列時,子串流與舊版本逐位元組相同,因為將 AddAxisParent 參數化為 iax = 0 正是原來的程式碼路徑。建構器仍會為每個群組發出完整的軸對,因此次群組始終帶有自己的類別軸,即使你只關心它的數值刻度

圖表檢查如何復原軸群組繫結

讀取需要對記錄清單執行兩遍,因為 AXESUSED 出現在它描述的區塊之前。第一遍只尋找 $1046,並將其第一個字讀為軸群組計數。這個值從 1 開始,只增不減:HotXLS 取目前計數和宣告計數的最大值,因此格式錯誤或重複的 AXESUSED 不會讓已經發現宣告為兩個群組的圖表退回。第二遍追蹤目前軸群組,在每個 AxisParent 處更新,並將該索引寫入它遇到的每筆 Axis 記錄,直到下一個 AxisParent 出現

var
  Model: TXLSChartModel;
  i: Integer;
begin
  // Sheets[1] 是資料工作表,Sheets[2] 是圖表工作表
  Model := Wb.Sheets[2]._Chart.GetChartModel;
  try
    if Model.AxisGroupCount = 2 then
      Writeln('AXESUSED declares a secondary axis group');
    for i := 0 to Model.AxisCount - 1 do
      Writeln('axis ', i, ' group ', Model.GetAxis(i).AxisGroup);
    for i := 0 to Model.SeriesCount - 1 do
      Writeln('series ', i, ' chart group ', Model.GetSeries(i).ChartGroup);
  finally
    Model.Free;
  end;
end;

有兩項限制值得命名。TXLSChartModel.AxisGroupCount 回報的是檔案宣告的數量,而不是實際找到的 AxisParent 區塊數量;一個宣告為 2 卻只提供一個區塊的檔案會回報 2,而 AxisCount 才是發現問題的位置。TXLSChartAxis.AxisGroup 則是位置標記:它記錄軸是在讀取哪個區塊時遇到的,這就是格式唯一告訴你的資訊。在序列端,SerToCrt 解碼只有位於 Series 區塊內部時才會啟用,因為同一個記錄 ID 也會出現在不是序列繫結的上下文中;沒有上下文限制的解碼器會輕易覆蓋錯誤的序列

沒有真實 Excel 檔案時如何驗證次座標軸

這裡的驗證不需要帶次座標軸的 Excel 檔案,這正是故事中有用的部分。結構解碼是記錄序列的屬性,因此合成序列和擷取序列一樣可以精確證明它。回歸測試建構載荷為 2 的 AXESUSED,然後建構兩個 AxisParent 區塊,每個區塊包住一個類別軸和一個值軸,並斷言模型回傳 AxisGroupCount = 2,四個軸的標記依序為 0、0、1、1,第二對軸的類型也符合預期

// 完全不需要 Excel 檔案的結構驗證
Chart := TXLSCustomChart.Create(nil, $0600);
try
  AddWordRecord($1046, [2]);   // AXESUSED:兩個軸群組
  AddAxisParentGroup(0);       // AxisParent iax=0 + Begin + 2 Axis + End
  AddAxisParentGroup(1);       // AxisParent iax=1 + Begin + 2 Axis + End

  Model := Chart.GetChartModel;
  Assert.AreEqual(2, Model.AxisGroupCount);
  Assert.AreEqual(4, Model.AxisCount);
  Assert.AreEqual(0, Model.GetAxis(1).AxisGroup);
  Assert.AreEqual(1, Model.GetAxis(2).AxisGroup);
finally
  Model.Free;
  Chart.Free;
end;

如果你自己合成記錄,還需要注意兩點。TXLSCustomChart.AddData(RecID, Len, nil)Len 非零時會解參照載荷,因此 Begin($1033)和 End($1034)標記必須以零長度新增,不能使用 nil blob 和過期長度。合成序列只能證明解碼器,不能證明 Excel 接受你的輸出——寫入端根據 ABNF 組織,然後透過 GetChartModel 往返檢查,斷言每個軸的 0、0、1、1 標記以及每個序列的 0、1 圖表群組,並以無次座標軸路徑逐位元組不變作為安全網。這正是HotXLS Delphi 圖表、影像和繪圖支援其餘部分採用的保守姿態:按記錄所說的內容解碼,拒絕猜測未在規範中讀到的二進位版面。寫入端完成後,完整 Delphi 套件在 Win32 和 Win64 上均通過了 1650 項中的 1650 項

Chart3d 場景參數與 fAuto 陷阱

當你超出預設圖表時,有兩個相鄰細節會造成麻煩。第一是 Chart3d($103A,[MS-XLS] 2.4.46),這是 3D 變體在圖表群組內發出的固定 14 位元組載荷:anRot(旋轉,0 到 360)、anElev(仰角,有符號,-90 到 90)、pcDist(透視距離,0 到 100,只有設定 fPerspective 時才使用)、pcHeightpcDepth(圖表寬度的百分比,5 到 500)、pcGap(0 到 500),以及一個 grbit,其中各位元為 fPerspective $0001、fCluster $0002、fAutoscale $0004、f3DScaling $0010 和 f2DWalls $0020。規範還增加了記錄版面不會替你執行的限制:轉置長條圖上的 anRotanElev 不得超過 44,圓餅圖上的 anElev 不得為負

第二是 fAuto 位元,它會產生「我的顏色被忽略了」的問題報告。LineFormat($1007)、AreaFormat($100A)和 MarkerFormat($1009)都在 grbit 的第 0 位元攜帶 fAuto;設定該位元時,Excel 會套用自動樣式,並把旁邊的明確 RGB 值、線型、標記形狀和標記大小視為裝飾。任何寫入自訂序列樣式的發射器都必須清除第 0 位元;預設發射器則有意保留它,讓 Excel 選擇調色盤。如果你是在編輯現有活頁簿而不是建構新活頁簿,保留規則又不同,詳見編輯 Excel 圖表而不遺失保留的 ChartML

次座標軸群組、SerToCrt 繫結以及這裡展示的具型別圖表模型,都屬於面向 Delphi 和 C++Builder 的 HotXLS Delphi 試算表元件,無需安裝 Excel 即可讀寫 BIFF8 圖表;產品頁提供完整的圖表記錄參考和 AddChartSheet 多載清單