技術記事

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

LibreOffice も自家製のリーダーも文句なしに開ける XLSX に対して Excel が「一部のコンテンツに問題があります」を出すのは、Excel がそれらのリーダーが無視する2種類の要素、スキーマ必須の属性と Open Packaging Conventions の一意性規則を強制するからです。生成パーツと保存パーツを組み合わせるどんなライターでも、そのうち3つの規則は踏みやすい。<phoneticPr>にはfontIdが要り、パーツ名ごとに[Content_Types].xmlへOverrideがちょうど1つ入る必要があり、2つのルートリレーションシップがrId4のような識別子を共有してはならない。この記事では、それぞれの規則と、Delphi・C++Builder 向けネイティブ Excel スプレッドシートコンポーネントである HotXLS がそれを満たすためにパッケージをどう書くかを順に見ていきます

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

なぜなら修復プロンプトは、パーサーの失敗ではなく、スキーマとパッケージのバリデータだからです。ワークブックは自前のライブラリ、LibreOffice、XML バリデータを通って回り、XLSX OPC リレーションシップ解決の記事でいう OPC 的な意味では構造的に健全でありえます。すべてのパーツが到達可能で、すべてのターゲットが解決可能というわけです。DisplayAlertsをオフにした隔離された COM インスタンスでWorkbooks.Openを通して開くと、それでも呼び出しは丸ごと失敗しかねません。対話的に開けば、同じファイルはおなじみの修復ダイアログを出し、Excel が気まぐれに書く修復ログは、規則ではなくパーツを名指しします。独立した複数の違反がその1つのプロンプトの背後に隠れていても、Excel は1つずつ報告しません。ワークブックを拒否して、見つけて分析するのをこちらに任せます。以下は各規則、それを違える自然なやり方、そして HotXLS が代わりにパッケージをどう書くかです。どれも、Delphi の XLSX ライターなら誰でもつまずき得る規則だからです

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

<phoneticPr>要素は ECMA-376 Part 1 §18.4.3 でuse="required"と宣言されたfontId属性を運び、値0は不在ではなく、正当なフォントインデックスです。ゼロを「未設定」と扱い、Sheet.PhoneticFontId > 0のときだけ属性を出すライターは、整数フィールドのデフォルトがゼロであるため、自然な Delphi の反射に従ったものです。しかしそれは、フォニティックフォントがたまたまstyles.xmlの最初のフォントであるワークブックすべてに<phoneticPr type="noConversion"/>を出力させます。インデックス 0 は単純に最初のフォントなので、この状態には容易に届きます。すると Excel は、戻りの読み込みで、自分で書いた値を拒否します

ワークシートパーツに Excel が修復を要求する理由:phoneticPr 要素は ECMA-376 Part 1 で fontId を use required として宣言し、フォントインデックス 0 は正当な値。PhoneticFontId がゼロのとき属性を省くライターは phoneticPr type noConversion を出す。スキーマは type と alignment にデフォルトを与え、fontId には何も与えない
デフォルトと等しいときの属性省略は、スキーマがそのデフォルトを宣言しているときにだけ安全。フォニティックフォントを styles.xml のまさに最初のエントリとして運ぶテンプレートは、まさにその状態に当たる
// 自然な反射:ゼロを未設定扱いにして fontId を落とす
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// lxHandleX.pas、ワークシートライター — 属性は必須。ゼロも含めて出す
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が空でないときだけ要素を出すので、フォニティック設定を一度も持たなかったワークブックは影響を受けません。新しいシートでPhoneticFontIdをゼロに設定して保存すると、xl/worksheets/sheet1.xmlは<phoneticPr fontId="0"を運びます。より広い教訓は、「デフォルトなら省略」が安全なのはスキーマがデフォルトを宣言しているときだけ、ということです。この要素でtypeとalignmentにはデフォルトがあり、fontIdにはありません

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

コンテンツタイプストリームは各パーツ名を高々1回宣言でき、Excel は同じPartNameへの2つ目のOverrideを、両エントリが同じContentTypeを運んでいても破損として扱います。HotXLS にはこのストリームに流れ込むライターが2つあります。BuildContentTypesXmlはオブジェクトモデルが生成するすべてのパーツ、workbook、styles、shared strings、theme、worksheets、そしてTXLSXWorkbook.CustomProperties.Count > 0のときは/docProps/custom.xmlを宣言します。PreserveUnsupportedPartsがオンだと、TXLSXOpaquePackageが続いて、ソースパッケージからそのまま取り込んだパーツすべてにOverrideを追加し、それらのバイトが出力でも宣言されたままになるようにします。衝突は、両側に住むパーツです。カスタム文書プロパティはモデルにパースされますが、ソースパッケージのdocProps/custom.xmlも不透明に取り込まれます。素朴なマージはそれを2回宣言し、モデルが不透明レイヤーも保存したパーツを再生成するときには、chart や pivot cache のパーツも同じ場所に着地し得ます。モデルが何をすでに書いたかを知らない不透明ライターには知りようがありません。だからこそContentTypeOverridesXmlにその視界を与えるのです

2つのライターが [Content_Types].xml で衝突するしくみ:BuildContentTypesXml はオブジェクトモデルから docProps/custom.xml を宣言し、TXLSXOpaquePackage はそのまま取り込んだ同じパーツに Override を追加しようとする。そこで HotXLS の不透明レイヤーは生成ストリームを先にパースし、OpcLowerPartName で名前を正規化して、衝突はすべてモデルの勝ちとする
各ライターは単独では一貫している。パーツ名は1回しか現れないという制約は、両者の出力を連結する継ぎ目にしか存在しない。だからこそ HotXLS はモデルのストリームを渡す
<!-- 素朴なマージが生み出すもの -->
<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"/>

HotXLS は生成された 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、core と extended の文書プロパティにはrId2とrId3、そしてモデルにカスタムプロパティがあればrId4です。不透明パッケージは続いて、ソースから保存したルートリレーションシップを、すでにUsedIdsリストにある識別子を再番号付けして追加します。そのリストがrId1からrId3しか知らず、モデルが自前のカスタムプロパティリレーションシップを出そうとしていることを知らないと、カスタムプロパティのリレーションシップもまたrId4である(Excel がデフォルトで書くものがまさにそれです)ソースパッケージは、同じターゲットを指す2つのrId4エントリを出力に持つことになります。そこでRootRelationshipsXmlは、モデルがカスタムプロパティを出すときはいつでもrId4を予約し、ソースのコピーをスキップします。呼び出し側のBuildRootRelsXmlは第2引数にWorkbook.FCustomProps.Count > 0を渡すので、予約とスキップは、モデルがそもそも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つのソースを持ち、パッケージ不変量の単一の持ち主を持たないライターです。オブジェクトモデルは理解できるパーツを生成し、不透明レイヤーは理解できないパーツをリプレイします。ラウンドトリップが、theme・extLst・calcChain のロスレス ラウンドトリップのノートで述べられている chart、pivot cache、カスタム XML その他すべてを保つためです。どちらの側も単独では一貫していられます。OPC がパッケージ全体に課す制約、一意なOverrideパーツ名と、パーツごとに一意なリレーションシップ識別子は、2つが連結される継ぎ目にしか存在しないので、誰かがその継ぎ目を所有しなければなりません。fontId規則は、1階層下の同じ形です。ライターは何を省きたいかは知っていても、省いてはならないと告げるスキーマに相談することはない。HotXLS はこれをマージのヒューリスティックではなく固定の優先順位で決めます。モデルが先に書き、不透明レイヤーは書かれたものを見て、衝突のときは譲る。不変量は外側からも安価にチェックできます。保存されたパッケージの[Content_Types].xmlとすべての.rels項目を読み、重複するPartName、Extension、Idで失敗させるのです。このチェックは Excel を要らず、2つの一意性規則をカバーします。fontId規則には、それでもスキーマバリデータが要ります

範囲ではなく数式である印刷範囲

印刷範囲は、Excel が保存済みコピーで比較するもう1つのものです。_xlnm.Print_Areaが$A$1:$J$29だと報告していたテンプレートは、ラウンドトリップの後も同じ報告をしなければなりません。その背後には2つの罠があります。インポートでは、最初のクォートされていない!までを切り捨てるプレフィックス剥ぎが、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;

対応づけの規則は両側で同じです。印刷範囲が素の範囲になるのは、すべてのセグメントが素の範囲としてパースできるときだけ。そうでなければ数式であり、そのままの形で運ばれます。この規則があれば、名前付きのベース、シート修飾付きのベース、ユニオンはいずれも、保存して開き直すサイクルを繰り返しても生き残ります。印刷範囲がページ設定や印刷モデルの残りとどう関わるかは、シート保護、ページ設定、印刷の記事で扱っています

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

出発点は、自分のバリデータが通したという事実こそが疑わしい、という仮定です。Open XML SDK のバリデータは、欠けたfontIdのようなスキーマ違反を、パーツと XPath つきで名指しし、その下のパッケージング層は、重複するコンテンツタイプエントリを持つパッケージをそもそも開こうとしないので、何よりも先にこれを走らせます。それが沈黙し、それでも Excel が修復するなら、パッケージを二分してください。unzip し、パーツとそのリレーションシップとそのOverrideを削除し、再 zip して開き直す。プロンプトが消えるまで候補集合を半減させます。上の3つの違反はその順で姿を現し、どれも Excel が保存を申し出る修復済みファイルには見えません。修復は違反エントリを静かに落とすか再番号付けするからです。HotXLS ライターの2つの設計選択は知っておく価値があります。重複排除はモデルを先頭にした最初の勝者方式であり、ソースパッケージが、モデルも生成するパーツについて別のコンテンツタイプを宣言していたときは、モデルの宣言が勝ち、ソースのものは捨てられます。これは HotXLS が再生成するパーツにとって正しい振る舞いであり、一般的なマージではありません。そして生成済みコンテンツタイプストリームへのTXMLReaderの追加パスは、PreserveUnsupportedPartsが有効な保存のたびに走ります。たかだか数 KB のストリームに対する小さな代償です。これらの規則が整っていれば、Win32 と Win64 の両ビルドが保存した4805数式のローンテンプレートは、Excel でプロンプトなしに開き、不一致ゼロで再計算し、オリジナルと同じ印刷範囲を報告します

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