一個有兩張圖表的活頁簿經過一趟什麼都沒改的開檔存檔,另一端卻可能冒出三種不同的結果。第一種輸出,來源明明只有兩個圖表部件,輸出卻有三個。第二種還是兩個,但位元組比對顯示每個 xl/charts/chartN.xml 都被重寫過,數列顏色也漂移了。第三種圖表 XML 原封不動,每張圖表卻跳了幾個像素、吸附到儲存格格線上。Excel 打開這三種檔案都不會跳修復提示。每個症狀都來自載入與儲存路徑上的一個決策:工作表怎麼找到它的繪圖、圖表的變更指紋什麼時候擷取,以及繪圖部件裡的錨點位移有沒有寫回去
工作表靠關聯找到它的繪圖
三部件症狀來自靠猜測部件名稱的載入器。在 XLSX 套件裡,工作表用一個 <drawing r:id="…"/> 元素指向它的繪圖(ECMA-376 Part 1 §18.3.1.36),r:id 經由該工作表的 .rels 部件解析出 ../drawings/drawing1.xml 這類目標。Excel 碰巧用可預測的方式為繪圖編號,所以手寫的讀取器常會長出一條後備路徑:關聯找不到時,就試試 xl/drawings/drawing{n}.xml(n 為工作表位置),封存裡有就用
// 手寫 XLSX 讀取器常見的捷徑(別把這段出貨)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
const RelTarget: string): string;
begin
Result := RelTarget; // 從工作表的 .rels 解析而來,沒有關聯時為 ''
if Result = '' then
begin
Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
if Zip.IndexOf(Result) < 0 then
Result := '';
end;
end;
這個猜測在「第一張工作表沒有圖表」時就失效了。當活頁簿的第一張工作表既沒有繪圖、連 .rels 部件都沒有,而第二張工作表經由 Target="../drawings/drawing1.xml" 指向 xl/drawings/drawing1.xml 時,後備路徑會把一張它從未參照過的繪圖塞給工作表 1。chart1.xml 被剖析兩次、每張工作表一次,存檔時就寫出三個圖表部件。OPC 套件裡的部件名稱除了關聯圖賦予它的意義外什麼都不代表,這正是 HotXLS 的 OPC 關聯解析逐步說明要論證的全部
HotXLS Delphi Component 採取嚴格解讀。工作表的繪圖只會從該工作表繪圖關聯的目標載入,沒有這條關聯的工作表就拿不到繪圖,無論 xl/drawings/ 裡碰巧躺著哪些檔案。沒有被任何東西參照的繪圖部件,永遠不會被掛到工作表上
未經修改的圖表要複製還是重建,由什麼決定?
由逐張圖表的指紋決定。只要 Open 當下 PreserveUnsupportedParts 為 True(預設即 True),HotXLS 會把每個圖表部件的原始位元組保留在剖析出的圖表模型旁邊。接著它用自己的圖表寫入器把模型序列化,並為這份輸出記下兩個數字:長度,以及對其 UTF-16 碼元算出的 64 位元 FNV-1a 雜湊,使用標準偏移基數 14695981039346656037 與質數 1099511628211。TXLSXChart.HasPreservedXml 會回報某張圖表是否還帶著那些原始位元組
存檔時圖表模型會再次序列化,兩個數字拿出來比對。長度相等且雜湊相等,代表模型跟匯入時一模一樣,應用程式什麼都沒改到。這種情況下,只要 StrictOOXML 沒開,寫入器就把壓縮的條目從來源封存原封複製到輸出、掛在圖表的新部件名稱下;這些位元組不會被解碼或重新壓縮。任一數字不同,就改用原始 XML 與模型合併。改錯誤線或趨勢線的線條格式也算編輯,即使那個格式並不在序列化的模型裡
對被人編輯過的圖表來說,合併是正確的操作。它保留模型沒涵蓋到的原始 XML 部分,讓模型在數列、座標軸與繪圖群組上勝出,再把結果重新序列化。它做不到的是重現原始位元組。編輯 Excel 圖表而不遺失 ChartML 那篇備註講的是合併保留了什麼;本文要談的是未經編輯的圖表在哪些條件下根本不會走到那一步
uses System.SysUtils, lxHandleX;
procedure ResaveUntouched(const Src, Dst: string);
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
S, C: Integer;
begin
Book := TXLSXWorkbook.Create;
try
Book.PreserveUnsupportedParts := True; // 預設值;Open 之前就必須成立
Book.StrictOOXML := False; // Strict 輸出會重建每張圖表
if Book.Open(Src) <> 1 then
raise Exception.Create('Cannot open ' + Src);
for S := 1 to Book.Sheets.Count do // Sheets[] 為 1 起始
begin
Sheet := Book.Sheets[S];
for C := 0 to Sheet.Charts.Count - 1 do // Charts[] 為 0 起始
Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
end;
if Book.SaveAs(Dst) <> 1 then
raise Exception.Create('Cannot save ' + Dst);
finally
Book.Free;
end;
end;
當你刻意要讓某張圖表從模型重建——例如想剝掉來源帶的格式——TXLSXChart.ClearPreservedXml 會丟掉儲存的位元組,那張圖表就只從模型寫出
為什麼每張圖表可能照樣都走合併路徑?
因為早一步擷取的指紋永遠對不上。圖表 XML 很少能一口氣讀完。串流剖析先建出數列、座標軸與繪圖群組,接著好幾道後續階段再從原始圖表文字裡讀出那些「等數列存在之後才好復原」的細節:每個 <c:ser> 上的 <c:smooth> 旗標、標記填滿與標記線條的 srgbClr 值、座標軸交越模式,以及主、次刻度標記樣式。串流階段還在跑的時候,數列集合根本還不存在,所以這些階段只能排在它後面
假設指紋在串流階段結束時、後續階段還沒跑之前就擷取了。那它描述的是一個沒有平滑旗標、沒有標記顏色、刻度全是預設值的模型。存檔時序列化器對著完成後的模型跑,吐出 <c:smooth val="1"/> 和找回來的顏色。XML 變長、雜湊不同,於是每本受保護活頁簿裡的每張圖表,每次存檔都走合併。在一張有平滑數列和自訂標記的折線圖上,重建的 <c:ser> 節點會取代原本的節點,顏色漂移正是從這裡來的
HotXLS 只在每道復原階段都結束之後才擷取指紋,讓雜湊描述的是應用程式第一眼看到的模型。存檔成功後會用同樣兩個數字重新建立一次,這次是從輸出檔取值,而且第二次擷取同樣從完整剖析的模型出發。變更偵測用的雜湊,必須在最後一道還可能改動模型的階段之後擷取。抓得再早,得到的只會是一個每次存檔都失敗的檢查
錨點位移住在繪圖部件裡
吸附網格的症狀跟圖表 XML 一點關係都沒有。圖表坐在工作表哪個位置,記在繪圖部件裡,常見的形式是 xdr:twoCellAnchor(ECMA-376 Part 1 §20.5):一個 from 角和一個 to 角,各自帶 col、colOff、row、rowOff。位移的單位是英文度量單位(EMU),每吋 914400、每點 12700,而 Excel 只要用滑鼠拖動或縮放過圖表,就會寫出非零值。以一個宣告 from 角在第 0 欄第 0 列、rowOff 19049,to 角在第 8 欄第 15 列、colOff 247650、rowOff 66674 的檔案來說,這張圖表從第 1 列頂端往下 1.5 點開始,結尾則伸進 I 欄超過四分之一吋
讀了這四個數字、卻對每個角都吐出 <xdr:colOff>0</xdr:colOff> 的寫入器,會把每張圖表搬到格線上。圖表部件可以逐位元組重播,圖表照樣會動,因為兩者住在不同的部件裡。HotXLS 把載入時的錨點種類(twoCellAnchor、oneCellAnchor 或 absoluteAnchor,以 PhysicalAnchorKind 公開)連同 EMU 值一起保留,寫回時分毫不動。位移可從 FromOffsetXEMU、FromOffsetYEMU、ToOffsetXEMU、ToOffsetYEMU 讀取,背後的單位換算在 HotXLS 圖片幾何與 EMU 縮放那篇備註裡有完整推導
procedure NudgeChart(Chart: TXLSXChart);
const
EmuPerPoint = 12700;
begin
if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
Exit;
Writeln(Format('from R%dC%d +(%d, %d) EMU to R%dC%d +(%d, %d) EMU',
[Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
// 一次呼叫就整組取代框架:列與欄為 1 起始,
// 位移為儲存格內的 EMU,最後一個引數是 editAs 行為
Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;
要移動或縮放,用 SetTwoCellAnchor。在雙儲存格錨點的圖表上直接指派 FromRow、FromCol、ToRow 或 ToCol,只會改那個儲存格索引、位移維持原值——做微調這是對的行為,從零開始擺圖表這就是錯的。SetTwoCellAnchor 會把框架當整體驗證,終角在起角之前、或位移超出 Excel 的 EMU 範圍時丟出 ERangeError。用 AddChart 建立的圖表位移從零開始,所以在你給它值之前,它正好貼在儲存格邊界上
用位元組比對證明 round trip
把輸出拿去 Excel 打開,在這裡證明不了什麼。Excel 載入時正規化得夠多,重序列化的圖表看起來一模一樣,直到有人發現標記顏色變了;幾個點的網格吸附在螢幕上也容易漏看。值得自動化的檢查,是對一趟沒有任何編輯的開檔存檔,逐位元組比對圖表部件,而 RTL 的 TZipFile 就夠用了
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 輸出本來就會重建圖表。任何承諾未經編輯的圖表能在存檔後存活的管線,都別打開它- 以 Strict OOXML 命名空間寫出的來源套件,在 transitional 存檔時會被轉成 transitional 命名空間,所以即使其他什麼都沒變,圖表位元組也不一樣
Open到SaveAs之間的程式碼可以自由讀取圖表屬性,但任何改動模型的 setter 都會改掉指紋、把那張圖表送進合併——這是正確行為,也不在這個測試的量測範圍內- 存檔時圖表部件會用整本活頁簿共用的計數器重新編號,所以工作表或圖表順序變過的活頁簿,會把相同的位元組放到不同的
chartN.xml名稱下;要檢查這種情況,請沿繪圖關聯走,而不是比對名稱
錨點要比的是值。繪圖部件是圍著它容納的圖表重新寫出的,拿它的位元組來比對是比錯對象;把兩個檔案重新打開、逐張圖表比較 FromOffsetXEMU 到 ToOffsetYEMU,才抓得住把 247650 變成 0 的寫入器。另外,一個沿著工作表、繪圖與圖表關聯走訪、遇到任何重複、孤立或懸空圖表參照就報錯的結構檢查,能在位元組比對之前就先攔下三部件案例
以上全部都在原生 Delphi 與 C++Builder 程式碼裡執行,機器上不需要安裝 Excel,批次工作交回來的活頁簿,就是它的圖表經歷了什麼的唯一證據。授權、完整的圖表 API 與試用版都在 HotXLS Delphi 試算表元件頁面