技術記事

DelphiのODSピボットテーブルとXML名前空間スコープ

HotXLS Delphi Excel Componentは、v2.382.0以降、オープン時にcontent.xmlの<table:data-pilot-tables>サブツリーをそのまま取得し、保存時に再生することで、ODSのオープンと保存のサイクルをまたいでOpenDocumentのデータパイロットテーブルを保っています。v2.382.1以降は、断片が祖先の宣言したXML名前空間バインディングもすべて持ち運ぶため、保存されたピボット定義はHotXLSだけでなくあらゆる消費者に対して整形式のままです

両方の変更を促した不具合は、厳格なコーパス実行から出てきました。LibreOffice 6.1の開発ビルドが書いたサンプルofficial-pivot.odsには、Sheet1.A2:E30を読み、結果をSheet1.G6:J18に置くDataPilot1という1つのピボットが入っています。これをHotXLSで開き、変更せずに保存し、出力内の<table:data-pilot-table>要素を数えると、入力1個に対して出力0個です。Win32でもWin64でも同じでした。テストはピボットに一切触れていません。最初の調査ではセル定数を比較しただけで通っており、この損失を暴いたのは構造アサーションでした。「値が一致する」がラウンドトリップ忠実度の弱い定義であることを思い知らされます

ライブラリの保存後にODSピボットテーブルが消える理由

ODSのピボットテーブルが消えるのは、HotXLSがOpenDocumentのデータパイロットテーブルのインメモリモデルを持っておらず、ODSライターがcontent.xmlを完全にモデルから組み立てるからです。ライターは自動スタイル、ワークシートごとの<table:table>、<table:content-validations>、<table:named-expressions>、<table:database-ranges>を組み立て、それぞれがワークブックが実際に保持しているオブジェクトから生成されます。ピボット定義——ODF 1.3 Part 3 §9.6、ピボットごとに1つの<table:data-pilot-table>を持つ<table:data-pilot-tables>コンテナーで、table:source-cell-range、table:data-pilot-fieldの子、table:target-range-address、table:buttonsを運びます——には住み着くオブジェクトがないため、再生成されたパートはそれを単純に省略します

XLSXとの対比は意図的です。HotXLSはSpreadsheetMLのピボットキャッシュとピボットテーブルを本物のモデルへ解析し、Delphiから構築し、集計フィールドで拡張し、更新することができるため、それらはコピーではなく書き直されることで保存を生き延びます。ODSのピボットははるかにまれな要望で、ラウンドトリップのためだけにODFのデータパイロット語彙をモデル化するのは、誰も編集しない大量のコードになります。実用的な答えは、HotXLSがXLSXの未知のextLstブロックにすでに適用しているものと同じです。モデル化しないものは保持する。できるならバイト単位で、できないならイベント単位で

最初のPosベースの取得が間違っていた点

v2.382.0の取得はピボット定義をcontent.xmlから単なる文字列として切り出しており、その断片には意味を与えている名前空間宣言が欠けていました。実装はそのままの短さです。パートをWideStringにデコードし、Posで開始タグを探し、その後の終了タグを探し、その範囲をワークブックのFRawOdsDataPilotTablesXmlにコピーします

// HotXLS v2.382.0 -- 1リリース後に置き換えられた
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // content.xml全体をメモリ上に
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

カウントのアサーションは緑になり、修正は出荷されました。それを見つけたのは同じ日に追加された2つ目のより厳しいチェックでした。保存されたパッケージのすべてのXMLパートがHotXLSの外にある独立した名前空間対応パーサーに渡され、そのパーサーが新しいcontent.xmlを未束縛プレフィックスのエラーで拒否したのです。LibreOfficeのピボットはプロデューサー拡張属性を運んでいます。ページフィールドのloext:ignore-selected-page="true"、各レベルのcalcext:repeat-item-labels="false"です。切り出された文字列にはこれらの属性は含まれていましたが、それを束縛するxmlns:loextとxmlns:calcext宣言は含まれていませんでした。それらの宣言はソースファイルの<office:document-content>ルートに、35個、ピボットから2000文字離れた場所にありました

これを化粧上の問題ではなく致命的な失敗にするルールを定めているのが、W3C Namespaces in XML 1.0 §6.1です。名前空間宣言は、それが現れる要素の開始タグからその要素の終了タグまでスコープに入り、そのスコープ内のすべてのプレフィックス付き名前はそれに対して解決されます。文書からサブツリーを切り出せば、そのスコープからも切り出したことになります。HotXLSは自前の<office:document-content>ルートを11個の宣言——office、table、text、style、number、fo、draw、svg、xlink、calcext、tableooo——で書くため、calcext:はたまたま解決し、table:もたまたま解決し、loext:は解決しませんでした。名前空間対応パーサーは未束縛プレフィックスを整形式性の違反として扱います。つまり、1つの属性だけではなくパート全体が読めなくなるのです

HotXLSのPosベース取得がofficial-pivot.odsで見落としていたもの:ピボットのサブツリーはloextとcalcextの拡張属性を運びますが、それを束縛するxmlns宣言は35個の束縛だけ離れたoffice:document-contentルートにあります。そのため切り出された断片は使うプレフィックスをすべて未束縛のままにし、名前空間対応パーサーはcontent.xml全体を拒否しました
名前空間宣言はその開始タグから終了タグまでスコープに入ります。文書からサブツリーを切り出せばそのスコープからも切り出したことになり、1つの属性がパート全体を読めなくしてしまいます

HotXLSは祖先のxmlns束縛を断片へどう運ぶのか

HotXLS v2.382.1は文字列スライスを、自前のストリーミングTXMLReaderによるcontent.xmlの走査に置き換えました。各バインディングが宣言された深さでタグ付けした名前空間バインディングのスタックを保ち、ターゲットに到達した瞬間に、まだ有効なバインディングを断片のルート要素へコピーします。リーダーはPreserveWhitespaceTextを有効にして走るため、テキストノードは書かれたとおりに返ります。また、再構築されるタグは、リーダーが通常パートパーサーに渡す正規名ではなく、TXMLReader.RawNameとTXMLReader.Attribute[I].RawName——ファイル内のプレフィックスの綴り——を使います。ループの核心はこちらです

HotXLS v2.382.1が名前空間スコープごとデータパイロットのサブツリーを取得する仕組み:ストリーミングTXMLReaderの走査が、宣言された深さでタグ付けしたxmlnsバインディングのスタックを保ち、table:data-pilot-tablesのターゲットで最も内側から順にたどり、Seenセットでシャドウイングを尊重し、要素自身が宣言するプレフィックスは飛ばし、終了タグと空要素の両方でバインディングをポップします
ターゲットをリーダーの正規名で照合するため、tableプレフィックスの綴りを変えるプロデューサーでも動きます。また、閉じられないサブツリーは、保存時に中途半端な断片を書き戻すのではなく例外を投げます
// Namespaces:'xmlns:p=uri' のTStringListで、宣言された深さをObjects[]に持つ
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // 要素、テキスト、CDATA、コメント
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // 末尾の '>' または '/>' を先に取り除く
      ...
      // 有効な祖先のバインディングを断片のルートへ運ぶ
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // 最も内側のバインディングが勝つ
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // ここで宣言済みならスキップ
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // サブツリーが閉じた
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // スコープから出る
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

このループの3つの詳細が正しさを支えています。スタックを最も内側のバインディングから外側へたどり、各プレフィックスをSeenに記録することでシャドウイングを実装しています。より近い祖先がxmlns:tableを再束縛すれば、§6.1が要求するとおり、より近い値が勝ちます。要素自身がすでに宣言しているプレフィックスを飛ばすことで、同じ属性を2回出力するのを避けます。それは別の整形式性エラーになります。そしてポップのルールは終了タグおよび空要素で発火します。<x/>はEndElementイベントを一切生成しないからで、これはXLSXのextLst取得が学ばなければならなかったのと同じ自己閉じの罠です。ターゲットをRawNameではなくReader.Nameで照合するのは、より静かな利点です。リーダーはODFのtable名前空間URIをtableプレフィックスへ正規化するため、それをt:data-pilot-tablesと綴るプロデューサーでも一致し、出力される断片はプロデューサーが使ったプレフィックスをそのまま保ちます

ループは推測も拒みます。取得が開いたままパートが終わった場合——切り詰められた、または壊れたcontent.xml——OdsCaptureDataPilotTablesXmlは中途半端な断片を返さず例外を投げます。そんな断片は保存時に書き戻され、壊れた入力を、ライブラリの名前が付いた壊れた出力に変えてしまうからです

断片は保存されたcontent.xmlのどこに着地するのか

HotXLSは取得した断片を、生成する<table:named-expressions>の直後、<table:database-ranges>より前の<office:spreadsheet>へ書き込みます。<office:spreadsheet>のODF 1.3 Part 3コンテンツモデルは、これらの末尾の子に固定の順序を定めているため、そのままのブロックをライターの居場所に単純に追加することはできません。特定のスロットへ差し込む必要があります。呼び出し側から見ればAPIも設定項目もありません。定義は普通のオープンと保存に同行します

HotXLSのODS保存で取得済みピボット定義が着地する場所:office:spreadsheetの子は、生成されるtable要素からtable:content-validations、table:named-expressionsへと固定のODF順序に従い、そのままのtable:data-pilot-tables断片はtable:database-rangesより前に差し込まれます。定義はOpenODSとSaveAsODSに同行するためAPIは存在しません
そのままのブロックはライターの居場所に追加できるものではありません。また、断片が運ぶ祖先バインディングのコピーは無害です。Namespaces in XMLは入れ子のスコープでのプレフィックスの再宣言を認めているからです
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // ピボットのソース範囲の内側を編集
    Book.SaveAsODS('official-pivot-out.ods');
    // 出力のcontent.xmlは依然としてDataPilot1を、そのソース範囲、
    // フィールド、ターゲット範囲、ボタン、loext:/calcext:属性とともに保持する
  finally
    Book.Free;
  end;
end;

この冗長性は意図的なもので、知っておく価値があります。断片のルートは、保存された文書ルートも宣言しているにもかかわらずxmlns:tableとxmlns:calcextを繰り返すようになりました。Namespaces in XMLは入れ子のスコープでのプレフィックスの再宣言を認めているため、重複は無害です。LibreOfficeのサンプルでは、運ばれる集合は35個のルート宣言すべてで、8357文字の定義に対して約2キロバイト増えます。取得はサブツリーが実際にどのプレフィックスを使うかを分析しないからです。使用プレフィックスの走査をすれば削れますし、後で入るかもしれません。まず正しさ、次にコンパクトさです

そのまま再生するためにXMLからサブツリーを切り出す際のルール

一般的な教訓は、サブツリーは自分でそうして初めて自己完結するということ、そして忘れたときに最初に壊れるのが名前空間スコープだということです。HotXLSが「モデル化しないものを保持する」あらゆる取得に適用しているチェックリストは次のとおりです

  • 文書は本物のリーダーで走査し、スコープ内のバインディングを追跡する。Posによる文字列検索はスコープをまったく見られませんし、同じ名前の入れ子要素、コメントやCDATAセクション内の一致文字列、タグのテキストを含む属性値でも誤一致します
  • 有効なバインディングを断片のルートへコピーする。最も内側から順に、プレフィックスごとに1回、ルートがすでに宣言しているものは飛ばす
  • 出力するタグでは生のプレフィックスの綴りを保つ。ターゲットはリテラルのプレフィックスではなく解決された名前空間で照合する
  • 空白のテキストノードを保持し、空要素は終了タグのイベントなしに自分のスコープを閉じることを忘れない
  • 保存されたパートは、テスト対象のライブラリではないパーサーで検証する。ライブラリは、書いたときと同じ寛容なコード経路で自分の出力を喜んで読み直してしまいます

最後の点こそが、2回目にHXLS-003を実際に見つけたものです。v2.382.0の受け入れチェックは、保存されたcontent.xml内のdata-pilot-table開始タグを数える正規表現でした。正規表現が見るのはタグであり文書ではありません。そのタグのプレフィックスが束縛されているかは分かりません。v2.382.1で追加された厳格なコーパスランナーは、保存されたパッケージのすべてのXMLパートと.relsパートを名前空間対応パーサーで解析し、次にピボットのツリー——タグ、ソート済み属性、テキスト、子を再帰的に——を元ファイルと比較します。この比較は名前空間展開されているため、プレフィックスの綴りを変えても通り、未束縛プレフィックスは通りません

そのまま保証が終わる場所

そのままの再生は定義を保つだけで、定義を理解はしません。境界はそこから従います。HotXLSはODSピボットを読み、編集し、更新するAPIを公開していないため、FRawOdsDataPilotTablesXmlは内部フィールドであり、観測できる挙動は定義が生き残ることだけです。断片はバイトとしてコピーされるのではなく、リーダーのイベントから再シリアライズされます。属性の引用符と自己閉じ形式は正規化され、テキストと空白は保たれます。取得されたXMLを出力するのはODSコンテンツライターだけなので、.odsから開いて.xlsxとして保存したワークブックはピボットを失い、.xlsxから開いたワークブックには.ods保存へ再生するものが何もありません。ODSインポートとエクスポート経路の非対称性は、どこでもそうであるようにここでも当てはまります。そして定義は不透明なので、こちらの編集には追随できません。HotXLSでSheet1の名前を変えたりソースデータを移動したりしても、保存されたピボットは依然としてSheet1.A2:E30を指し、次に更新したときに壊れた範囲を報告するのは消費者側になります。順序に関する注意も1つあります。HotXLSはAutoFilterの範囲をピボット断片の後に<table:database-ranges>として出力しますが、コーパスのサンプルはデータベース範囲を持ちません。そのためフィルターとピボットの両方を持つワークブックは、これら2つの要素の相対順序に依存する前にODFスキーマバリデーターを通すべきです

コーパスのサンプルだけでなく、自分の使うプロデューサーのファイルでテストしてください。名前空間の引き継ぎは、プロデューサーが祖先で宣言したあらゆるプレフィックスを扱えますが、ピボット要素自体でプレフィックスを宣言する文書や、table語彙に既定の名前空間を使う文書は、LibreOfficeのサンプルが通らないスキップとシャドウイングの分岐を行使します。どちらも実装済みですが、どちらもまだコーパスにサンプルがありません。そしてこの区別こそ、チェンジログの項目がぼやけさせがちな類のものです

v2.382.0のデータパイロットのそのまま取得と、v2.382.1の名前空間スコープの修正は、現行のHotXLS Delphi Excel Componentに含まれています。製品ページにはDelphiとC++Builder向けのODS、XLSX、XLSの読み書き対応の全体が掲載されています