技術記事

DelphiのExcelテーブルスライサー:HotXLSラウンドトリップ

HotXLSは、Worksheet.Slicers.Add(Table, ColumnName)という1回の呼び出しで、DelphiとC++BuilderからExcelテーブルスライサーを作成します。スライサーが必要とするパッケージ内の8つの配置先——slicerパートとslicerCacheパート、2つのcontent-type override、対応するextLstブロック付きのワークシートとワークブックのリレーションシップ、そしてドローイングアンカー——をすべて書き出します

スライサーは、シートにドロップして使うコントロールのように見えます。しかしファイルの中身はまったく別物です。スライサーは、2つの新しいパート、2つのリレーションシップグラフ、3つの拡張ブロック、1つのドローイングアンカーにまたがって分散したレコードであり、Excelがパッケージを開くときに全体が1つの単位として検証されます。1つでも欠けると、機能が落ちたスライサーができるわけではありません。修復ダイアログが表示され、その後にスライサーが静かに削除されたワークブックが残ります。この失敗モードこそが、スライサーを手書きで出力するのを面倒にしている原因であり、ここでの興味深いエンジニアリングがレンダリングではなく簿記作業になる理由です

1つのスライサーがパッケージの8か所に触れるのはなぜか

スライサーは3つの別々の概念であり、OPCがそれを3つの別々の居場所に押し込めるからです。フィルタ状態(ワークブック単位)、ビュー(ワークシート単位)、そしてシェイプ(ドローイング単位)です。HotXLSはテーブルスライサーごとにちょうど8つの着地点を書き出しますが、そのすべてが構造上欠かせません

  • xl/slicers/slicerN.xml — ワークシートごとに1つのパートで、そのシート上のすべての<slicer>ビューをhttp://schemas.microsoft.com/office/spreadsheetml/2009/9/main名前空間で保持します
  • xl/slicerCaches/slicerCacheN.xml — ワークブックレベルで、スライス対象のテーブル列ごとに1つ存在し、実際のバインドを保持します
  • [Content_Types].xml内の<Override>が、これら2つのパートそれぞれに1つずつ、application/vnd.ms-excel.slicer+xmlとapplication/vnd.ms-excel.slicerCache+xmlを使って記述されます(ECMA-376 Part 2はすべてのパートにcontent typeを要求し、Excelはこれらがないパッケージを拒否します)
  • .../office/2007/relationships/slicer型のワークシートリレーションシップと、そのridを指すワークシートのextLst内の<x14:slicerList>拡張
  • .../office/2007/relationships/slicerCache型のワークブックリレーションシップと、同じridを列挙するワークブックのextLst内の<x15:slicerCaches>拡張
  • スライサーに位置とサイズを与える、シートドローイング内のmc:AlternateContentアンカー
  • キャッシュパート内の<x15:tableSlicerCache>ブロックで、これをテーブルidとテーブル列idにバインドします

拡張ブロックは、標準のECMA-376 Part 1 §18.2.10のextLstメカニズムを使います。各<ext>は機能を識別するuri属性を持ち、GUIDを認識できないリーダーはブロックをスキップします。HotXLSはテーブルスライサーとピボットスライサーを、このGUIDだけで区別します——テーブル用のslicerListが{3A4CF648-6AED-40f4-86FF-DC5316D8AED3}、ピボット用が{A8765BA9-456A-4dab-B4F3-ACF838C121DE}、ワークブック側の2つのslicerCaches拡張がそれぞれ{46BE6895-7355-4a93-B00E-2C351335B9C9}と{BBE1A952-AA13-448e-AADC-164F8A28A991}です。テーブルのバインド自体は{2F2917AC-EB37-4324-AD4E-5DD8C200BD13}の下に置かれます。これらの定数はユニット内でXlsxExtUriSlicerListX15、XlsxExtUriWbSlicerCachesX15、XlsxExtUriTableSlicerCacheとして公開されており、書き込みパスにマジック文字列が紛れ込むことはありません

Worksheet.Slicers.Addで作成した1つのテーブルスライサーに対してHotXLSが書き出すパッケージ内の8つの配置先:slicerパートとslicerCacheパート、2つのcontent type override、ワークシートとワークブックのリレーションシップ、x14:slicerListとx15:slicerCachesのextLstブロック、ドローイングアンカー。Excelがパッケージを検証するとき、これらはすべて構造上欠かせません
1回のWorksheet.Slicers.Add呼び出しは、パッケージ全体の8つの着地点に広がります:2つのパート、2つのcontent-type override、2つのリレーションシップ、2つのextLstブロック、1つのドローイングアンカーで、Excelがファイルを開くときにそれぞれが1つの単位として検証されます

そのまま再生できるパートと再構築が必要なパートの見分け方

HotXLSはこれを1つの規則で答えています。参照するものが保存時に番号を振り直されない限り、パートは元のバイトをそのまま再生できる、という規則です。テーブルスライサーキャッシュはこのテストに合格しないため、HotXLSは保存のたびに、ユーザーがスライサーにまったく触れていなくても、無条件にモデルから再構築します。理由はtableIdです。<x15:tableSlicerCache>要素は<table>のid属性でテーブルを識別するのですが、HotXLSは書き込み時にテーブルidをワークブック全体で振り直します。初期の実装は、オープン時にキャッシュパートを生のバイトとして保持し、保存時にそれを書き戻していました。これはほとんどの不透明なパートには正しい戦略です。しかしその実装が生成したファイルは、よくあるケースでは問題なく開けても、興味深いケースで修復を引き起こしました。前のシートにテーブルを追加して保存すると、キャッシュが別の何か、あるいは何も指していないテーブルidを指すようになるのです。このバグは、単純なオープン&セーブの回帰テストでは見えません。idがたまたま動かなかったからです

ピボット型のキャッシュは正反対の道を取ります。そのペイロードは<pivotTables>と、キャッシュされたアイテム、並べ替え状態、アイテムのインデックスを記述する<x14:data>ブロックで構成されます。HotXLS側で番号を振り直すものが何もなく、忠実にモデル化するのが高くつく構造です。これらは生のバイトとして保持され、そのまま再生されます。TXLSXSlicerCache.IsPivotとIsTableCacheで両者を見分けます。スライサーのビューは3つ目の方針、ダーティトラッキングに従います。触れていない<slicer>要素とそのドローイングアンカーはそのまま再生され、シート上のどのビューが変更された瞬間に、シートパート全体がモデルから再生成されます。これが判断の一般的な形です——参照が安定していればそのまま再生、ライターがIDを割り当て直すならモデルから再構築、どちらでも許容できるならダーティトラッキング——そしてこの方針はスライサーにとどまらず、テーマ、extLstブロック、calcChainに関するロスレスラウンドトリップのメモでさらに深く掘り下げられています

HotXLSにおけるスライサーパートごとの再生方針:テーブルスライサーキャッシュは、保存時にtableIdがワークブック全体で振り直されるため常にモデルから再構築され、ピボットキャッシュはpivotTablesとx14:dataが決して振り直されないため生のバイトをそのまま再生し、スライサービューはダーティトラッキングされ、シート上のビューが1つでも変更されるまではそのまま再生されます
パートごとの再生方針:テーブルスライサーキャッシュはテーブルidがワークブック全体で振り直されるため常に再構築し、ピボットキャッシュは中身が何も振り直されないため生のバイトを再生し、ビューはダーティトラッキングするので、触れていない保存はバイト単位できれいなまま残ります

Delphiからテーブルスライサーを作成する

TXLSXSlicers.Addが作成APIのすべてです。TXLSXTableと列名を渡すと、テーブル列のidを具体化し、背後にあるTXLSXSlicerCacheを検索または作成し、対応する定義名を予約し、テーブルの右側にビューをアンカーします。2引数のオーバーロードは配置を自動で選び、6引数のオーバーロードは1始まりの包含ボックスを明示的に受け取ります

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Slicer: TXLSXSlicer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('sales.xlsx');
    Sheet := Book.Sheets[0];

    // デフォルト配置:テーブルから2列離れた位置、
    // 19ptのデフォルト行高で13行分の高さ
    Slicer := Sheet.Slicers.Add(Sheet.Tables[0], 'Region');
    Slicer.Caption   := 'Sales region';
    Slicer.StyleName := 'SlicerStyleLight1';
    Slicer.ColumnCount := 2;

    Book.SaveAs('sales-sliced.xlsx');
  finally
    Book.Free;
  end;
end;

知っておく価値のある命名規則が2つあります。どちらもHotXLSの考案ではなくExcelの慣習です。キャッシュ名はSlicer_<column>で、識別子に使えない文字はアンダースコアに置き換えられ、衝突時には数字のサフィックスが付きます——Slicer_Region、次はSlicer_Region1、という具合です。さらに、すべてのキャッシュ名は、数式が#N/Aのワークブック定義名としても予約されます。TXLSXSlicers.ReserveCacheDefinedNameがやっているのはこれです。Excel自身がこの名前を作るのですが、これが重要です。キャッシュ名と定義名は名前空間を共有するため、すでにSlicer_Regionという名前を持つワークブックは、そうしないと次の保存で衝突します。テーブルとデータ検証をまとめて作るなら、データ検証、AutoFilter、テーブルに関する記事の定義名の相互作用が隣接するケースをカバーしています。共有はExcelと同じ規則に従います。TXLSXSlicerCaches.FindForTableColumn(ATableId, ATableColumnId)は新しいキャッシュを作成する前に参照されるため、同じテーブル列に対する2つのスライサービュー——ダッシュボードシートのものと詳細シートのもの——は単一のキャッシュパートを共有し、フィルタの同期が保たれます。ビューを削除すると、ワークブック内のすべてのシートを横断して逆方向のチェックが走り、それを参照する最後のビューが消えたときにのみ、キャッシュと予約された#N/A名が削除されます

リレーションシップidが両端で安定を保つ仕組み

安定するのは、HotXLSが他のすべてのリレーションシップを出力する正確な順序を再現してスライサーのridを計算し、新しいリレーションシップを末尾に追加するからです。ファイル内の既存のものは何も動きません。これは、別の方法をデバッグするまでは過剰エンジニアリングに聞こえます。ワークシートの.rels内のリレーションシップidは、ワークシートパートの内部から参照されています——ハイパーリンク、ドローイング、コメント、テーブルパートによって——そしてECMA-376 Part 2はそれらを安定したIDではなく、リレーションシップグラフ内の位置で解決します。ライターがスライサーのリレーションシップを途中に挿入すると、それ以降のすべての参照が1つずつずれ、シートは静かに間違ったパートを指し始めます。そこでXlsxWorksheetSlicerRelNumberは、決定的なチェーン全体を歩きます——外部ハイパーリンク、次にコメントVML、コメント、ヘッダー/フッター画像、スレッド化コメント、ドローイング、シート背景、プリンタ設定、テーブル、ピボットテーブル、クエリテーブル、テーブル単一セル——そしてその後にようやくスライサー用の次の番号を取得します。XlsxWorkbookSlicerCacheFirstRidはワークブック側で同じことをします。シート、sharedStrings、スタイル、VBA、外部リンク、テーマ、接続、ピボットキャッシュ、person、calcChain、シートメタデータ、XMLマップ、カスタムXML、そしてslicerCacheの並びです。この一般的な問題はOPCリレーションシップ解決に関する記事で詳しく解説しています。ワークブック側の拡張は、その番号からXlsxBuildWorkbookSlicerCachesExtXmlによって構築され、ソースファイルにすでにslicerCaches拡張ブロックがあれば、まず生のextLstから取り除かれます。モデルが古いコピーを残す代わりに、現在のridに対して再出力するためです

HotXLSがスライサーのリレーションシップidを安定させる仕組み:XlsxWorksheetSlicerRelNumberが外部ハイパーリンクからドローイング、テーブル単一セルまで決定的なワークシートチェーンを歩いてスライサーのリレーションシップを末尾に追加し、XlsxWorkbookSlicerCacheFirstRidがワークブック側で同じことをするため、挿入しても既存のrid参照がずれることはありません
リレーションシップidは位置で解決されるため、スライサーのリレーションシップは両側とも決定的な出力順序の末尾に追加されます——途中への挿入はそれ以降のすべての参照をずらし、シートを間違ったパートに向けてしまいます

AlternateContentアンカーとレガシーフォールバック

ドローイングアンカーは、スライサーマークアップが本当に奇妙になる唯一の場所で、その奇妙さは意図的です。HotXLSはECMA-376 Part 3に従って、シェイプをmc:AlternateContent要素の中に出力します。Excel 2013以降が取るChoiceブランチと、それより古いすべてのためのFallbackブランチを持ちます

// XlsxSlicerAnchorPayloadXmlが構造的に生成するもの:
//
//   <mc:AlternateContent>
//     <mc:Choice xmlns:sle15=".../drawing/2012/slicer" Requires="sle15">
//       <xdr:graphicFrame>
//         <a:graphicData uri=".../drawing/2010/slicer">
//           <sle:slicer xmlns:sle=".../drawing/2010/slicer" name="Region"/>
//         </a:graphicData>
//       </xdr:graphicFrame>
//     </mc:Choice>
//     <mc:Fallback><xdr:sp>...notice shape...</xdr:sp></mc:Fallback>
//   </mc:AlternateContent>

2つの名前空間を見てください。Requires="sle15"トークンは2012年のスライサー名前空間に解決されます。これがバージョンゲートです——2012年のスライサーを理解できないリーダーは、Choiceブランチ全体をスキップしなければなりません。一方、その中のgraphicData/@uriとsle:slicer要素は2010年の名前空間を使います。グラフィックフレームがずっと使ってきたドローイング側の識別子だからです。これを逆にすると、Excelが文句も言わずに開くものの、空のフレームとして描画されるファイルができあがります。印象的なほど役に立たない症状です。Fallbackブランチは、このバージョンではテーブルスライサーがサポートされていないと説明するテキストボディ付きのプレーンな長方形を保持します。2007年代のリーダーがコントロールの代わりに描くものはこれで、古いExcelがワークブックを壊さずにラウンドトリップできる理由もこれです。TXLSXSlicerは、SetBounds(ARow1, ACol1, ARow2, ACol2)による1始まりの包含ボックスと、個別のFromRow / ToColプロパティ、さらに通常のセル追従(move-and-size-with-cells)挙動のためのAnchorTypeでジオメトリを公開します。HasAnchorがFalseになるのは特定の1つのケースだけです。グループシェイプの中に入れ子になったスライサーです。これらはグループアンカー付きでそのまま再生され、HotXLSはあえて2つ目のアンカーを合成しません。そうするとシェイプが2回出力されてしまうからです

既存のスライサーを編集する

var
  Slicer: TXLSXSlicer;
  Cache: TXLSXSlicerCache;
begin
  Slicer := Sheet.Slicers.FindByName('Region');
  if Slicer <> nil then
  begin
    Slicer.SetBounds(2, 8, 16, 11);      // 1始まり、包含
    Slicer.ShowCaption := 1;             // -1 = 属性省略
    Slicer.LockedPosition := 1;
    Slicer.RowHeightEmu := 19 * XlsxEmuPerPoint;   // 241300 EMU

    Cache := Slicer.Cache;
    if Cache <> nil then
    begin
      Cache.SortOrder   := 'descending';
      Cache.CrossFilter := 'showItemsWithNoData';
    end;
  end;

  // 列上の最後のビューを削除すると、そのキャッシュと
  // 予約された #N/A 定義名も一緒に削除される
  Sheet.Slicers.Delete(Sheet.Slicers.IndexByName('Region'));

三値の整数プロパティには一言添えておきます。モデリング事故に見えて、そうではないからです。ShowCaption、LockedPosition、ColumnCount、StartItem、CustomListSortはすべて、ソースファイルに属性が存在せず、書き出し時も存在しないままにすべきことを-1で表し、0と1を明示的な値として使います。Excelは属性がない場合と明示的なデフォルトを数か所で異なる扱いをし、その区別を保つのが、触れていない保存のバイト差分をきれいに保つことです。RowHeightとColumnWidthが生の文字列として保持されるのも関連する理由からです。ExcelはこれらのEMU属性に小数を書き込み、整数で再解析するとラウンドトリップで精度が失われるからです。RowHeightEmuは同じストレージ上の整数の便利アクセサで、XlsxSlicerDefaultRowHeightは241300 EMU——XlsxEmuPerPoint比12700での19ポイントです

この対応の限界

正直な限界が2つあります。1つ目、HotXLSはテーブルスライサーを完全にモデル化していますが、ピボットスライサーの作成はモデル化していません。ピボットスライサーを含むワークブックを開き、そのIDを読み、保存をまたいでそのまま維持し、削除することはできますが、キャッシュ側はバイトとして再生され、ゼロから構築するAPIはありません。2つ目、スライサーはそれ自体では何もフィルタリングしません。テーブル列の上のUIであり、それが駆動する可視行の状態はExcel側のものです。HotXLSが書き出すのはコントロールとバインドであり、計算済みのフィルタ結果ではありません。保存したファイルで実際に行をフィルタリングしたい場合は、テーブルのAutoFilterも設定してください。<slicer>や<x15:tableSlicerCache>でHotXLSがモデル化していない属性は、すべて変更されずに引き継がれます。新しいExcelビルドから来たスライサーでも、移動後も馴染みのない属性を保持したまま再シリアライズされます。ダーティ再構築のパスを安全に取れるのは、まさにこの性質があるからです。モデルからの再構築は、モデルがパースしたもののスーパーセットである場合にのみ許容されます。ECMA-376 Part 1 §18.2.10のGUIDタグ付き拡張メカニズムと、[MS-XLSX]のスライサーパート定義が、そもそもこの保証を表現可能にしているのです

テーブルスライサーはHotXLS Delphi Componentに同梱されています。このコンポーネントは、Delphi 5から現行リリースまで、さらにC++BuilderとFree Pascalで、XLSX、XLSM、レガシーBIFFワークブックを、ExcelのインストールもOLEもなしで読み書きできます。完全なAPIリファレンスとトライアルビルドはHotXLS Delphi Spreadsheet Componentの製品ページにあります