技術文章

用 HotXLS 在 Delphi 中寫 Excel 瀑布圖與矩形樹狀圖

HotXLS 把 Excel 瀑布圖與矩形樹狀圖寫成獨立的 cx:chartSpace 部件——xl/charts/chartExN.xml,內容類型是 application/vnd.ms-office.chartex+xml——再用 mc:AlternateContent 包一層來錨定,讓 Excel 2016 與之後的版本渲染圖表,舊版讀取端則拿到一個後備圖形。選 xlsxChartWaterfall 或 xlsxChartTreemap,整條部件管線就跟著走

這個問題通常是以一張技術支援單的形式自我介紹。客戶寄來他們財務團隊用 Excel 2019 做的活頁簿,你的報表工具打開它、存回去,瀑布圖就不見了。沒有例外、沒有警告、日誌裡什麼都沒有——圖表就是不在輸出裡。你要是去傳統圖表的程式路徑找它,會找很久,因為它從頭到尾就不在那裡

HotXLS 為 chartEx 圖表寫出的 mc:AlternateContent 錨定結構:mc:Choice 分支要求 cx1 並包著 xdr:graphicFrame,其 a:graphicData 以裸的 cx:chart 關聯指向 chartex 命名空間,而 mc:Fallback 放著一張舊版讀取端改畫的通知矩形
mc:AlternateContent 錨定結構在 Excel 2016 與舊版讀取端之間替 chartEx 圖表分流:Choice 分支要求 cx1 並帶著 chartex 框架,Fallback 則放著舊版讀取端必須畫的通知矩形

為什麼瀑布圖與矩形樹狀圖不在 c:chartSpace 裡?

因為微軟根本放不進去。ECMA-376 Part 1 §21.2 的 DrawingML 圖表 schema 把圖表元素——c:barChart、c:lineChart、c:pieChart 還有其他——列成封閉的選擇清單,所以要在 Excel 2016 加入瀑布圖、矩形樹狀圖、旭日圖與漏斗圖,就等於打破每個既有讀取端都依賴的 schema。新類型改放進一個平行的文件:2014 chartex 命名空間(http://schemas.microsoft.com/office/drawing/2014/chartex)下的 cx:chartSpace 部件,與傳統圖表並排存放,結構上卻互不相干。繪圖那一側用 mc:AlternateContent 把兩個世界縫在一起,這是 ECMA-376 Part 3 的相容機制:mc:Choice 分支宣告 Requires="cx1" 並包著 xdr:graphicFrame,其 a:graphicData/@uri 是 chartex 命名空間,內容則是一個裸的 <cx:chart r:id="rIdN"/> 參照;mc:Fallback 放著 Excel 自己寫在那裡的通知矩形。看不懂 cx1 的讀取端,依 Part 3 的契約必須改走後備

關聯類型是另一個線索。傳統圖表用標準 OOXML 圖表關聯掛在繪圖部件下;chartEx 部件改用 http://schemas.microsoft.com/office/2014/relationships/chartEx,這在 [MS-ODRAWXML] 有記載。HotXLS 把兩個世界都暴露成常數——XlsxRtChartEx 是那個關聯、XlsxCtChartEx 是內容類型、XlsxNsMsChartEx 與 XlsxNsMsChartEx2015 是錨定需要的兩個命名空間——而 XlsxChartIsChartEx 這個述詞負責在儲存時把圖表導向延伸路徑。組合圖表與保留下來的傳統圖表 XML 走另一條路,見保留圖表 XML 與組合圖表一文

四條決定 Excel 開不開得了檔案的 schema 規則

chartex schema 有四個地方比傳統 schema 更嚴格,每一處寫錯都會得到同一種沒什麼幫助的修復提示。HotXLS 的 BuildChartExKnownXml 與 BuildChartExRelsXml 把四條全部內建,但如果你哪天要手工檢查 XML,這些規則值得知道

  • 順序。cx:chartData 在文件裡必須排在 cx:chart 之前。這跟傳統圖表建立起的直覺正好相反——傳統圖表的快取活在繪圖區裡的數列內部;chartEx 部件把資料放在前面自己的區塊,圖表本體只用 id 參照它
  • 基數。cx:plotArea 只能包含一個 cx:plotAreaRegion。不是零個、不是兩個。這裡沒有組合圖表的故事——一個區域,數列在裡面
  • 型別的位置。圖表型別根本不是元素名稱。它是 cx:series 上的 layoutId 屬性,取值來自 Microsoft chartex schema 的 ST_SeriesLayout 列舉。XlsxChartExLayoutId 把 HotXLS 的列舉對映到那個 token,回傳 'waterfall' 或 'treemap';XlsxChartExLayoutIdToType 在讀取時反向跑這個對映
  • 傳統圖表永遠不需要的關聯。cx:chartData 裡有一個 <cx:externalData r:id="rId1"/> 元素,這個關聯必須存在於 xl/charts/_rels/chartExN.xml.rels,指回擁有資料的工作表,因為 Excel 靠它解析 cx:f 範圍公式。少了 rels 部件,圖表會開成空白的,而且沒有任何東西解釋原因

怎麼在 Delphi 裡加一張瀑布圖或矩形樹狀圖?

跟加其他任何圖表一樣——延伸類型是 TXLSXChartType 的成員,所以 TXLSXWorksheet.AddChart 直接收它們,下游一切全部以列舉值為鍵。沒有另一套 chartEx API 要學

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Chart: TXLSXChart;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('cashflow.xlsx');
    Sheet := Book.Sheets[0];

    // 錨定矩形以列/欄座標表示,與傳統圖表相同
    Chart := Sheet.AddChart(xlsxChartWaterfall, 'Q3 Cash Flow',
      2, 5, 20, 12);
    Chart.AddSeries('Movement', 'Sheet1!$A$2:$A$9', 'Sheet1!$B$2:$B$9');

    Book.SaveAs('cashflow-waterfall.xlsx');
  finally
    Book.Free;
  end;
end;
HotXLS 儲存延伸 Excel 圖表的管線:AddChart 搭配 xlsxChartWaterfall 或 xlsxChartTreemap 會走到 XlsxChartIsChartEx,它把傳統圖表導向共用的計數器、chartEx 圖表導向獨立的計數器,後者寫出 chartEx1.xml、指回工作表的 rels 檔、chartex 內容類型覆寫與 AlternateContent 錨定內容
一個圖表模型加一個分流述詞,在儲存時餵兩個計數器:傳統圖表共用 chart1.xml 與 chart2.xml,瀑布圖則落成 chartEx1.xml,因為 chartEx 計數器與傳統計數器互不相干

儲存時,HotXLS 看到 XlsxChartIsChartEx 回傳 True,就從獨立的 chartEx 計數器分配部件名稱,寫出 xl/charts/chartEx1.xml、它的 rels 檔、內容類型覆寫,以及 XlsxChartExAnchorPayloadXml 產生的 mc:AlternateContent 錨定。獨立計數器很重要:一個活頁簿有兩張傳統圖表和一張瀑布圖,會得到 chart1.xml、chart2.xml 與 chartEx1.xml,而不是 chartEx3.xml。矩形樹狀圖與瀑布圖的差異只有兩處細節,都由同一個建置器處理。數值維度換型別——瀑布圖的 cx:numDim type="val" 在矩形樹狀圖變成 type="size",因為矩形樹狀圖的矩形用面積編碼大小,而不是畫成被繪製的值——而且矩形樹狀圖通常要類別階層,模型用 TXLSXChartSeries.CategoriesMultiLevel 表達。設定這個旗標後,S.ResolveCategoryLevels 在寫入時把範圍從工作表讀出來,在 S.CategoryLevelCount 與 S.CategoryLevels[] 背後拆成每層一個 TXLSXChartDataCache,BuildChartExLevelXml 則在單一個 cx:strDim 裡每層輸出一份 cx:lvl,葉層在前——這跟 ECMA-376 Part 1 §21.2 用於 c:multiLvlStrCache 的實體排序相同,所以這個慣例沿用下來,即使標記方式沒有一處相同

Chart := Sheet.AddChart(xlsxChartTreemap, 'Revenue by Region',
  2, 8, 24, 16);
S := Chart.AddSeries('Revenue',
  'Sheet1!$A$2:$B$40',   // 兩欄:葉標籤,接著是父群組
  'Sheet1!$C$2:$C$40');
S.CategoriesMultiLevel := True;

打破傳統圖表一切假設的快取形狀

如果你寫過傳統圖表快取的讀取器,有三個習慣會在 chartEx 部件上背叛你,而且三者來自同一個設計決策:chartex 把 DrawingML 放在子元素裡的東西,改放進屬性與元素內容

  • 一個快取點是 <cx:pt idx="0">42</cx:pt>。值就是元素內容。沒有 c:v 子元素可以深入,所以找 v 節點的剖析器什麼都找不到,回報一個空的快取
  • ptCount 與 formatCode 是 cx:lvl 上的屬性。在傳統的 c:numCache 裡兩者都是子元素
  • 多層級階層是單一個 cx:strDim 內的多個 cx:lvl 元素。沒有像 c:multiLvlStrCache 獨立於 c:strCache 那樣的獨立多層級快取元素

BuildChartExLevelXml 寫出這個形狀,ParseChartExXml 把它讀回來,輸出的每個值都過一遍 XlsxEscapeText,所以含有 &amp; 或角括號的類別標籤能安全走完 round trip。值得注意建置器在數值層沒有格式碼時的做法:它寫 formatCode="General" 而不是丟掉屬性,因為在這裡屬性不像傳統版本那樣可以省略

HotXLS chartEx 部件中快取形狀的並排比較:傳統 c:numCache 把 ptCount 與 formatCode 存成子元素、值放在 c:v 內,而 cx:numDim 把它們存成 cx:lvl 上的屬性、值當元素內容,CategoriesMultiLevel 則在單一個 cx:strDim 內每個類別層輸出一份 cx:lvl,葉層在前
chartex 把 DrawingML 放在子元素裡的東西改放進屬性與元素內容:值直接坐在 cx:pt 內,ptCount 與 formatCode 變成 cx:lvl 上的屬性,類別階層則是單一個 cx:strDim 裡的幾個 cx:lvl 元素

讓型別讀回失敗的命名空間前置詞

HotXLS chartEx 剖析器的第一個版本寫得出好端端的檔案,卻把每一份都讀成直條圖。根因只有一行,這個教訓適用於你將來要寫的任何 OOXML 剖析器:TXMLReader.Name 回傳的是文件裡實際寫著的名稱,含前置詞。傳統圖表部件裡大部分元素沒有前置詞,所以 SameText(Reader.Name, 'series') 能用,你就不再想它。chartEx 部件裡每個元素都有前置詞——讀取器交給你 'cx:series',比較靜默地全部失敗,沒有例外、沒有畸形 XML,只有空的數列清單和一個退回預設值的圖表型別。修法是比較 local name,集中在一處而不是每個呼叫點

// Reader.Name 帶有命名空間前置詞:'cx:series',而非 'series'
function IsEl(const LocalName: string): Boolean;
var
  NodeName: string;
begin
  NodeName := String(Reader.Name);
  Result := SameText(NodeName, LocalName) or
            EndsText(':' + LocalName, NodeName);
end;

ParseChartExXml 對每個元素測試都用這個 helper,屬性則用對等的 AttrVal helper,因為 cx:autoUpdate 之類也都有前置詞。一旦數列元素真的被看到了,型別就從碰到的第一個 layoutId 讀回來,每個 cx:series 用 cx:dataId/@val 對上 cx:data/@id 的方式接到它的 cx:data 區塊。開過但從未修改的圖表改走比較舊的路徑:原始位元組從 FRawChartXml 重播,所以客戶的檔案原封不動進來、原封不動出去——這正是迷你圖延伸文章裡規範工作表層級延伸的那套保守寫入者紀律

HotXLS 刻意不對這些圖表做的事

有兩條界線值得講清楚,因為兩者都是選擇,不是等著被補上的缺口。內建的 PDF 與 SVG 渲染器不畫瀑布圖或矩形樹狀圖的數列:這些型別配不上任何繪圖分支,所以數列渲染被跳過,工作表其餘部分照常匯出——在匯出的 PDF 裡偽造一張看起來合理的矩形樹狀圖,比什麼都不畫更糟,因為它會靜默地跟 Excel 對同一個檔案顯示的內容不一致。而 xlsxChartWaterfall 與 xlsxChartTreemap 是 HotXLS 從模型建置的僅有兩種延伸版面;ST_SeriesLayout 還定義了旭日圖、漏斗圖、盒鬚圖與直方圖,含有其中任何一種的活頁簿仍然能經由 raw-XML 重播路徑走完 round trip,只是你今天無法用 Delphi 程式碼從頭建立一張。傳統圖表類型的渲染沒有改變,見圖表、圖片與繪圖一文,chartEx 管線與它一起隨HotXLS Delphi Excel Component提供,支援 Delphi 與 C++Builder