HotXLS Delphi Component 只有在兩個條件同時成立時,才會把未經修改的 Excel 圖表逐位元組原樣重播:圖表是經由工作表的繪圖關聯抵達的,而不是靠猜出來的部件名稱;而且 64 位元模型指紋是在圖表模型剖析完成之後才擷取的。2.382.0 版修掉了第一個條件,2.382.3 版修掉第二個,並開始讓非零的 xdr:colOff 與 xdr:rowOff 錨點位移能走完 round trip——在此之前,繪圖寫入器一直把這兩個值硬寫成零。兩個缺陷都出自同一筆本機 corpus 案例 two-charts.xlsx:先是結構斷言看到兩個圖表部件變成三個,接著逐份比對每個 xl/charts/chartN.xml 的位元組,發現根本沒人動過的圖表還是照樣被重寫——而這兩個問題都沒有丟出例外,Excel 也沒抱怨,這正是它們能活這麼久的原因
為什麼兩個圖表的工作簿存回來變成三個圖表部件?
因為載入器有一條靠猜測的後備路徑。當某個工作表的 .rels 部件裡沒有繪圖關聯時,舊程式碼就假設繪圖放在慣用名稱 xl/drawings/drawing{i+1}.xml 底下(其中 i 是工作表的位置),只要壓縮檔裡確實有那個部件就把它掛上去。在 two-charts.xlsx 裡,第一張工作表既沒有繪圖也沒有 .rels 部件,而 xl/drawings/drawing1.xml 卻確實存在——它屬於第二張工作表,後者透過 Target="../drawings/drawing1.xml" 找到它。於是工作表 1 繼承了一個它從未參照過的圖表,chart1.xml 被剖析兩次,儲存時就把活頁簿寫成三個圖表部件,而不是兩個
HotXLS v2.382.0 的修正把這個猜測整個移除。工作表的繪圖現在只透過 ParPartTargets[i].Values[XlsxRtDrawing] 載入——也就是該工作表上為繪圖關聯型別記錄下來的目標——而沒有這條關聯的工作表就完全不會有繪圖。這正是格式要求的行為:工作表裡的 <drawing r:id="…"/> 元素(ECMA-376 Part 1 §18.3.1.36)是工作表與其繪圖之間唯一的連結,而 OPC 套件裡的部件名稱,除了關聯圖賦予它的意義之外別無其他含意。Excel 寫出的壓縮檔剛好都用慣用名稱,這才讓這條捷徑活了這麼久;HotXLS 的 OPC 關聯解析一文說明了為什麼猜部件名稱永遠不安全,即使這個猜測通常是對的
// v2.382.0 之前:缺少繪圖關聯時退回猜測
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
LoadDrawing(zip, drawingName); // 可能屬於別的工作表
// v2.382.0 起:只有關聯,沒有就沒有
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
圖表指紋保證了什麼?
指紋逐張圖表決定儲存時能不能直接複製原始部件,還是必須重新產生。匯入時,只要在 Open 之前啟用 PreserveUnsupportedParts,HotXLS 就會把每個圖表部件的原始 UTF-8 位元組留在 FRawChartXml 裡,用 BuildChartKnownXml 建出型別模型自己的序列化,並把該序列化的長度存進 FRawChartModelLength、雜湊存進 FRawChartModelHash。雜湊是對產生出來的 XML 之 UTF-16 編碼單元算 FNV-1a,採標準的 64 位元 offset basis 14695981039346656037 與質數 1099511628211。儲存時 XlsxChartRawModelUnchanged 會重建 known XML 並比對長度與雜湊;相符就代表型別模型跟匯入當時一模一樣,應用程式能改的東西一個都沒被改過
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
const KnownXml: WideString): Boolean;
begin
Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
(Length(KnownXml) = Chart.FRawChartModelLength) and
(XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;
function BuildChartXmlFromKnown(Chart: TXLSXChart;
const KnownXml: WideString): WideString;
begin
if Chart.FRawChartXml = '' then
Result := KnownXml // 什麼都沒保留
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // 逐字重播
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // 結構合併
end;
XLSX 寫入器比 BuildChartXmlFromKnown 又多走一步。當模型沒有變動、StrictOOXML 又是關閉的時候,它會先嘗試把壓縮項目從來源壓縮檔直接複製到輸出、掛在圖表的新部件名稱底下,這樣位元組連解開再重新 deflate 都不用。只有在這個複製不可行時,才會落到解碼或合併那條路。這套機制本身——長度加雜湊,相等就重播、不等就合併——就是編輯 Excel 圖表而不弄丟 ChartML一文所描述的那一套。本文要談的是它怎麼無聲地停止運作
為什麼每張圖表還是都走了合併路徑?
因為指紋早了一次呼叫被擷取。HotXLS 的圖表剖析是對圖表部件跑一趟 SAX,後面再跟上幾道復原階段,把 SAX 處理器沒有直接建模的細節從原始文字裡拉出來:XlsxChartParseSeriesFlags 讀每個 <c:ser> 區塊的 <c:smooth> 旗標、標記填色與標記線的 srgbClr 值,然後復原座標軸交叉模式,以及類別軸與數值軸的主副刻度線樣式。在 v2.382.3 之前,ParseChartXml 結尾的順序是:先把座標軸分組分類、建出 known XML、擷取長度與雜湊,最後才跑 XlsxChartParseSeriesFlags。所以那個指紋描述的是一個還缺少平滑旗標、標記顏色與刻度線的模型。儲存時 BuildChartKnownXml 是對著完成後的模型跑的,而這個模型現在會輸出 <c:smooth val="1"/> 與復原出來的標記顏色。XML 變長、雜湊不同、XlsxChartRawModelUnchanged 回傳 False,圖表就走了 XlsxMergeChartXml。對一張有人編輯過的圖表來說,合併是正確的操作,但它不是保留位元組的操作:它會把整棵樹重新序列化,而讓型別模型在數列、座標軸與繪圖區分組上勝出的那條所有權規則,意味著重新產生的節點會取代原本的節點。corpus 跑下來看到的結果,就是沒人編輯過的圖表出現數列顏色漂移——每一本保留的活頁簿裡的每一張圖表、每一次儲存,而且任何地方都沒有診斷訊息
修法只是把順序調一下:XlsxChartParseSeriesFlags 現在在 known XML 建好之前就先跑,所以指紋描述的是應用程式第一次看到它時的模型。這個教訓不只在圖表上成立。變更偵測用的指紋,好壞取決於擷取的那一刻,而安全的時機是在每一個可能改動模型的階段都跑完之後。HotXLS 對同樣這兩個值還有第二個擷取點——成功儲存之後,它會對著輸出檔重建基準線——而那個點向來都是對著完整剖析過的模型跑的;匯入時那個點才是特例
錨點位移跑到哪去了?
跑進一個字面上的零。繪圖部件裡的 twoCellAnchor 把圖表釘在兩個儲存格之間,每個角都帶著一個儲存格索引加上該儲存格內的位移:from(ECMA-376 Part 1 §20.5.2.5)與 to(§20.5.2.32)各自有 col、colOff(§20.5.2.4)、row 與 rowOff。這些位移的單位是 English Metric Units,每英吋 914400,只要圖表是用滑鼠擺放過或調整過大小,Excel 就會寫非零值,而大多數圖表都是這樣。two-charts.xlsx 裡的第一張圖表從第 0 列開始、rowOff 是 19049,結束在第 8 欄、第 15 列,colOff 是 247650、rowOff 是 66674——差不多是最後一欄往內四分之一英吋。HotXLS 的繪圖剖析器一直都讀得到這四個值——圖片程式碼就用到了——但圖表寫入器對每個角都輸出 <xdr:colOff>0</xdr:colOff> 與 <xdr:rowOff>0</xdr:rowOff>,儲存時把每張圖表都吸附到儲存格格線上
// v2.382.3 起,錨點寫入器會重播匯入的 EMU 位移
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
'<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...
TXLSXChart 現在帶著 FFromColOff、FFromRowOff、FToColOff 與 FToRowOff,由繪圖剖析器填入,並在圖表被指派時跟著其他錨點狀態一起複製過去。它們刻意保持私有:公開的錨點介面仍然是那四個儲存格座標 FromRow、FromCol、ToRow 與 ToCol,而從 Delphi 程式碼建立的圖表照舊落在儲存格邊界上。這些位移存在的目的是讓 round trip 忠實,不是要把次儲存格定位當成功能暴露出去。注意這項修正跟指紋無關:錨點住在繪圖部件、不在圖表部件,所以即使某張圖表的 ChartML 完美重播,少了它,圖表照樣會跳回格線。這些 EMU 值背後的單位換算見HotXLS 圖片幾何與 EMU 縮放一文
怎麼證明一張圖表原封不動地走完 round trip?
靠比對位元組,不是把結果拿去 Excel 打開。Excel 載入時會修復並正規化掉太多東西,所以一張已經漂移的圖表看起來一切正常,直到某位分析師注意到標記顏色變了。抓到這兩個缺陷的 corpus 測試,在一次不帶任何編輯的開檔加存檔之後做三件事:走訪工作表、繪圖與圖表的關聯,只要出現重複、孤立或懸空的圖表參照就失敗;比對原檔與輸出檔之間圖表型別、數列公式與錨點幾何的簽章;而對 two-charts.xlsx,它會從兩個壓縮檔各讀出每個 xl/charts/chartN.xml,要求位元組完全相同。同樣的檢查用 RTL 的 TZipFile 在 Delphi 裡很好寫
uses System.Zip, System.SysUtils;
function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
Src, Dst: TZipFile;
Name: string;
A, B: TBytes;
begin
Result := True;
Src := TZipFile.Create;
Dst := TZipFile.Create;
try
Src.Open(Original, zmRead);
Dst.Open(Resaved, zmRead);
for Name in Src.FileNames do
if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
begin
Src.Read(Name, A);
Dst.Read(Name, B); // 部件若消失就會丟出例外
if (Length(A) <> Length(B)) or
((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
begin
Writeln('changed: ', Name);
Result := False;
end;
end;
finally
Dst.Free;
Src.Free;
end;
end;
有三個條件讓這個比對有意義,而每一個被忘掉時都會無聲地失效。PreserveUnsupportedParts 必須在 Open 之前就是 True,否則不會擷取任何原始位元組,每張圖表都會從模型重建。StrictOOXML 必須是 False,因為 strict 模式依設計就是強制重新產生。而且應用程式不能在開檔與存檔之間碰這張圖表——讀取屬性是沒問題的,但任何會改動型別模型的 setter 都會翻掉指紋、把圖表送進合併路徑,那是正確的行為,卻不是這個測試要驗的東西。圖表部件在儲存時也會從活頁簿層級的計數器重新編號,所以工作表順序或圖表順序變過的活頁簿,會把相同的位元組放在不同的 chartN.xml 名稱底下;corpus 檢查器就是因為這個理由才沿著關聯走,而不是沿著名稱走
兩項修正分別在 HotXLS 2.382.0 與 2.382.3 出貨,並在 Win32 與 Win64 上對著本機 corpus 驗證過,重新存回的圖表範例另外經由一套獨立辦公套件算繪成 PDF,與原檔逐頁比對。HotXLS 用原生 Delphi 與 C++Builder 程式碼讀取、編輯與寫入 XLSX 圖表,完全不涉及 Excel 安裝,這正是為什麼這樣等級的保真度是函式庫的責任——HotXLS Delphi 試算表元件產品頁有功能清單與試用版下載