技術記事

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

HotXLS Delphi Componentが未編集のExcelチャートをバイト単位でそのまま再生できるのは、2つの条件が同時に成り立つ場合だけです。チャートが推測されたパート名ではなくワークシートの描画リレーションシップ経由で到達されていること、そして64ビットのモデル指紋がチャートモデルの解析完了後に取得されていることです。バージョン2.382.0が1つ目の条件を修正し、2.382.3が2つ目を修正するとともに、描画ライターがゼロにハードコードしていた非ゼロのxdr:colOffとxdr:rowOffアンカーオフセットをラウンドトリップさせるようになりました。どちらの不具合も、ローカルコーパスの1つのケースtwo-charts.xlsxから出てきたものです。まず構造アサーションがチャートパート2つを3つと数え、次に全xl/charts/chartN.xmlのバイト比較が、誰も触っていないチャートまで書き換えられていることを示しました。しかもどちらの問題も例外を投げず、Excelも文句を言いませんでした。だからこそこれほど長く生き延びたのです

チャート2つのワークブックがチャートパート3つで戻ってきた理由

ローダーに推測するフォールバックがあったからです。ワークシートの.relsパートに描画リレーションシップが存在しない場合、古いコードは描画が慣例的な名前xl/drawings/drawing{i+1}.xmlにあると仮定していました。iはシート位置で、そのパートがアーカイブ内に存在すれば結び付けていました。two-charts.xlsxでは、先頭のシートに描画も.relsパートも一切ありませんが、xl/drawings/drawing1.xmlは存在します。これは2番目のシートのもので、そのシートがTarget="../drawings/drawing1.xml"で参照しています。結果としてシート1は一度も参照していないチャートを継承し、chart1.xmlが2回解析され、保存時にはチャートパートが2つではなく3つあるワークブックが書き出されました

two-chartsサンプルでHotXLSがワークシート描画を解決する仕組み:Sheet1は描画リレーションシップもrelsパートも持たない一方、Sheet2はParPartTargets経由でxl/drawings/drawing1.xmlに到達します。2.382.0より前のフォールバックはシート位置から慣例的な名前を推測したためchart1.xmlが2回解析され、修正がXlsxRtDrawing経由のみで描画を読み込むようになるまで保存はチャートパートを3つ書き出していました
Sheet1はチャートを一切参照していないため、描画ターゲットの安全な情報源はリレーションシップグラフだけです。慣例的な名前を推測したことで、チャート2つのワークブックが3パート保存になっていました

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ビットオフセットベース14695981039346656037と素数1099511628211を使います。保存時にXlsxChartRawModelUnchangedが既知XMLを再構築して長さとハッシュを比較し、一致すれば型付きモデルはインポート時と完全に同一であり、アプリケーションが変更し得たものは何も変わっていないことになります

HotXLSはインポート時にチャート指紋を取得します。生のUTF-8バイトをFRawChartXmlに保持しつつ、BuildChartKnownXmlがFRawChartModelLengthとFNV-1aハッシュを生成し、保存時にXlsxChartRawModelUnchangedが両方を再構築して比較します。一致すれば元のバイトを再生するか圧縮エントリをコピーし、不一致ならXlsxMergeChartXmlにフォールバックします
指紋は取得した時点の精度しか持ちません。すべてのリカバリパスが終わる前に取得すれば、完成したモデルとは二度と一致しないハッシュが確定してしまいます
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がオフの場合、まず圧縮エントリをソースアーカイブから出力側のチャートの新しいパート名へ直接コピーしようとします。バイト列はデコードも再圧縮もされません。そのコピーが不可能な場合にのみ、デコードまたはマージの経路に落ちます。仕組みそのもの——長さとハッシュ、一致すれば再生、しなければマージ——はChartMLを失わずにExcelチャートを編集するのメモで説明したものです。この記事は、それが静かに機能しなくなった経緯についてのものです

それでも全チャートがマージ経路を通っていた理由

指紋が1回呼び出しが早すぎるタイミングで取得されていたからです。HotXLSのチャート解析は、チャートパートに対するSAXパスと、それに続く一連のリカバリパスで構成されます。リカバリパスは、SAXハンドラが直接モデル化しない細部を生テキストから取り出します。XlsxChartParseSeriesFlagsは各<c:ser>ブロックを読んで<c:smooth>フラグと、マーカーの塗りつぶしおよびマーカー線のsrgbClr値を取得し、さらに項目軸と数値軸の軸交差モード、主目盛りと補助目盛りのスタイルを復元します。v2.382.3より前のParseChartXmlの末尾の順序は、軸グループの分類、既知XMLの構築、長さとハッシュの取得、そしてようやくXlsxChartParseSeriesFlagsの実行でした。つまり指紋は、smoothフラグもマーカー色も目盛りもまだ欠けたモデルを記述していたのです。保存時にはBuildChartKnownXmlが完成済みのモデルに対して走り、そこでは<c:smooth val="1"/>と復元されたマーカー色が出力されます。XMLは長くなり、ハッシュは変わり、XlsxChartRawModelUnchangedはFalseを返し、チャートはXlsxMergeChartXmlを通りました。マージは誰かが編集したチャートに対しては正しい操作ですが、バイト保存にはなりません。ツリーを再シリアライズし、系列・軸・プロットグループについて型付きモデルを優先させる所有権ルールにより、再生成されたノードが元のノードを置き換えるからです。コーパス実行で目に見えた結果は、誰も編集していないチャートの系列色のずれでした。保存のたびに、保存対象のワークブックの全チャートで、どこにも診断が出ないまま起きていました

修正は順序の並べ替え1つです。XlsxChartParseSeriesFlagsが既知XMLの構築前に走るようになり、指紋はアプリケーションが最初に目にする時点のモデルを記述します。この教訓はチャートに限りません。変更検知用の指紋は取得した瞬間の精度しか持たず、安全な瞬間はモデルを変更し得るすべてのパスが終わった後です。HotXLSには同じ2つの値の取得箇所がもう1つあり、保存成功後に出力ファイルに対して基準を再確立する箇所で、そちらは常に完全に解析済みのモデルに対して実行されていました。インポート時の箇所だけが例外だったのです

アンカーオフセットはどこへ行ったのか

文字どおりのゼロの中へです。描画パート内のtwoCellAnchorはチャートを2つのセル間に固定し、各コーナーはセルインデックスとそのセル内のオフセットを持ちます。from(ECMA-376 Part 1 §20.5.2.5)とto(§20.5.2.32)はそれぞれcol、colOff(§20.5.2.4)、row、rowOffを保持します。オフセットの単位はEMU(English Metric Units)で、1インチが914400です。Excelはマウスでチャートを配置またはリサイズすると非ゼロ値を書き込みますが、それはほとんどのチャートに当てはまります。two-charts.xlsxの最初のチャートは行0から始まりrowOffが19049、列8・行15で終わりcolOffが247650、rowOffが66674です。最後の列へ約1/4インチ入り込んでいます。HotXLSの描画パーサーはこれら4つの値を以前から読んでいました(画像のコードが使っていました)が、チャートライターはすべてのコーナーに対して<xdr:colOff>0</xdr:colOff>と<xdr:rowOff>0</xdr:rowOff>を出力し、保存時に各チャートをセルグリッドへ吸着させていました

HotXLSサンプルの最初のチャートにおけるxdr:twoCellAnchorのコーナー構造:fromはcol 0とrowOff 19049を、toはcol 8、colOff 247650、rowOff 66674を1インチ914400 EMUで保持します。ゼロを出力していたライターはチャートをグリッドに吸着させ、FFromColOff、FToColOffとその仲間がインポート値を再生するようになって解消しました
アンカーはチャートパートではなく描画パートにあるため、この修正は指紋の修正とは独立しています。ワークブックが本当にラウンドトリップするには、両方が出荷されている必要がありました
// 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を持つようになり、描画パーサーから値が入り、チャートの割り当て時に他のアンカー状態と一緒にコピーされます。これらは意図的にprivateです。公開アンカーAPIは従来どおり4つのセル座標FromRow、FromCol、ToRow、ToColで、Delphiコードから作成したチャートはこれまでどおりセル境界に配置されます。オフセットはラウンドトリップを忠実にするためのもので、セル内配置を機能として公開するためのものではありません。この修正が指紋とは独立している点に注意してください。アンカーはチャートパートではなく描画パートにあるため、ChartMLが完璧に再生されたチャートでも、これがなければグリッドへ飛んでしまいます。これらのEMU値の背景にある単位変換はHotXLSの画像ジオメトリとEMUスケーリングのメモで扱っています

チャートが未変更でラウンドトリップすることをどう証明するか

Excelで結果を開くのではなく、バイトを比較します。Excelは読み込み時に大量の修復と正規化を行うため、ずれたチャートも、アナリストがマーカー色の変化に気づくまでは正常に見えます。両方の不具合を捕まえたコーパステストは、編集なしのオープンとセーブの後に3つのことを行います。ワークシート、描画、チャートのリレーションシップをたどり、重複・孤立・宙に浮いたチャート参照があれば失敗させます。元ファイルと出力ファイルの間で、チャート種別、系列の数式、アンカージオメトリの署名を比較します。そしてtwo-charts.xlsxについては、両方のアーカイブから各xl/charts/chartN.xmlを読み、バイト単位で同一であることを要求します。同じチェックはDelphiで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;

この比較を意味のあるものにする3つの条件があり、どれも忘れると静かに失敗します。PreserveUnsupportedPartsはOpenの前にTrueでなければなりません。そうでなければ生バイトは取得されず、すべてのチャートがモデルから再構築されます。StrictOOXMLはFalseでなければなりません。strictモードは設計上再生成を強制するからです。そしてアプリケーションはオープンとセーブの間でチャートに触れてはなりません。プロパティの読み取りは問題ありませんが、型付きモデルを変更するセッターは指紋を反転させ、チャートをマージ経路へ送ります。これは正しい挙動であり、このテストが目的としているものではありません。チャートパートは保存時にワークブック全体のカウンターから採番し直されるため、シート順やチャート順が変わったワークブックは同一バイトを別のchartN.xml名に置きます。コーパスチェッカーが名前ではなくリレーションシップをたどるのはそのためです

両方の修正はHotXLS 2.382.0と2.382.3で出荷され、ローカルコーパスに対してWin32とWin64で検証されています。再保存したチャートのサンプルは独立したオフィススイートでPDFにレンダリングし、元ファイルとページ単位で比較しています。HotXLSはExcelのインストールを一切使わず、ネイティブDelphiとC++BuilderのコードからXLSXチャートを読み、編集し、書き出します。この忠実度がライブラリの責務になるのはそのためで、HotXLS Delphiスプレッドシートコンポーネントの製品ページに機能一覧とトライアル版があります