技術記事

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

ピボットテーブルを1つ含むODSワークブックがライブラリを通ると、ソースのセルを1つ編集しただけで、保存されたファイルはセル値はすべて無傷なのにピボットが消えて開きます。content.xmlは入力時に<table:data-pilot-table>を1つ持っていたのに、出力時にはゼロになっている。見かけ上の修復は、ピボットXMLを文字列としてコピーして渡すことです。要素数は戻ります。その直後、名前空間を理解するパーサーが未束縛プレフィックスエラーで保存されたパートを丸ごと拒否します。2つの失敗の根は1つです。XMLの断片は、依存する名前空間宣言が一緒に運ばれるときだけ、安全に運べるということ

ODSのピボット定義はcontent.xmlのどこにあるのか

OpenDocumentのピボットテーブル(フォーマット用語ではdata pilot)は、content.xml内でシートの後に格納される定義です。このパートを自前のオブジェクトモデルから再構築するライターは、モデルに置き場所があって初めてそれを保持します。ODF 1.3 Part 3 §9.6がコンテナを定めています。<table:data-pilot-tables>はピボットごとに1つの<table:data-pilot-table>を持ち、それぞれがtable:source-cell-range、table:data-pilot-field子要素の集合、table:target-range-address、table:buttonsリストを持ちます。ピボットが表示する結果セルはワークシート内に普通のセルとして存在します。だからセル、スタイル、入力規則、名前付き式、データベース範囲だけを再生成するライターは、画面の数字は同じでピボットのからくりが後ろにない、完全に妥当なスプレッドシートを作ります

HotXLS Delphi Excel ComponentはOpenODSで1つもセルをパースする前に、content.xmlから<table:data-pilot-tables>サブツリー全体を読み取り、それからそのコレクションをピボットモデルへの翻訳に試みます。モデルが表現できる素の形の定義、つまりローカルなソース範囲、行フィールド、集計メジャー、ソースデータと整合する保存済み結果を持つものは、ワークシートのPivotTablesリストのTXLSPivotTableになります。XLSXピボットから得られるのと同じオブジェクトで、Delphiから計算済みフィールドを追加したりリフレッシュしたりできます。その形から外れるものはすべて、取り込んだXMLのままコレクションに保持され、SaveAsODSがそれを書き戻します。翻訳器が知らない名前空間の属性が1つあるだけで十分です。LibreOfficeが保存したピボットはloext:やcalcext:属性を運ぶことがあり、loext:属性が1つあれば定義はキャプチャパスに回されます

フォールバックはワークブック単位のオール・オア・ナッシングです。3つのピボットのうち1つでも翻訳可能な形から外れると、3つとも取り込んだXMLのまま残ります。分割するとcontent.xmlに1つのコンテナに対する2つの真実の源ができ、片半はオブジェクトから、もう片半はテキストから再構成され、両者の食い違いはそのままユーザーのファイルに着地します。コレクションを丸ごと保つのが安全なデフォルトで、これはHotXLSがXLSXの未知のextLstブロックに適用してきたのと同じ方針です(未知のextLstブロックのロスレス往返)。モデルが運べないものは、書かれたまま保持します

切り出したピボット定義はなぜ保存ファイルを壊すのか

文字列検索でcontent.xmlから切り出したピボット定義は、プレフィックスに意味を与える名前空間宣言を失います。Namespaces in XMLは宣言のないプレフィックスを整形式エラーとして扱います。文字列アプローチは短いコードです。パートをWideStringにデコードし、Posで<table:data-pilot-tablesを探し、その後に対応する閉じタグを探し、間を全部コピーする。出てくるテキストは完全に見えます。ページフィールドにloext:ignore-selected-page="true"が、全レベルにcalcext:repeat-item-labels="false"が入っています。しかし、これらのプレフィックスを束縛するxmlns:loextとxmlns:calcextの宣言は、何千文字も手前の文書ルート<office:document-content>の上に置かれたままです

Namespaces in XML 1.0 §6.1は、宣言をその開始タグから対応する終了タグまでのスコープに置き、内側のプレフィックス付き名はすべてそれに対して解決します。文書からサブツリーを切り出すと、そのスコープからも切り出されます。断片を新しい文書に貼ったとき、各プレフィックスが解決されるのは、新しいルートがたまたまそれを宣言している場合だけです。HotXLSは自前の<office:document-content>を、ライターが使う名前空間(office、table、text、style、fo、draw、calcext、tableooo、ofなど)とともに書きます。切り出したコピーではtable:は解決し、calcext:も解決しますが、loext:は解決しません。未束縛のプレフィックスが1つあるだけで、名前空間を理解するパーサーはパート全体を拒否します。ピボットが静かに劣化してくれるようなことはなく、周囲のシートデータまで一緒に読めなくなります

PosベースでLibreOfficeピボットを切り取ると何を見逃すか:ピボットサブツリーはloextとcalcextの拡張属性を運ぶ一方、それらを束縛するxmlns宣言はoffice:document-contentルート側に置かれている。切り出した断片ではこの2つのプレフィックスが未束縛のまま残り、名前空間を理解するパーサーはcontent.xml全体を拒否する
名前空間宣言は開始タグから終了タグまでがスコープです。文書からサブツリーを切り出すと、そのスコープからも切り離され、1つの属性がパート全体を読めなくします

文字列検索には、リーダーベースの取り込みでは絶対に会わない別の死角もあります。Posはコメントの中、CDATAセクションの中、そのテキストを引用する属性値の中のタグテキストにも一致します。同名のネストした要素を区別できません。しかもリテラルのプレフィックスtable:に一致しますが、ODFはプロデューサーがtable名前空間を好きなプレフィックスに束縛することを許しています

HotXLSは名前空間スコープを断片にどう載せるのか

HotXLSは、宣言が現れた深さとともにすべての名前空間宣言を追跡するストリーミングXMLリーダーでピボットサブツリーを取り込み、ターゲット要素が到達した瞬間、まだ効いている宣言を断片の開始タグにコピーします。スタックはxmlnsやxmlns:prefixを宣言する開始タグごとに伸び、束縛を宣言した要素が閉じると縮みます。空要素も同じ扱いが必要です。<x xmlns:p="..."/>は別個の終了タグイベントを決して出さないので、ポップは空の開始タグでも発火させないと、束縛が兄弟要素に漏れ出します

ターゲット地点では、取り込みはスタックを最も内側の束縛から外側へ歩き、各プレフィックスを1回ずつ書きます。内側から外側へ歩くのがシャドーイングの実装です。<office:spreadsheet>がルートがすでに宣言したプレフィックスを束縛し直しているなら、ピボット地点で効いているのは近いほうの値です。§6.1が要求するとおりの挙動で、すでに書いたプレフィックスの集合が外側の値の追従を防ぎます。ピボット要素自身が宣言するプレフィックスはスキップします。1つのタグにxmlns:tableを2回書くのは、これも別の整形式エラーだからです

HotXLSがデータパイロットサブツリーを名前空間スコープごと取り込む仕組み:ストリーミングXMLリーダーのパスが、宣言時の深さを刻んだxmlns束縛のスタックを保持し、table:data-pilot-tablesのターゲット地点で内側から順に歩き、すでに書いたプレフィックスの集合でシャドーイングを尊重し、要素自身が宣言するプレフィックスをスキップし、終了タグでも空要素でも束縛をポップする
ターゲットをリテラルのプレフィックスではなく名前空間で照合するので、tableプレフィックスを別のつづりに替えたプロデューサーでも動きます。閉じないサブツリーは、半分の断片を保存時に書き戻す代わりにraiseします

ターゲットは名前空間で照合されるので、tをODFのtable URIに束縛した<t:data-pilot-tables>を書くプロデューサーでも認識されます。取り込まれたタグのつづりはファイルのままです。空白テキストノードは書かれたとおりに戻ります。属性の引用符の選択と<x/>か<x></x>かの選択は正規化されます。断片はバイト単位のコピーではなくリーダーイベントから再シリアライズされるからです。ピボットサブツリーが開いたままcontent.xmlが終わる場合(切り詰められたファイルで起こります)、オープンは例外で失敗します。次の保存時に書き戻される半分の定義を残すより、こちらが良い

運ぶ宣言集合は意図的に寛容です。取り込みはスコープ内のすべての束縛をコピーします。サブツリーが使うかどうかは問いません。だから断片のルートは、保存された文書のルートが宣言しているにもかかわらず、xmlns:tableやxmlns:calcextを繰り返します。ネストしたスコープでのプレフィックス再宣言はNamespaces in XMLの下で合法で、ルートに何十もの名前空間を宣言するプロデューサーのファイルでも、数キロバイトが増えるだけです。リストを刈り込むには、要素名、属性名、QNameを保持する属性値に現れるすべてのプレフィックスをサブツリーから精査する必要があり、そこで1つ見逃せば未束縛プレフィックスの失敗がすぐに戻ってきます。冗長な宣言数個のコストは、そのリスクよりはるかに安い

ピボットを運ぶワークブックのオープン、編集、保存

呼び出し側で切り替えるものは何もありません。ピボット定義は普通のOpenODSとSaveAsODSに乗って運ばれます。ワークシートは1から番号が付き、両メソッドとも成功で1を返します:

uses
  System.SysUtils, lxHandleX;

procedure EditPivotSource(const InFile, OutFile: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS(InFile) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InFile]);
    // E2はSheet1.A2:E30というピボットソース範囲の内側にある
    Book.Sheets[1].Cells[2, 5].Value := 1250.0;
    if Book.SaveAsODS(OutFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [OutFile]);
  finally
    Book.Free;
  end;
end;

保存されたcontent.xmlでは、ピボットコレクションは<office:spreadsheet>の中、ライターが生成する<table:named-expressions>の直後、<table:database-ranges>の前に置かれます。AutoFilter(HotXLSはデータベース範囲として書きます)とピボットの両方を運ぶワークブックでは、この2つの要素が隣り合います。パイプラインで厳密なODFスキーマバリデーターを回しているなら、まずここを見ます

取り込んだピボット定義がHotXLSのODS保存でどこに着地するか:table:content-validationsと生成されたテーブルが先に来て、次にtable:named-expressions、そのままのtable:data-pilot-tables断片、table:database-rangesと続く。APIは不要で、定義はOpenODSとSaveAsODSに乗って運ばれる
そのまま書き戻すブロックはoffice:spreadsheet内の1つの固定位置に書かれます。運ばれてきた祖先の束縛のコピーは無害です。Namespaces in XMLはネストしたスコープでのプレフィックス再宣言を認めているからです

この例の編集はソースの値を変えるだけで、ピボットの結果セルはそのままです。取り込まれた定義は書かれたとおりに再生されます。HotXLSは再計算せず、ファイルを開く表計算アプリケーションが次のリフレッシュでピボットを再計算します。アドレスも同じです。Sheet1をリネームしたりソースブロックを移動したりすると、保存された定義はまだSheet1.A2:E30を指し、それを消費するアプリケーションはリフレッシュ時に壊れた範囲として報告します。ピボットをこの方法で運ぶワークブックでは、シート名とソースブロックを安定させておいてください

あるピボットがどちらのパスを通ったかを知るには

いちばん手っ取り早い確認はワークシートのPivotTablesリストです。翻訳された定義はここにTXLSPivotTableとして現れ、取り込まれた定義は現れません。変換レポートは疑問の後半、別の出力フォーマットでピボットに何が起こるか、に答えます。GetConversionReportは保存も再計算もせずにワークブックを検査し、返されたレポートの所有権は呼び出し側にあります:

uses
  System.SysUtils, lxHandleX, lxDiagnostics;

function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
  I, Modeled: Integer;
  Report: TXLSConversionReport;
begin
  Modeled := 0;
  for I := 1 to Book.Sheets.Count do          // Sheetsは1始まり
    Inc(Modeled, Book.Sheets[I].PivotTables.Count);

  Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
  try
    for I := 0 to Report.Count - 1 do
      if (Report[I].Feature = 'OpenDocumentPivotTables') and
         (Report[I].Fidelity = xlsConversionDropped) then
        Exit('Captured ODF pivot XML: keep this workbook on .ods');
  finally
    Report.Free;
  end;

  if Modeled > 0 then
    Result := Format('%d pivot table(s) in the pivot model', [Modeled])
  else
    Result := 'No pivot definitions';
end;

出力パスが重要なのは、取り込んだXMLがODFの語彙で、それを出力するのはODSライターだけだからです。.xlsxで保存すると、取り込まれたコレクションにはSpreadsheetMLの対応物がなく、レポートはそれをOpenDocumentPivotTablesの下にxlsConversionDroppedで列挙します。バイナリの.xlsbライターは、それをまだ保持するワークブックの保存を拒否します。翻訳されたピボットは普通のモデルオブジェクトで、代わりにターゲットフォーマットのルールに従います。ODSインポートパスとエクスポートパスの違いはここでもほかと同様に効きます。ピボットを運ぶファイルの安全な経路は、.ods入力、.ods出力です

オール・オア・ナッシングのフォールバックからもう1つのルールが従います。ワークブックは取り込んだコレクションを再生するか、モデルからピボットを書くかのどちらかで、1つのcontent.xmlに2つを混ぜることは決してありません。取り込んだピボットXMLをすでに保持するワークブックにAddPivotTableでピボットを追加すると、ODSの保存は生成XMLを再生XMLの隣に継ぎ合わせる代わりに、エラーで停止します

保存パートを2つ目のパーサーで確認する

要素数は名前空間について何も証明しません。保存されたcontent.xmlからdata-pilot-tableの開始タグを見つける正規表現が見るのはテキストで、第2節の切り出しコピーはそのテストを通過してしまいます。ファイルを書いたライブラリとは無関係の、名前空間を理解するパーサーははるかに厳しい審判で、Delphiには1つ最初から入っています。下の関数は自分のテストスイートに追加して使うためのチェックです。TZipFileとTXMLDocumentはHotXLSではなくDelphi RTL由来です:

uses
  System.SysUtils, System.Classes, System.Zip,
  Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;

// 自分のテストヘルパー:.odsパッケージ内のcontent.xmlが
// 名前空間を理解するDOMパーサーで読み込めるときTrue
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
  Zip: TZipFile;
  Bytes: TBytes;
  Stream: TBytesStream;
  Doc: IXMLDocument;
begin
  Error := '';
  Zip := TZipFile.Create;
  try
    Zip.Open(OdsFile, zmRead);
    Zip.Read('content.xml', Bytes);
  finally
    Zip.Free;
  end;
  Stream := TBytesStream.Create(Bytes);
  try
    Doc := TXMLDocument.Create(nil);
    try
      Doc.LoadFromStream(Stream);     // 未束縛のプレフィックスはここで失敗する
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

コードが保存するすべての.odsでこれを回し、さらにピボットを運ぶファイルでは一歩進んで、パース後の入力と出力のピボットサブツリーを比較します。タグ、ソート済み属性、テキスト、子要素を再帰的に、名前は名前空間URIとローカル名に展開して比較します。この比較なら、つづり替えられたプレフィックスは通し、未束縛のものは捕まえます。無害と壊れているの境界線はまさにそこです。ユーザーが実際に使うすべてのプロデューサーのファイルを喰わせてください。table名前空間を別のプレフィックスに束縛する文書、ピボット要素自身にプレフィックスを宣言する文書、<office:spreadsheet>で束縛し直す文書は、単一のLibreOfficeファイルでは鍛えられないスキップとシャドーイングのルールを鍛えてくれます

ODSリーダーとライター、XLSXとODS両方のピボットモデル、上で使った変換レポートは、すべてHotXLS Delphi Excel Componentの一部です。製品ページでは、DelphiとC++Builder向けのXLSX、XLS、ODSの完全な読み書き対応を説明しています