技術記事

DelphiパーサーにおけるXLSX OPCリレーションシップ解決

有効なxlsxがxl/worksheets/sheet1.xmlを含んでいなければならないという決まりはない。DelphiとC++Builder向けのネイティブExcelスプレッドシートコンポーネントであるHotXLSは、名前を推測するのではなくOPCリレーショングラフを通じてすべてのパートを探し出す。ISO/IEC 29500-2が保証しているのは、パートが_rels/.relsから到達可能であるということだけであり、それが慣習的なパスに置かれているということでは決してないからだ

なぜ自分のパーサーは有効なxlsxで失敗するのか

暗記していたパート名は、フォーマットの要件ではなく、ある1つのプロデューサーの慣習にすぎないからだ。これまでハードコードしたことのあるあらゆるパス——xl/workbook.xmlxl/sharedStrings.xmlxl/styles.xmlxl/worksheets/sheetN.xml——は、デスクトップ版Excelのライターがたまたま出力しているものにすぎない。準拠したパッケージは、リレーションシップがそこを指している限り、ワークブックをoffice/book.xmlに、最初のワークシートをxl/custom/data-sheet.xmlに置いてもなお合法なSpreadsheetMLでありうる。これこそが、Excel、LibreOffice、Numbersのどれもが何の文句もなく開くファイルに対して、自作のリーダーが「sheet1.xmlが見つからない」と報告する最も一般的な理由である

これを行うプロデューサーは何も奇特なものではない。サーバー側のレポートジェネレータはテンプレートパッケージを再利用し、その元のレイアウトを保持する。2つのワークブックをマージするエクスポートパイプラインはシートを振り直して隙間を残すため、5シートのワークブックがsheet1sheet2sheet4sheet7sheet9を持つことになる。あるシートを取り除くツールが常に生き残ったものを振り直すとは限らない。これらすべてのケースで、インデックスに基づく推測であるxl/worksheets/sheet + IntToStr(i + 1) + .xmlは、静かに間違ったシートを読むか何も読まない。これは例外よりも悪い。ワークブックは読み込まれ、数値だけが間違っているからだ。以下の最小限のパッケージはこの問題全体を運動させるものであり、HotXLSが回帰テストしている形そのものである

<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
      Target="office/book.xml"/>
</Relationships>

<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId42"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
      Target="../xl/custom/data-sheet.xml"/>
</Relationships>

<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="note7"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
      Target="../notes/review.xml"/>
</Relationships>

ISO/IEC 29500-2が実際に保証していること

それが保証しているのは到達可能性であって、場所ではない。ISO/IEC 29500-2は規格のうちOpen Packaging Conventionsを扱う部分であり、そのリレーションシップに関する条項が固定するエントリポイントはちょうど1つだけである:_rels/.relsにあるパッケージリレーションシップパートだ。そこから、Typehttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocumentであるリレーションシップを辿ってワークブックパートに到達し、他のすべてのパートは、そのパート自身のリレーションシップパートを読み、型付きのエッジを外側へと辿ることで発見される

同じ規格からのさらに2つの規則が実際の仕事をこなす。パート命名に関する条項は、あるリレーションシップパートがどこに住むかを固定する:<folder>/<name>にあるパートに対して、そのリレーションシップは<folder>/_rels/<name>.relsにあり、パッケージのルートにあるパートに対してはそのフォルダは単に_rels/である。リレーションシップのマークアップに関する条項は、Targetが、TargetMode="External"がパッケージの外を指すとマークしていない限り、普通のRFC 3986の意味でソースパートのURIに対して解決されるURI参照であると述べている。ソースに対する相対解決は誰もがすっ飛ばすステップであり、これこそが、同じ文字どおりの../notes/review.xmlxl/custom/_rels/data-sheet.xml.relsの中では1つのことを意味し、1つ深いフォルダのrelsファイルの中ではまったく別のことを意味する理由である。論理モデルとディスク上のバイト列の間にはもう1つ最後の襞がある:論理モデルの中のパート名は絶対的であり先頭にスラッシュを持つが、ZIPの物理マッピングに関する条項は、パート名をZIPのアイテム名に変換する際にそのスラッシュを取り除く。したがってそれを忘れたリゾルバーはアーカイブの中で/xl/sharedStrings.xmlを探して何も見つけられない

XlsxResolveRelationshipTargetの内部

HotXLSはこの解決規則全体を、lxHandleX.pas内でfunction XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideStringとして宣言されたXlsxResolveRelationshipTargetという1つの関数に集約している。これはソースパートのZIPアイテム名と生のTarget属性を受け取り、先頭にスラッシュのないZIPアイテム名を返す。これはアーカイブへそのまま渡せる形である。空のOwnerPartNameを渡すとパッケージのルートに対して解決される。これはパッケージリレーションシップパートがまさに必要としているものだ。操作の順序は個々のステップよりも重要だ:一部のプロデューサーはWindowsの区切り文字をTargetに書き込むため、まずバックスラッシュがスラッシュに正規化される。#によって導入されるフラグメントはパス処理の前に切り落とされる。したがって../charts/chart1.xml#Sheet1は存在しないアーカイブエントリではなくパート名に解決される。そして初めてこの関数は絶対パスと相対パスを分ける

// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
  combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
  Delete(combined, 1, 1)              // package-absolute: strip the slash only
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // relative to the source part folder
end;

source.StrictDelimiter := True;       // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
  segment := WideString(source[i]);
  if (segment = '') or (segment = '.') then
    Continue;                         // empty and dot segments vanish
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop, and never below the root
  end
  else
    parts.Add(String(segment));
end;

このセグメントのループは単純なスタック走査である:空のセグメントと.は捨てられ、..は1レベルをポップし、パッケージのルートを飛び出してしまうような..は負のインデックスや../で始まる名前を生む代わりに吸収される。StrictDelimiter := Trueという代入は飾りではない。これがなければDelphiのTStringListはスペースを区切り文字として扱い引用符を尊重してしまうため、スペースを含むどんなパート名も台無しにしてしまう。そしてスペースを含むパート名は合法である

グラフを辿る:ワークブック、ワークシート、図形描画

HotXLSはTXLSXWorkbook.Openの経路上で3階層のリレーションシップパートを歩く。パッケージ階層はXlsxFindOfficeDocumentPartが扱い、これは_rels/.relsを読んでofficeDocumentターゲットを返す。ワークブック階層はワークブックのリレーションシップパートを読み、一度に2つのマップを構築する:r:idルックアップ用の識別子マップと、単一パート用の型マップである。ワークシートと図形描画の階層はParseWorksheetRelsXmlParseDrawingRelsXmlで同じパターンを繰り返し、それぞれが自分自身のパート名を解決の基点として渡すため、../media/image3.pngを参照する図形描画は正しいブロブに着地する

// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
  PartName := 'xl/sharedStrings.xml';

シートは特に、型マップではなく識別子マップを通さなければならない。ワークブックパート内の<sheet>要素はr:id属性を運んでおり、シート名をパートに結びつけるものはその識別子だけである。HotXLSはParseWorkbookXmlの間にそれらの識別子を収集し、それぞれをワークブックのリレーションシップマップに対して解決し、識別子が存在しないか解決できない場合にのみ慣習的な連番の名前へとフォールバックする

// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
  PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
  PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));

// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParseWorksheetRelsXml(relsStream, PartName,
      FParRels[i], ParTableTargets[i], ParPartTargets[i]);
  finally
    relsStream.Free;
  end;
end;

これより下流のすべてはこの同じ仕組みに乗っている。共有文字列、スタイル、テーマ、Microsoft名前空間の型http://schemas.microsoft.com/office/2006/relationships/vbaProjectのもとにあるVBAプロジェクト、外部リンク、ワークブックスコープのpersonパート、レガシーコメント、スレッド化されたコメント、コメントの吹き出しのジオメトリを運ぶVML図形描画、図形描画、画像、チャート、テーブル、ピボットテーブルは、すべて解決済みのターゲットを通じてそのバイト列に到達する。とりわけテーマパートは正しく特定されなければならない。さもないと往復変換が顧客のブランドパレットを標準のOfficeテーマで静かに上書きしてしまう。これはテーマ・extLst・calcChainの無損失XLSX往復変換に関するノートで扱っている失敗モードの1つである。リレーションシップの読み取りは、読み込みがそのような段階に分けて行われる理由でもある:ZIPアーカイブのinflate状態はスレッドセーフではないため、ワークシートのXMLがパースされる前に、すべてのアーカイブアクセスは1つのスレッド上で行われる。この制約は並列XLSXパースとメモリアロケータの解説記事で説明している

なぜ重複したrIdは型ベースのルーティングを壊すのか

後から来た不正なエントリが先に来た有効なエントリを上書きし、ルックアップを乗っ取ることがあるからだ。リレーションシップ識別子はリレーションシップパート内で一意であるはずだが、不正なパッケージはそれを使い回しており、素朴なValues[Id] :=という代入は最後に書いたものが勝つ。もし最初のrId3が本物のワークシートを指し、2つ目のrId3がサポートされていないターゲットや空のターゲットを指しているなら、最後に書いたものが勝つ方式ではワークシートが失われてしまう。そのためParsePartRelationshipsXmlは2つの条件を持つ最初勝ちの規則を適用する:解決されたターゲットが空でないこと、そしてその識別子がまだ存在していないことである。この両方の条件が揃って初めてこれが安全になる。空でないことのテストが、使用可能なものが到着する前にTargetを欠いたリレーションシップがそのスロットを乗っ取ってしまうことを防ぐからだ

if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
  (TargetById.IndexOfName(String(Id)) < 0) then
  TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
  TargetsByType.Add(String(relType + '=' + resolvedTarget));

このスニペットにある意図的な非対称性に注目してほしい。識別子マップは最初勝ちのガードを備えた本物のマップであり、一方型のコレクションはtype=targetの組を追記していくだけのリストである。この区別は屋台骨を支えている:ワークブックは共有文字列のリレーションシップをちょうど1つだけ持つが、ワークシートと外部リンクのリレーションシップは多く持つ。そのためValues[]を通じた型のルックアップは単一パートに対して最初の一致を返し、externalLinkのような複数値を持つ型はリストを走査することで列挙される

リレーションシップを辿ることが止まる場所

綺麗な物語よりも正直な境界のほうが重要だ。HotXLSはリレーションシップが存在しない場合には慣習的な名前へとフォールバックする。したがって破損した、あるいは欠落したリレーションシップパートを持つパッケージも、たまたまExcelのレイアウトに従っていれば依然として開ける。このフォールバックは互換性のための機能であって第二の真実の源ではなく、テスト中にプロデューサー側のバグを覆い隠してしまうこともある。知っておく価値のある限界がさらに3つある。TargetMode="External"とマークされたターゲットは解決されるのではなく文字どおり保存される。これはハイパーリンクやリモートワークブックのURLを運ぶexternalLinkPathリレーションシップにとっては正しいが、返ってくる値がプロデューサーが書いたものそのままであることを意味する。図形描画のリレーションシップパートを通じて発見されるチャートパートは、識別子によってではなく位置によって図形描画のアンカーと対にされるため、通常でないアンカーの並び順はチャートの結びつけをずらしてしまうことがある。そしてlxDirectRead.pas内のストリーミング直接リーダーは、xl/にキーされた独自の軽量なパス処理を保持している。したがってここで説明した完全なリゾルバーが管理するのはTXLSXWorkbook.OpenGetSheetNamesのエントリポイントであって、Delphi向けストリーミング直接リーダーに関する記事で文書化されている低アロケーションのスキャン経路ではない

もし自分でこれを構築しているなら、最も短い正しい要約はこうだ:パート名を組み立てるのではなく、常に解決すること。_rels/.relsを読み、officeDocumentを辿り、すべてのTargetをそれを宣言したパートに対して解決し、シートはr:idでルーティングする。名前を変えられたパート、非連続なシート番号、重複するリレーションシップ識別子に対してすでにテスト済みのものが欲しいなら、ここで説明したリゾルバーは、パースしない部分をそのまま保つ往復変換の仕組みとともに、HotXLSDelphiスプレッドシートコンポーネントに同梱されている