技術記事

HotXLSが書くXLSXピボットフィールドのスキーマ妥当性

HotXLSが書くXLSXピボットテーブル定義は、pivotFieldとcacheField要素がECMA-376 Part 1 §18.10のスキーマで検証を通ります。axis属性はST_AxisトークンのaxisRow、axisCol、axisPageを使い、値領域のフィールドはdataField="1"を運び、アイテムリストは決して空にならず、キャッシュフィールドは数値のnumFmtIdを格納します。v2.384.33以降、リーダーも、以前は読み違えていたスキーマのデフォルトを尊重します

この大掃除の背後にあるバグは、みな気まずい性質を共有しています。どれも一度もテストに落ちていないのです。HotXLSがピボットを書き、HotXLSが読み戻し、全フィールドが正しいaxisに着地し、ラウンドトリップのスイートは何年もグリーンのままでした。問題は、ライターとリーダーが密かに私的方言で合意していたことです。Delphiから組んだピボットは、それを作ったコンポーネントには正常に見え、一方CT_PivotFieldとCT_CacheFieldに対する検査は、無効な列挙トークン、スキーマが禁じる空要素、そしてExcelが期待していたのに一度も受け取らなかったフラグを掘り出します。サーバーでピボットを生成し、Excelで開く人や自分のパーサーに流す人へ届けるなら、効力を持つ契約はスキーマだけです。自分のリーダーがたまたま許してくれるものではありません

HotXLSのラウンドトリップが誤ったaxisトークンを捉えられなかった理由

HotXLSのラウンドトリップが誤ったaxisトークンを捉えられなかったのは、リーダーが両方の綴りを受け付けていたからです。旧XlsxPivotAxisAttrはaxis="rowAxis"、colAxis、pageAxisを出力していました。英語としては自然に読めますが、スキーマには存在しません。ST_Axisが定義する値はaxisRow、axisCol、axisPage、axisValuesのちょうど4つです。その間、lxPivotXml.pasのPivotAxisFromTokenはスキーマトークンと創作トークンの両方にマッチしていたので、自己テストは全部通りました。ライターはいまスキーマトークンだけを出力し、リーダーは旧綴りの受け付けを続けます。以前のHotXLSバージョンが保存したファイルも、レイアウトを保ったままロードできるようにするためです

<!-- v2.384.33より前:無効なST_Axis値、空のCT_Items -->
<pivotField axis="rowAxis" defaultSubtotal="1"><items count="0"></items></pivotField>

<!-- v2.384.33以降 -->
<pivotField axis="axisRow" defaultSubtotal="1">
  <items count="4"><item x="0"/><item x="1"/><item x="2" h="1"/><item t="default"/></items>
</pivotField>
v2.384.33の前後のHotXLSのpivotField XMLの図。創作されたaxis値rowAxisと空のitems要素がCT_PivotFieldに違反していましたが、ライターはaxisRowのようなST_Axisトークンを、実在のitemエントリ、保持された非表示フラグ、スキーマが受け入れる末尾のデフォルト小計とともに出力するようになります
寛容なリーダーは両方の綴りを受け付けたため、ラウンドトリップは全部通るのに、ファイルはどんな厳格なスキーマ検査も破っていました。書くのは4つのST_Axisトークンだけ、CT_Itemsにはitemを1つ以上運ばせます

旧ライターが飛ばしていたCT_PivotFieldの要件

CT_PivotFieldが要求する3つの事柄を、旧BuildPivotTableXmlは欠落させていたか、間違えていました。第1に、値領域で集計されるフィールドは、自分の定義上でdataField="1"と言わなければなりません。ライターはいま、DataFieldsのエントリから参照される全フィールドにこのフラグを立てます。<dataFields>リスト内だけではなく。第2に、CT_Itemsはitemを1つ以上必要とするので、アイテムを持たないフィールドは空の<items count="0">を受け取る代わりに、要素ごと省略されます。第3に、各itemは自分の状態を保持します。非表示アイテムのh="1"(TXLSPivotItem.IsHidden)と、詳細折りたたみのsd="0"(IsDetailHidden)です。旧ライターはどちらも保存のたびに落としていました

微妙なのは末尾の小計アイテムです。フィールドがアイテムを持つとき、Excelはデータアイテムの後ろに小計関数1つにつき追加のitemを並べ、ST_ItemTypeで型を付けます。自動小計は<item t="default"/>、明示的な小計はsum、countA、avg、max、min、product、count、stdDev、stdDevP、var、varPです。HotXLSはこれらのエントリを保存時にTXLSPivotField.Subtotalsから導出し、items countへ算入します。AddPivotTableが作るフィールドは空のSubtotals集合から始まり、これはdefaultSubtotal="0"と末尾アイテムなしに書かれます。レポートに小計が要るときは、明示的に要求してください。命名の罠にも注意です。xlpsCountはcountA(全エントリ)へ、xlpsCountNumsはcount(数値のみ)へマップされます

HotXLSのピボットitemsリストの構造図。データitemエントリの後ろに、TXLSPivotField.Subtotalsから導出されたt=defaultやt=avgなどの末尾小計アイテムが続き、items countへ算入されます。xlpsCountがcountAへ、xlpsCountNumsがcountへ対応する命名の罠も書き添えます
AddPivotTableが作るフィールドは空のSubtotals集合から始まり、defaultSubtotal=0と末尾アイテムなしに書かれます。欲しい関数を要求すれば、ライターは関数ごとに1つのitemを導出してcountへ入れます
uses
  lxHandleX, lxPivot;

var
  Book  : TXLSXWorkbook;
  Sheet : TXLSXWorksheet;
  Pivot : TXLSPivotTable;
  Region: TXLSPivotField;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('orders.xlsx');
    Sheet := Book.Sheets[1];                  // 1始まり。XLSエンジンと同じ
    Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 3, 6, 'RegionTotals');
    if Pivot = nil then
      raise Exception.Create('Bad source range or anchor');

    Region := Pivot.AddRowField('Region');    // 該当フィールドがなければnil
    if Region <> nil then
      Region.Subtotals := [xlpsDefault, xlpsAverage];  // -> t="default"、t="avg"
    Pivot.AddColumnField('Quarter');
    Pivot.AddDataFieldByName('Revenue', xlpaSum);      // RevenueにdataField="1"を立てる

    Book.SaveAs('orders-pivot.xlsx');
  finally
    Book.Free;
  end;
end;

HotXLSはいま小計アイテムとスキーマデフォルトをどう読むか

HotXLSのリーダーはいま、t属性が存在し、かつdataではないitemをすべてスキップします。小計、総計、空白のエントリはキャッシュインデックスを運ばないからです。v2.384.34より前は、これらのエントリがCacheItemIndexを-1にした普通のアイテムとしてロードされ、Excel製ピボットは行き先のない幻のメンバーを抱えて戻ってきました。Itemsを歩くコードは、手でフィルタするしかありませんでした。ライターが末尾エントリをSubtotalsから組み立て直す以上、リーダーの仕事はそれをその集合へ翻訳することであって、データとして保持することではありません

2つ目のリーダー修正は、存在しない属性についてです。スキーマでは、CT_PivotFieldのdefaultSubtotalとCT_SharedItemsのcontainsStringはどちらもデフォルトがtrueで、Excelはそのデフォルト値のときは属性を省略します。HotXLSは欠けた属性をfalseとして読んでいました。その結果、Excelが保存したピボットはすべて、ロード時にデフォルト小計を黙って失い、プレーンテキストのキャッシュフィールドは文字列ではなく混合として分類されていました。これはaxisバグの鏡像です。すべての属性を必ず綴り出すライターは、デフォルトの経路を決して通らないので、それを晒すのは他のプロデューサーのファイルだけになります

キャッシュフィールドのnumFmtId="General"が無効だった理由

numFmtId="General"が無効だったのは、ST_NumFmtIdが符号なし整数であって書式名ではないからです。旧キャッシュライターはすべてのcacheFieldにその文字列をハードコードしていました。セルの書式設定ダイアログでユーザーが目にする名前を借りたのです。HotXLSはいま、キャッシュフィールドのNumberFormatを数値として書きます。何も設定していなければ0、つまり組み込みの標準(General)書式です。スキーマから属性の型を取る厳格なパーサーは旧値を一蹴します。そしてこれこそ、修復ダイアログに化ける種類の障害です。Excelの修復プロンプトの背後にあるOPCとマークアップの規則の記事が、そのダイアログの発火条件を扱っています

行65535以下のピボットテーブルが切り捨てられた理由

行65536以下に置かれたXLSXピボットテーブルが切り捨てられたのは、共有ピボットモデルがFirstRow、LastRow、FirstHeaderRow、FirstDataRowとその列対応物をWordで保存し、行シフトのコードがMin(.., High(Word))でクランプしていたからです。BIFF8のSxViewレコードの名残です。16ビットで足りる世界では正しいのですが、XLSXのシートは1,048,576行まで走ります。v2.384.37以降、TXLSPivotTableのこれらのプロパティはIntegerになり、クランプは消え、値を狭めるのはBIFF8ライターだけです。TXLSXWorksheet.AddPivotTableとAddPivotTableCopyは、1..1048576 × 1..16384の外側のアンカーや、範囲がグリッドをはみ出すコピーに対してnilを返します

行70001のHotXLSピボットアンカーと16ビット上限の対比の図。FirstRowとLastRowはWordとして保存され、MinでHigh(Word)の65535にクランプされ、行のライン以下のピボットが切り捨てられていました。v2.384.37がモデルをIntegerフィールドへ移し、グリッド外ではnilを返すまで
Wordフィールドは、シートが1048576行まで走るフォーマットに残ったBIFF8 SxViewの名残でした。行65536超のアンカーは16ビット範囲へ折り返し、保存時にピボットを失っていました
var
  Pivot: TXLSPivotTable;
  Check: TXLSXWorkbook;
begin
  // 行70001は以前16ビット範囲へ折り返した。いまは保存とロードを生き延びる
  Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 70001, 1, 'LateTotals');
  if Pivot = nil then
    Exit;  // アンカーがシート外、またはソース範囲を解決できない
  Pivot.AddRowField('Region');
  Pivot.AddDataFieldByName('Revenue', xlpaSum);
  Book.SaveAs('late.xlsx');

  Check := TXLSXWorkbook.Create;
  try
    Check.Open('late.xlsx');
    Pivot := Check.Sheets[1].PivotTables.FindByName('LateTotals');
    Assert((Pivot <> nil) and (Pivot.FirstRow = 70001));
  finally
    Check.Free;
  end;
end;

クラシックXLSエンジンにはv2.384.38で対応する修正が入りました。このモデルは生の0始まりのSxViewとDConRef値を保存し、AddPivotTableのアンカーを素通しさせていました。一方、ドキュメントもデモもXLSXエンジンも、Cells[Row, Col]のような1始まりセルを使っています。両エンジンともモデルには1始まりの位置を保持し、BIFF8リーダーがレコード境界で1を足し、ライターが1を引きます。(0, 0)でアンカーしていたコードは(1, 1)へ移る必要があります。クラシックのAddPivotTableはいま、1..65536 × 1..256の外側のアンカーに対してnilを返すからです。新しい呼び出しは旧呼び出しと同じバイトを書きます。レコードレイアウト自体は無変更で、クラシック.xlsピボットテーブルの背後にあるBIFF8 SXレコードで解説しています

自分のリーダーではなくスキーマで検証する

教訓はピボットの外へも一般化します。寛容なリーダーはライターの違反を隠すので、自分のコードを通るラウンドトリップが証明するのは整合性であって正しさではありません。ここのバグはどれも、寛容な側と誤った側が同じライブラリに住んでいたからこそ生き延びました。この種の欠陥を実際に捉える検査は、生成パーツのスキーマ検証、属性がデフォルトで省略されたExcel製ファイルを自分のリーダーへ通すこと、そしてパース結果ではなく正確なトークンを固定するフィクスチャです。APIから組むピボットは、集計フィールド付きXLSXピボットテーブルの構築と更新で示した集計フィールド、集計アイテム、構成比レイアウトも含めて、コード変更なしで修正済みXMLを受け取ります。一方、Excelファイルからロードしたピボットは、手を加えるまで元のパーツを再生し続けます

これらの修正はすべて、現行のHotXLS Delphiスプレッドシートコンポーネントに搭載済みです。マシンにExcelもCOMオートメーションもなしで、DelphiとC++BuilderからXLS、XLSX、ピボットテーブルを読み書きします