技術記事

Excelが有効なXLSXを修復する理由とDelphiのOPC規則

LibreOfficeや自作のリーダーなら何の問題もなく開けるXLSXに、Excelが「一部の内容に問題が見つかりました」と表示するのは、Excelがこれらのリーダーが無視する2つのことを強制しているからです。スキーマが必須とする属性と、Open Packaging Conventionsの一意性ルールです。DelphiとC++Builder向けのネイティブExcelスプレッドシートコンポーネントであるHotXLSがまさにこれに遭遇したのはv2.382.5で、その出力が初めて実際のExcel COMインスタンスを通ったときでした。原因は3つあり、fontIdのない<phoneticPr>、[Content_Types].xml内で重複したOverride、そしてrId4を共有する2つのルートリレーションシップでした

他のすべてのリーダーが受け入れるパッケージをExcelが拒否する理由

修復プロンプトは解析の失敗ではなく、スキーマとパッケージのバリデーターだからです。HotXLSのコーパスは、4805個の数式を持つローン計算テンプレートを、ライブラリ経由、LibreOffice経由、そしてテストスイートのXMLバリデーター経由で何週間もラウンドトリップさせていました。保存されたファイルは、XLSX OPCリレーションシップ解決の記事で扱ったOPCの意味で構造的に健全でした。すべてのパートに到達でき、すべてのターゲットが解決できます。そこへExcel 16.0 build 20326が入ったWindowsマシンが使えるようになり、コーパスランナーがDisplayAlertsをオフにした隔離COMインスタンスでWorkbooks.Openから保存済みテンプレートを開いたところ、呼び出しはそのまま失敗しました。対話的に開くと、同じファイルでおなじみの修復を促すダイアログが出ます。修復ログは、Excelがわざわざ書いた場合ですが、パート名は挙げてもルールは挙げません。3つの独立した不具合が1つのプロンプトに隠れており、Excelはそれらを1つずつ報告しません。ワークブックを拒否して、見つけて分析するのはこちらに任せます。以下では各ルール、それを破っていたHotXLSの行、そして出荷された修正を順に見ていきます。いずれもDelphiのXLSXライターなら誰でも踏み得るルールだからです

ルール1:phoneticPrのfontIdはゼロでも必須

<phoneticPr>要素はfontId属性を持ち、ECMA-376 Part 1 §18.4.3でuse="required"と宣言されています。値0は正当なフォントインデックスであり、属性の不在ではありません。旧HotXLSのワークシートライターはゼロを「未設定」と扱い、Sheet.PhoneticFontId > 0のときだけ属性を出力していました。整数フィールドはゼロが既定値なのでDelphiでは自然な反射ですが、これにより、ふりがな用フォントがstyles.xmlの最初のフォントであるワークブック、まさにHotXLSコーパスのローン計算テンプレートがそうでしたが、それに対して<phoneticPr type="noConversion"/>が出力されます。Excelは自分自身が書いた値を読み戻す途中で拒否するのです

ExcelがHotXLSのワークシートパートに修復を求めた理由:phoneticPr要素はECMA-376 Part 1でfontIdをuse requiredとして宣言し、フォントインデックス0は正当な値です。PhoneticFontIdがゼロのときに属性を省略していた旧ライターはphoneticPr type noConversionを出力していましたが、スキーマが既定値を持つのはtypeとalignmentで、fontIdにはありません
既定値と同じときに属性を省略してよいのは、スキーマがその既定値を宣言している場合だけです。そしてローン計算テンプレートは、ふりがな用フォントをstyles.xmlの最初のエントリとして持っていました
// lxHandleX.pas、ワークシートライター — v2.382.5より前
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — この属性は必須で、ゼロも含む
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLSは今もTXLSXWorksheet.PhoneticTypeが空でないときにだけこの要素を出力するため、ふりがな設定を持たないワークブックは影響を受けません。リグレッションテストPhoneticSettings_DefaultFontIsExplicitは、新しいシートでPhoneticFontIdをゼロに設定して保存し、<phoneticPr fontId="0"がxl/worksheets/sheet1.xmlに存在することをアサートします。より広い教訓は、「既定値なら省略」が安全なのはスキーマが既定値を宣言している場合だけだということです。この要素ではtypeとalignmentには既定値がありますが、fontIdにはありません

ルール2:[Content_Types].xmlのパート名ごとにOverrideは1つ

コンテンツタイプのストリームは各パート名を最大1回しか宣言できず、Excelは同じPartNameに対する2つ目のOverrideを、両方のエントリが同じContentTypeを持っていても破損と見なします。HotXLSにはこのストリームへ書き込むライターが2つあります。BuildContentTypesXmlはオブジェクトモデルが生成するすべてのパート——ワークブック、スタイル、共有文字列、テーマ、ワークシート、そしてTXLSXWorkbook.CustomProperties.Count > 0のときの/docProps/custom.xml——を宣言します。PreserveUnsupportedPartsが有効なとき、TXLSXOpaquePackageはソースパッケージからそのまま取得したすべてのパートに対してOverrideを追加し、それらのバイトが出力時にも宣言されたままになるようにします。衝突するのは両側に存在するパートです。カスタム文書プロパティはモデルへ解析されますが、ソースパッケージのdocProps/custom.xmlも不透明レイヤーとして取得されているため、マージされたストリームでは2回宣言されていました。チャートやピボットキャッシュのパートも、モデルが再生成するパートを不透明レイヤーが保持している場合に同じ状況になります。v2.382.5より前のContentTypeOverridesXmlは、モデルがすでに何を書いたかを見る手段がなく、知りようがありませんでした

[Content_Types].xml内で2つのHotXLSライターが衝突した仕組み:BuildContentTypesXmlがオブジェクトモデルからdocProps/custom.xmlを宣言する一方、TXLSXOpaquePackageがそのまま取得した同じパートのOverrideを追加していました。v2.382.5以降、不透明レイヤーは生成済みストリームを先に解析し、OpcLowerPartNameで名前を正規化し、すべての衝突でモデルを優先させます
各ライターは個別には整合していました。パート名が1回だけ現れるという制約は、両者の出力が連結される継ぎ目にだけ存在し、修正がモデルのストリームを渡すようにしたのはそのためです
<!-- v2.382.5より前にExcelが見ていたもの -->
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>

修正は生成されたXMLをContentTypeOverridesXmlに渡し、不透明ライターが何かを出力する前にそれを解析させます。正しさを支える詳細は2つあります。OpcLowerPartNameは比較の前に小文字化し、バックスラッシュをスラッシュに反転し、先頭のスラッシュを除去します。OPCのパート名は大文字小文字を区別せずに比較され、モデルは先頭スラッシュ付きで書き、不透明レイヤーはZIPアイテム名をスラッシュなしで保存しているからです。そしてBuildContentTypesXmlの呼び出し側はResult + '</Types>'を渡し、組み立て途中の文書を閉じることで、TXMLReaderが切り詰められたストリームではなく整形式の入力を見るようにします。そこから出てくるルールは、モデルを先頭にした先勝ちです。オブジェクトモデルが宣言したものが権威を持ち、不透明な再生は隙間を埋めるだけになります

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // モデルが生成したストリームを解析し、宣言済みのPartNameをすべて集める
    while Reader.Read do
      if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
      begin
        Index:= Reader.AttributeIndex('PartName');
        if Index>= 0 then
          UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
      end;
  for i:= 0 to FParts.Count- 1 do
  begin
    Part:= TXLSXOpaquePart(FParts[i]);
    if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
      (UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
      Continue;                       // すでに宣言済み、またはrelsパート
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

ルール3:リレーションシップIdはリレーションシップパート内で一意

.relsパート内のすべてのRelationshipは、そのパート内で一意なIdを必要とし、2つが1つを共有しているとExcelはパッケージを拒否します。HotXLSはパッケージレベルの_rels/.relsを固定の識別子で書きます。ワークブックにrId1、コア文書プロパティと拡張文書プロパティにrId2とrId3、モデルが何か持っていればカスタムプロパティにrId4です。その後、不透明パッケージはソースから保持したルートリレーションシップを追加し、UsedIdsリストにすでにある識別子は採番し直します。リストはrId1からrId3までは知っていました。rId4は知らず、モデルが独自のカスタムプロパティリレーションシップを出力しようとしていることも知りませんでした。そのため、カスタムプロパティリレーションシップがrId4でもあるソースパッケージ、つまりExcelが既定で書くものですが、これが同じターゲットを指す2つのrId4エントリを持つ形で出てきました。呼び出し側のBuildRootRelsXmlはWorkbook.FCustomProps.Count > 0を第2引数として渡すようになり、予約とスキップが、モデルがrId4を出力するかどうかを決めるのと同じ条件で駆動されます。パッケージルートでの採番し直しは安全です。ワークブック内部の何もルートのリレーションシップ識別子を名前で参照していないからです。同じ手口は1階層下では誤りになります。workbook.xmlのr:id属性がワークブックのリレーションシップパートの識別子に束縛されるからで、MergeWorkbookRelationshipsXmlが別の識別子マップを保っているのはそのためです

HotXLSパッケージのルートで起きたリレーションシップ識別子の衝突:モデルはrId1からrId4を書き、rId4はカスタムプロパティ用に予約されています。不透明レイヤーは、UsedIdsがrId1からrId3しか知らなかったため、同じくrId4として届いたソースのリレーションシップを再生してしまいました。修正はEmitCustomPropsが成立するときにrId4を先に予約し、残りを採番し直します
パッケージルートでの採番し直しが安全なのは、ワークブック内部の何もルートの識別子を名前で参照していないからです。同じ手口を1階層下で使うと、workbook.xmlのすべてのr:id束縛が壊れます
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // モデルライターが予約している
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // カスタムプロパティは今やモデルのもの。ソースのコピーは再生しない。
  if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
    Continue;
  Id:= Rel.Id;
  if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
    Id:= AllocateRelationshipId(UsedIds);      // 最小の空きrIdN
  UsedIds.Add(String(Id));
  ...
end;

3つの失敗に共通するもの

3つとも、2つの情報源を持ち、パッケージ不変条件の単一の所有者がいないライターの症状です。オブジェクトモデルは理解しているパートを生成し、不透明レイヤーは理解していないパートを再生します。そうしてラウンドトリップが、テーマ、extLst、calcChainのロスレスラウンドトリップのメモで説明したチャート、ピボットキャッシュ、カスタムXMLなどすべてを保つのです。どちらの側も個別には整合していました。OPCがパッケージ全体に課す制約——Overrideのパート名の一意性と、パートごとのリレーションシップ識別子の一意性——は、両者が連結される継ぎ目にだけ存在し、v2.382.5までは誰もその継ぎ目を検査していませんでした。fontIdの不具合も1階層下の同じ形です。ライターは何を省略したいかは知っていましたが、省略してはならないと言うスキーマを参照していませんでした。HotXLSが落ち着いた修正は、マージのヒューリスティックではなく固定の優先順位です。モデルが先に書き、不透明レイヤーは書かれたものを見て衝突ではすべて譲り、コーパスランナーはverify_opc_uniquenessで外側から不変条件を強制するようになりました。これは保存済みパッケージの[Content_Types].xmlとすべての.rels項目を読み、PartName、Extension、Idの重複があればケースを失敗させます。このチェックは安価でExcelを必要とせず、3つのうち2つの不具合を最初のコーパス実行で捕まえられたはずです

同じバッチから:範囲ではなく数式である印刷範囲

Excelでの確認は、ローン計算テンプレートの_xlnm.Print_Areaも指摘しました。Excelはこれを元ファイルで$A$1:$J$29と報告しており、保存されたコピーでも同一に報告する必要がありました。その1つのアサーションの裏に2つの別々の不具合がありました。インポート時、XlsxStripSheetPrefixが最初の引用符なし!までをすべて切り落としていたため、OFFSET('Print Data'!$A$1,0,0,2,2)のような動的な印刷範囲は$A$1,0,0,2,2)として戻ってきてしまい、'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2のような修飾付きの和集合は最初のセグメントだけ接頭辞を失っていました。エクスポート時には、ライターが保存されたPrintArea全体の先頭にシート名を1回だけ付けていたため、単純な和集合$A$1:$B$2,$D$1:$E$2はライブラリから最初のセグメントが修飾され2番目が裸のまま出てきて、ExcelはこれをECMA-376 Part 1 §18.2.5のもとでの_xlnm.Print_Area定義として受け付けません

// インポート:残りが単純なsqrefである場合にのみ接頭辞を除去する
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
  Result:= Formula;
  if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
    Exit;
  Area:= Copy(Formula, Start, Length(Formula));
  if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
    Result:= Area;                    // 'Sheet'!$A$1:$J$29 -> $A$1:$J$29
end;                                  // OFFSET(...) はそのまま返される

// エクスポート:カンマ区切りの各セグメントを修飾するか、すべて修飾しない
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
  Result:= Area;
  ... split Area on ',' with StrictDelimiter ...
  for I:= 0 to Parts.Count- 1 do
    if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
      Exit;                           // 数式:そのまま出力する
  Result:= '';
  for I:= 0 to Parts.Count- 1 do
  begin
    if I> 0 then Result:= Result+ ',';
    Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
  end;
end;

対のルールは両側で同じです。印刷範囲が裸の範囲になるのは、すべてのセグメントが単一の範囲として解析できる場合だけで、そうでなければ数式であり、そのまま渡ります。PrintArea_FormulaDefinitionSurvivesRoundTripは、名前付き基準、シート修飾付き基準、和集合を、2回の保存と再オープンのサイクルを通してカバーします。印刷範囲がページ設定や印刷モデルの残りとどう関わるかは、シート保護、ページ設定、印刷の記事で扱っています

Excelがどの規則に反対しているかを見つける方法

自分のバリデーターが間違っているという前提から始めてください。通ってしまったからです。Open XML SDKのバリデーターは、fontIdの欠落のようなスキーマ違反をパートとXPath付きで示し、その下のパッケージングレイヤーはコンテンツタイプのエントリが重複したパッケージをそもそも開こうとしません。ですから何よりも先にこれを走らせてください。それが沈黙しているのにExcelがまだ修復する場合は、パッケージを二分探索します。unzipし、パートとそのリレーションシップとOverrideを削除し、zipし直して開き直します。プロンプトが消えるまで候補を半分にしていきます。ここでの3つの不具合はその順序で出てきましたし、どれもExcelが保存を申し出る修復後のファイルでは見えませんでした。修復は問題のエントリを静かに削除したり採番し直したりするからです。v2.382.5の修正の境界も同じくらい率直に述べておきます。重複排除はモデルを先頭にした先勝ちなので、ソースパッケージがモデルも生成するパートに対して別のコンテンツタイプを宣言していた場合、モデルの宣言が勝ち、ソースのものは捨てられます。これはHotXLSが再生成するパートに対しては正しく、一般的なマージではありません。verify_opc_uniquenessは一意性だけを検査し、スキーマは検証しないため、将来必須の属性が増えればExcelかスキーマバリデーターでしか表面化しません。そして生成されたコンテンツタイプストリームに対する追加のTXMLReaderパスは、PreserveUnsupportedPartsが有効な保存のたびに走ります。めったに数キロバイトを超えないストリームに対する小さなコストです。これらが揃ったことで、ローン計算テンプレートのWin32版とWin64版の両方が、今はプロンプトなしでExcelで開き、検証済みの4805個の数式を不一致ゼロで再計算し、元ファイルと同じ印刷範囲を報告します

自分でDelphiからXLSXを書くなら、チェックリストは短いものです。スキーマが必須とする属性は値にかかわらずすべて出力し、パート名はそれぞれ1回だけ宣言し、リレーションシップパートごとに使った識別子のリストを、そこに触れるすべてのライターで1つ共有してください。そのリストが最初から存在し、自作リーダーだけでなくExcelに対してもテストされていてほしいなら、ここで説明したパッケージライターはHotXLSのDelphiスプレッドシートコンポーネントに同梱されています。継ぎ目を守る価値のあるものにした不透明パートのラウンドトリップも一緒です