技術文章

Delphi 中 HotXLS 圖表指紋時機與錨點位移

一個有兩張圖表的活頁簿經過一趟什麼都沒改的開檔存檔,另一端卻可能冒出三種不同的結果。第一種輸出,來源明明只有兩個圖表部件,輸出卻有三個。第二種還是兩個,但位元組比對顯示每個 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 關聯解析逐步說明要論證的全部

兩圖表活頁簿中工作表如何找到它的繪圖:Sheet1 沒有繪圖關聯也沒有 rels 部件,Sheet2 經由繪圖關聯指向 xl/drawings/drawing1.xml,因此從工作表位置猜測這個慣用名稱的載入器會把 chart1.xml 剖析兩次、存出三個圖表部件,這正是 HotXLS 只透過工作表的繪圖關聯載入繪圖的原因
Sheet1 從未參照任何圖表,所以關聯圖是繪圖目標唯一安全的來源,猜出來的慣用名稱會把兩圖表活頁簿變成三部件存檔

HotXLS Delphi Component 採取嚴格解讀。工作表的繪圖只會從該工作表繪圖關聯的目標載入,沒有這條關聯的工作表就拿不到繪圖,無論 xl/drawings/ 裡碰巧躺著哪些檔案。沒有被任何東西參照的繪圖部件,永遠不會被掛到工作表上

未經修改的圖表要複製還是重建,由什麼決定?

由逐張圖表的指紋決定。只要 Open 當下 PreserveUnsupportedParts 為 True(預設即 True),HotXLS 會把每個圖表部件的原始位元組保留在剖析出的圖表模型旁邊。接著它用自己的圖表寫入器把模型序列化,並為這份輸出記下兩個數字:長度,以及對其 UTF-16 碼元算出的 64 位元 FNV-1a 雜湊,使用標準偏移基數 14695981039346656037 與質數 1099511628211。TXLSXChart.HasPreservedXml 會回報某張圖表是否還帶著那些原始位元組

存檔時圖表模型會再次序列化,兩個數字拿出來比對。長度相等且雜湊相等,代表模型跟匯入時一模一樣,應用程式什麼都沒改到。這種情況下,只要 StrictOOXML 沒開,寫入器就把壓縮的條目從來源封存原封複製到輸出、掛在圖表的新部件名稱下;這些位元組不會被解碼或重新壓縮。任一數字不同,就改用原始 XML 與模型合併。改錯誤線或趨勢線的線條格式也算編輯,即使那個格式並不在序列化的模型裡

HotXLS 在匯入時擷取圖表指紋:保留原始圖表部件位元組,並為自己序列化出的圖表模型記下長度與 64 位元 FNV-1a 雜湊;存檔時重建並比對兩個值,比對相符就重播原始位元組或直接複製壓縮條目,不符則把原始 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:twoCellAnchor 各角解剖:from 為第 0 欄、rowOff 19049,to 為第 8 欄、colOff 247650、rowOff 66674,單位為每吋 914400 的 EMU;寫出零位移的寫入器會讓圖表吸附到網格上,HotXLS 因此在存檔時把匯入的位移原樣寫回
錨點住在繪圖部件而非圖表部件裡,所以位移重播與指紋互不相干,忠實的 round trip 兩者都要

讀了這四個數字、卻對每個角都吐出 <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 試算表元件頁面