新版 Excel 存出的圖表部件,很少停在 ECMA-376 圖表結構描述定義的元素上。圖表空間通常以一個 mc:AlternateContent 區塊開場,對新讀取端提供 c14:style、對舊讀取端提供樸素的 c:style;每條數列帶著含 c16:uniqueId 的 c:extLst;「從儲存格取值」的資料標籤則住在另一個延伸裡。只剖析自己建模的部分、儲存時重新產生整個部件的程式,這些東西一樣都寫不回去。標題裡改一個字,在 Excel 裡重新開檔,那些從儲存格範圍取文字的標籤就消失了
解法是別把儲存當成重建。保留圖表帶進來的位元組;模型沒動時原樣寫回;模型動了時,把新元素合併進原始樹,讓那些陌生部分留在 Excel 放它們的地方
沒有變動時,重播原始 ChartML
TXLSXWorkbook.PreserveUnsupportedParts 為 True 時——新建活頁簿的預設值——HotXLS 做的正是這件事。匯入時,每個圖表保留原始 UTF-8 位元組,連同長度與強型別模型所指內容的 64 位元指紋。儲存時,HotXLS 重新產生已建模部分、比對長度與指紋,兩者都相符就把存放的位元組原樣寫回:沒有元素重排、沒有空白漂移,記憶體裡也不會為每個沒人碰的圖表留第二份 UTF-16 副本
因為比對的對象是內容,把屬性指派成它本來的值等於什麼都沒做。TXLSXChart 的 setter 在值相等時提前返回,所以一個從資料庫資料列「重新整理」每個圖表標題的迴圈,仍然會重播每個標題本來就正確的圖表。誤差線與趨勢線的線條格式由自己的變更旗標追蹤,動它就一定算編輯
TXLSXChart.HasPreservedXml 在你動手編輯之前,就告訴你這個圖表會走哪條路。圖表上掛著原始 ChartML 時它回報 True,而這是重播與合併兩條路的共同前提
var
Workbook: TXLSXWorkbook;
Chart: TXLSXChart;
begin
Workbook := TXLSXWorkbook.Create;
try
// 預設為 True;明確設定讓意圖在重構後仍存活。
// 必須在 Open 之前設好,因為 Open 會建立這份狀態
Workbook.PreserveUnsupportedParts := True;
Workbook.Open('quarterly-review.xlsx');
Chart := Workbook.Sheets[1].Charts[0]; // Sheets[] 以 1 起算,Charts[] 以 0 起算
if not Chart.HasPreservedXml then
Log('chart 0 has no original ChartML attached'); // Log 是你自己的常式
Chart.Title := Chart.Title; // 相同值:照樣重播
Workbook.Sheets[1].Charts[1].Title :=
'Revenue by region, Q3 2026'; // 這個圖表走合併路徑
Workbook.SaveAs('quarterly-review-out.xlsx');
finally
Workbook.Free;
end;
end;
編輯一個圖表時,合併保住什麼?
簡短答案:強型別模型不擁有的一切。標題、數列、資料快取、繪圖群組與座標軸歸模型所有,由新生成的樹決定。未建模的屬性、命名空間宣告、樣式、延伸清單與格式節點來自原始樹,重新插回它們原本相鄰的相符節點旁
刪掉一條數列,合併絕不能只因為原始 XML 裡那個位置還有一個 c:ser 就把它救回來。模型擁有的東西一律取自模型,就算模型的答案是「沒了」。屬性層級同理:清掉已知文字樣式的粗體、斜體、底線、刪除線或字號時,合併把缺的屬性讀成「清除」,不會從原始 defRPr 把 b 或 sz 抄回來。同一元素上的未知屬性保有原位
標題遵循同一條規則。匯入時,HotXLS 保留 c:title 原生的豐富文字 run,連同每個 run 的字型、字號與語言標籤,文字沒動就一直重播。指派新的 Title,那些豐富內容就被丟棄;標題改寫成一個按 TitleTextStyle 設樣式的 run。把舊 run 合併進新文字,等於猜新句子屬於哪個 run,這種猜法產出的標題沒有人打得出來
Office 在意的結構描述順序會被保留。extLst 留在父節點的最後一個子節點,結構描述如此要求。標記相容性區塊另有專門處理:讀取端跳過 mc:Choice、讀 mc:Fallback;當後援包著模型認得的繪圖群組,合併把新的強型別內容寫回後援子樹。天真的合併會發出編輯後的繪圖群組、卻把舊副本留在後援分支裡,存出的圖表於是帶著同一個群組的兩份
用 ClearPreservedXml 把單一圖表排除在外
TXLSXChart.ClearPreservedXml 丟棄單一圖表的原始 XML 與指紋,下一次儲存就純粹從強型別模型重建該圖表。這是按圖表做出的決定。PreserveUnsupportedParts 仍然治理匯入時是否建立保留狀態,而且它涵蓋其他不受支援的套件部件,不只圖表——這正是「為了換得一個乾淨圖表而把它關掉」是錯誤施力點的原因
操作的目的就是丟棄時才清,例如範本圖表還帶著已退役企業佈景主題的格式。除此之外別把它當除錯反射動作。圖表編輯後渲染錯誤,通常是建模問題;清除保留的 XML,等於把 Excel 原本寫下的唯一副本扔掉
有一種情況要先檢查。誤差線或趨勢線用了強型別模型無法表達的線條格式時,其 LineAppearanceSupported 屬性為 False;帶模型外格式的趨勢線標籤則透過 LabelAppearanceSupported 回報同樣狀態。HotXLS 不會悄悄把任何一個換成更樸素的版本:圖表一旦沒有保留 XML 可退,儲存就以 EArgumentException 中止,訊息點名無法表達的線條或標籤外觀。清除之前把兩者都測一遍,一個迴圈就夠
// 你自己的輔助函式,不是 HotXLS 的一部分:當每條誤差線、
// 趨勢線與趨勢線標籤都能單靠強型別模型寫出時回傳 True
function ChartRebuildable(Chart: TXLSXChart): Boolean;
var
I, T: Integer;
S: TXLSXChartSeries;
begin
Result := False;
for I := 0 to Chart.SeriesCount - 1 do // 涵蓋每個繪圖群組
begin
S := Chart.Series[I];
if (S.ErrBarX <> nil) and not S.ErrBarX.LineAppearanceSupported then Exit;
if (S.ErrBarY <> nil) and not S.ErrBarY.LineAppearanceSupported then Exit;
for T := 0 to S.TrendlineCount - 1 do
begin
if not S.Trendlines[T].LineAppearanceSupported then Exit;
if not S.Trendlines[T].LabelAppearanceSupported then Exit;
end;
end;
Result := True;
end;
// ...
Chart := Workbook.Sheets[1].Charts.FindByName('Old theme chart');
if (Chart <> nil) and ChartRebuildable(Chart) then
Chart.ClearPreservedXml // 下次儲存會從模型重建這個圖表
else
Log('kept original ChartML for this chart');
用 AddPlotGroup 與次要座標軸組合圖表
TXLSXChart.AddPlotGroup 附加一個有序繪圖群組,自帶圖表家族與主軸或次軸指派。財務團隊點名要的那張圖就靠它:營收以直條畫在左軸,利潤百分比以折線畫在右軸。每個圖表公開 ValueAxis、CategoryAxis、SecondaryValueAxis、SecondaryCategoryAxis 與 SeriesAxis,並為位置、軸識別碼、交叉、日期單位、標籤間距、數字格式與可見性提供強型別設定
單一類型圖表的平面 API 還在,並對應到這些群組。ChartType 與 AddSeries 定址第一個繪圖群組;SeriesCount 與 Series[] 按順序走過每個群組的數列,上面的輔助函式因此也看得到折線數列。順序只在一處要緊。TXLSXCharts.Add 建出的圖表一開始沒有任何繪圖群組,AddSeries 與 AddPlotGroup 誰先跑,誰就建立群組一。在新圖表上先呼叫 AddPlotGroup(xlsxChartLine, xlsxAxisSecondary),折線就成了群組一、圖表類型切成折線,你接著加的營收數列落進去。把兩個群組都明確建出來就不會有歧義,範例正是這麼做
var
Chart: TXLSXChart;
Columns, Line: TXLSXChartPlotGroup;
begin
// 錨定在第 1..15 列、第 5..12 欄;還沒有任何繪圖群組
Chart := Sheet.Charts.Add(xlsxChartColumn, 'Revenue and margin',
1, 5, 15, 12);
Columns := Chart.AddPlotGroup(xlsxChartColumn); // 群組 1,主軸
Columns.AddSeries('Revenue', 'Data!$A$2:$A$13', 'Data!$B$2:$B$13');
Line := Chart.AddPlotGroup(xlsxChartLine, xlsxAxisSecondary); // 群組 2
Line.AddSeries('Margin %', 'Data!$A$2:$A$13', 'Data!$C$2:$C$13');
Line.ZOrder := 1; // 把折線畫在直條前面
Chart.SecondaryValueAxis.NumberFormat := '0%';
Chart.SecondaryValueAxis.NumberFormatSourceLinked := False;
Chart.SecondaryValueAxis.MinValue := 0;
Chart.SecondaryValueAxis.MaxValue := 0.5;
// 繪圖區以圖表的分數表示:一次呼叫設定 x、y、寬、高
Chart.PlotAreaLayout.SetPositionAndSize(0.08, 0.12, 0.80, 0.72);
end;
繪圖群組上的 ZOrder 控制疊層:圖表寫入器在輸出 c:barChart 與 c:lineChart 區塊前按它排序群組,值小的在後。每個有序群組都有模型,開啟再儲存一份組合圖表,每個圖表家族連同其次要、日期與數列座標軸都原樣保住。圖表公式參照與快取數列值能在活頁簿複製與開啟儲存循環中存活,圖表錨點按關聯識別碼對應到其圖表部件,所以就算關聯項目被其他工具重排過,套件仍把每個圖表放回它自己的工作表位置
為什麼 Office 無視手動版面?
因為座標到得不齊。只要 c:manualLayout 裡出現 x、y、w、h 其中任何一個,Office 就要求四個成一組到齊,否則整個元素無視。結構描述把四者都標成選用,所以只有 x 與 y 的檔案合法,卻在 Excel 裡什麼也不做
該用的呼叫是 TXLSXChartLayout.SetPositionAndSize。它一次設齊四個值、把四個模式元素標為存在、並啟用版面。個別的 X、Y、Width、Height 屬性各設一個值,HotXLS 只寫出存在的元素,跟它讀檔時保留九個選用子元素各自的存在狀態是同一套做法。這讓沒動過的版面忠於來源。反過來說,用兩次屬性指派建出的版面就只會帶兩個座標寫出,Office 會跳過它。同一個 TXLSXChartLayout 類別也支撐著 PlotAreaLayout、TitleLayout、LegendLayout 與每個軸的 TitleLayout
兩個預設跟隨 Excel 的行為,而不是規格允許的全部。新版面用 edge 當 XMode 與 YMode、factor 當 WidthMode 與 HeightMode;標準允許其他組合,但 Excel 存位置用邊、存尺寸用因子,儲存時會把其他組合轉掉。layoutTarget(Target,內或外)只為繪圖區寫出;標題、軸標題與圖例版面省略它,因為 Microsoft 的圖表結構描述實作註記不允許在那些元素下使用
把這一點嵌進活頁簿往返裡
圖表保留是一條更廣規則的個案:編輯他方所撰文件的程式庫,應該只改它被要求改的東西,其餘照它找到的原樣留著。HotXLS 把同樣的推理用在佈景主題、計算鏈與工作表延伸上,見 佈景主題、extLst 與 calcChain 的無損往返;用在 VBA 專案與外部連結上,見 保留 VBA 與外部連結。從零開始建立圖表、擺放圖片與繪製圖形,請見 HotXLS 的圖表、圖片與繪圖
HotXLS 從原生 Delphi 與 C++Builder 程式碼讀取、編輯並寫入 XLSX 圖表,機器上不必安裝 Excel,圖表保真度由程式庫決定,不由自動化伺服器決定。圖表 API 參考與試用版在 HotXLS Delphi 試算表元件頁面