技術記事

DelphiのHotXLSチャート指紋とEMUアンカーオフセット

チャート2つ入りのワークブックを、何も編集せずに開いて保存してみると、出口からは3種類の違う結果が出てこられます。1つ目は、ソースでは2つだったチャートパーツが3つになっている出力。2つ目は、2つのままですが、バイト比較を見ると xl/charts/chartN.xml がすべて書き直されていて、系列の色がずれているもの。3つ目は、チャート XML は無傷なのに、すべてのチャートが数ピクセル、セルのグリッド線へ飛んでいるもの。Excel は3つとも修復プロンプトなしで開きます。症状のそれぞれが、ロードと保存の経路における1つの決断から来ます。ワークシートがどうやって描画を見つけるか、チャートの変更指紋をいつ取得するか、そして描画パーツ内のアンカーオフセットを書き戻すかどうか、です

ワークシートはリレーションシップを通して描画を見つける

3パーツ症状の出どころは、パーツ名を推測するローダーです。XLSX パッケージでは、ワークシートは <drawing r:id="…"/> 要素(ECMA-376 Part 1 §18.3.1.36)で自分の描画を指し、r:id はシートの .rels パーツを通って ../drawings/drawing1.xml のようなターゲットに解決します。Excel はたまたま予測可能な付け方で描画に番号を振るので、手書きのリーダーにはフォールバックが生えがちです。リレーションシップが無いとき、シート位置 n に対して xl/drawings/drawing{n}.xml を試し、アーカイブにあれば使う、というものです

// 手書きの 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 パーツもまったくなく、2枚目のシートが Target="../drawings/drawing1.xml" で xl/drawings/drawing1.xml に届くワークブックでは、フォールバックはシート1に、参照したことのない描画を渡します。chart1.xml はシートごとに1回、計2回パースされ、保存は3つのチャートパーツを書き出します。OPC パッケージのパーツ名は、リレーションシップグラフが割り当てた意味以外を運びません。HotXLS の OPC リレーションシップ解決のウォークスルーが論じているのは、まさにこれです

2チャートのワークブックでワークシートが描画を見つけるしくみ:Sheet1 は描画リレーションシップも rels パーツも持たず、Sheet2 は自分の描画リレーションシップで xl/drawings/drawing1.xml に届く。シート位置からその慣例名を推測するローダーは chart1.xml を2回パースして3つのチャートパーツを保存する。だから HotXLS は描画をシートの描画リレーションシップ経由でだけロードする
Sheet1 はチャートを一度も参照していないので、描画ターゲットの安全なソースはリレーションシップグラフだけです。推測した慣例名は、2チャートのワークブックを3パーツの保存に変えます

HotXLS Delphi Component は厳密な読み方を取ります。ワークシートの描画は、そのシートの描画リレーションシップのターゲットからだけロードされ、リレーションシップを持たないシートは、xl/drawings/ にどんなファイルがいようと、描画を一切受け取りません。何からも参照されない描画パーツがシートに取り付けられることはありません

未編集のチャートをコピーするか組み立て直すかを決めるものは何か

決めるのはチャートごとの指紋です。Open の時点で PreserveUnsupportedParts が True(既定は True)だと、HotXLS は各チャートパーツの元のバイトを、パース済みのチャートモデルのそばに保持します。そしてモデルを自前のチャートライターでシリアライズし、その出力に2つの数を記録します。長さと、UTF-16 コード単位に対する 64 ビット FNV-1a ハッシュです。オフセットベースは標準の 14695981039346656037、素数は 1099511628211 を使います。チャートがその元のバイトを運んでいるかどうかは、TXLSXChart.HasPreservedXml が教えてくれます

保存時にはチャートモデルをもう一度シリアライズし、2つの数を比較します。長さとハッシュが一致していれば、モデルはインポート時とまったく同じで、アプリケーションが変え得たものは何も変わっていません。この場合、StrictOOXML がオフなら、ライターは圧縮エントリをソースアーカイブから、チャートの新しいパーツ名の下の出力へ直接コピーします。バイトはデコードも再 deflate もされません。どちらかの数でも違っていれば、元の XML をモデルとマージする側に回ります。誤差棒や近似曲線の線の書式を変えるのも編集に数えられます。その書式はシリアライズ済みモデルの一部ではないのに、です

HotXLS はインポート時にチャート指紋を取得します。元のチャートパーツのバイトを保持しつつ、チャートモデルを自前でシリアライズしたものの長さと 64 ビット FNV-1a ハッシュを記録し、保存時には両値を組み立て直して比較する。一致なら元のバイトを再生するか圧縮エントリをコピーし、不一致は元の XML をモデルとマージする
指紋の価値は取得した瞬間で決まります。リカバリパスがすべて終わる前に取得すれば、完成したモデルに決して一致しないハッシュが保証されるだけです

マージは、誰かが編集したチャートには正しい操作です。元の XML のうちモデルがカバーしない部分を残し、系列・軸・プロットグループについてはモデルに勝たせ、その結果を再シリアライズします。できないのは、元のバイトの再現です。マージが何を残すかはChartML を失わずに Excel チャートを編集するノートが扱っています。この記事の主題は、未編集のチャートがそこへたどり着かないための条件です

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 の前に True が要る
    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 が保存済みのバイトを捨て、そのチャートはモデルだけから書かれます

それでもすべてのチャートがマージ経路を通ってしまうのはなぜか

理由は、1ステップ早く取得された指紋は決して一致しないからです。チャート XML が1パスで読み切られることはめったにありません。ストリーミングパースが系列・軸・プロットグループを組み立て、その後に後続パスの一群が、系列が存在してからのほうが回復しやすい細部を生のチャートテキストから読みます。各 <c:ser> の <c:smooth> フラグ、マーカー塗りとマーカー線の srgbClr 値、軸の交差モード、主と副の目盛りスタイルです。ストリーミングパスが走っている間、系列コレクションは単純にまだ存在しないので、それらのパスは後に回るしかありません

指紋が、後続パスが走る前のストリーミングパスの終わりで取得されたとしましょう。指紋が描写するのは、smooth フラグもマーカーカラーもなく、目盛りは既定値のモデルです。保存時にはシリアライザが完成したモデルに対して走り、<c:smooth val="1"/> と回復された色を出力します。XML は長くなり、ハッシュは変わり、保存対象のすべてのワークブックのすべてのチャートが、保存のたびにマージを通ります。系列が平滑化されマーカーをカスタムした折れ線グラフでは、再生成された <c:ser> ノードが元のノードを置き換えます。色のドリフトはここから来ます

HotXLS は指紋を、すべてのリカバリパスが終わった後にだけ取得します。ハッシュが描写するのは、アプリケーションが最初に目にするときの姿のままのモデルです。同じ2つの数は、保存成功後にも張り直されます。今度は出力ファイルに対してで、この2回目の取得も完全にパースされたモデルから始まります。変更検知のハッシュは、まだモデルを変え得る最後のパスの後に取得しなければなりません。それより早く取ると、保存のたびに失敗するチェックが出来上がります

アンカーオフセットは描画パーツに住む

グリッド吸着の症状は、チャート XML とは何の関係もありません。チャートがシートのどこに座るかは描画パーツに格納されており、典型的な形が xdr:twoCellAnchor(ECMA-376 Part 1 §20.5)です。from コーナーと to コーナーが、それぞれ col、colOff、row、rowOff を持ちます。オフセットの単位は English Metric Units、1インチ 914400、1ポイント 12700 で、チャートがマウスでドラッグやリサイズされると、Excel は非ゼロの値を書き込みます。列 0・行 0 に rowOff 19049 の from コーナーを置き、列 8・行 15 に colOff 247650、rowOff 66674 の to コーナーを置くファイルでは、チャートは行1の上端から 1.5 ポイント下で始まり、列 I の4分の1インチ少し奥で終わります

例のチャートの xdr:twoCellAnchor コーナーの解剖:from は col 0 と rowOff 19049 を持ち、to は EMU(914400 毎インチ)で col 8、colOff 247650、rowOff 66674 を持つ。ゼロオフセットを出すライターはチャートをグリッドに吸い付かせるので、HotXLS は保存時にインポートされたオフセットを書き戻す
アンカーはチャートパーツではなく描画パーツに住みます。だからオフセット再生は指紋と独立しており、忠実なラウンドトリップには両方が要ります

その4つの数を読んだあげく、すべてのコーナーに <xdr:colOff>0</xdr:colOff> を出すライターは、各チャートをグリッド線の上へ移動させます。チャートパーツがバイト単位で再生されても、チャートは動きます。2つは別のパーツに住んでいるからです。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回の呼び出しで置き換える。行と列は 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 を使います。2セルアンカーのチャートに FromRow、FromCol、ToRow、ToCol を代入すると、そのセルインデックスだけが変わり、オフセットは元の位置に残ります。小さな調整には正しい振る舞いですが、チャートをゼロから置くには間違った振る舞いです。SetTwoCellAnchor はフレームを全体として検証し、終端コーナーが始点より先、あるいはオフセットが Excel の EMU 範囲の外に落ちるときは ERangeError を上げます。AddChart で作ったチャートはゼロオフセットで始まるので、何か与えるまでは正確にセル境界の上に座ります

バイト比較でラウンドトリップを証明する

出力を 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 に変えるライターを捉えられます。ワークシート・描画・チャートのリレーションシップを歩き、重複・孤立・ぶら下がりのチャート参照があれば失敗させる構造チェックは、バイト比較が走る前に3パーツケースを捉えます

このすべては、マシンに Excel をインストールすることなく、ネイティブな Delphi と C++Builder コードで動きます。バッチジョブから戻ってきたワークブックこそが、チャートに何が起きたかの唯一の証拠になります。ライセンス、チャート API の全体、試用ビルドはHotXLS Delphi spreadsheet component ページにあります