技術記事

DelphiでBIFF SupBookとXTI外部リンクを分類する

古いxlsを開いて保存し直すと、登録済み解析ライブラリを呼び出していたアドイン数式が、ブック内部の空参照を指すようになります。HotXLSはこの静かな破損を一つの誤った前提まで遡ります。BIFF SupBookレコードは「セルフ」か「外部ファイル」のどちらかだ、という前提です。[MS-XLS]が定義しているのは2種ではなく7種です

保存したブックでアドインリンクが失われるのはなぜか

分類テストが型付けされておらず構造的だったからです。従来の近道はSupBookレコード($01AE)を読み、セルフマーカーを運んでいるかを確認し、運んでいなければ後続の文字列を何でもドキュメントURLとして扱います。そのどちらでもないレコードはすべてデフォルト分岐に落ち、デフォルト分岐はほぼ常に「これはブック自身だ」と答えます。アドインを支援するリンクも、同じシートへのリンクも、未使用スロットも、切り詰められたレコードも、みんな同じ間違ったラベルを着せられます。この間に何も例外は投げられません。レコードはパースされ、数式は再コンパイルされ、警告なしにファイルは保存され、欠陥が表面化するのは3週間後、通貨換算があった場所にゼロの列が並んで誰かが気づいたときです。[MS-XLS] §2.4.271が記述するレコードは、セルフ参照、同じシート参照、アドイン関数コンテナ、仮想パスとシート名テーブルを持つ外部ブック、DDEまたはOLEデータリンク、未使用プレースホルダのいずれかになり得ます。さらに仕様に載っていない第7の状態が実ディスクには存在します。パースに失敗するレコードです。求められるのはより賢いヒューリスティックではなく、ヒューリスティックそのものを拒否することです

SupBookレコードが運べる7種の種別

HotXLSは支援リンクの分類学をlxExternSheet.pas内の閉じた列挙型として宣言し、下流の判断はすべてこれにスイッチします。列挙値は9個あり、7カテゴリをカバーします。DDEとOLEのケースは確定前に暫定状態を必要とするからです

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // 解析に失敗、または末尾に残余バイトが残った
    slkSelf,              // このブック自身
    slkSameSheet,         // U+0000 マーカー
    slkAddIn,             // アドイン関数コンテナ
    slkExternalWorkbook,  // 仮想パス + シート名テーブル
    slkDde,               // ExternName フラグから解決
    slkOle,               // ExternName フラグから解決
    slkDdeOrOle,          // 両者のどちらか、未確定
    slkUnused);           // 半角スペース1個のプレースホルダ

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // ゼロ始まり。ExternSheet.rgXTI の格納値どおり
    ExternID    : Integer;   // 1始まり。ライブラリ内部の規約
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

ディスパッチは文字列ではなくセンチネルで駆動します。フィールド値$0401はセルフレコードの印です。シート数1と$3A01の組はアドインコンテナの印です。1から$00FFの範囲の値だけがエンコード済み仮想パスの後続を意味し、そのときに限りHotXLSは文字列をデコードします。これら3つの形のどれにも当てはまらないものはslkUnknownのまま残り、シート名テーブルがレコード本体をちょうど消費しきれなかったレコードは、先頭が如何にもそれらしく見えてもslkUnknownへ格下げされます

HotXLSがBIFF SupBookレコードを7種に分類するために使うセンチネル駆動のラダー。エンコード済みパス範囲の値に対してのみ文字列をデコードし、デフォルト分岐ではなく未知種別へフォールバックする
各種別には文字列テストではなくセンチネルで到達し、どの形にも一致しないレコードは「このブック」という意味のデフォルト分岐に落ちる代わりに未知のまま残ります

同じシートマーカーが空文字列にデコードされるのはなぜか

汎用のBIFF文字列リーダが、分類が依存するあの1バイトを壊してしまうからです。同じシートへの支援リンクはただ1文字の文字列で、その文字がU+0000です。ところがTXLSBlob.GetBiffStringはこれを空のWideStringとして返します。本当に空のパスと区別がつきません。そして空のパスこそ、セルフ参照ヒューリスティックが「セルフ」と答える入力です。そこでHotXLSはデコード結果を信用せず、レコード本体から生の最初のコードポイントを直接読みます

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // 圧縮形式、1バイト
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // ワイド形式、2バイト
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

圧縮とワイドの分岐にも注目してください。オプションバイトは文字列ヘッダから固定オフセットにあり、最初のコードポイントはビット0に応じて1バイトか2バイトになります。無条件にバイトとして読むと大半のファイルでは成功し、ローカライズ版が書いたファイルで失敗します。バグの分布として最悪のパターンです。未使用プレースホルダも同じ要領で捕捉します。文字通りの半角スペース1個のペイロードで判定し、DDEまたはOLEのケースはエンコード済みパスに埋め込まれたU+0003セパレータで判定します

HotXLSがデコード済み文字列ではなくBIFF SupBookレコード本体から生の最初のコードポイントを読む理由。汎用文字列リーダは同じシートのU+0000マーカーを空値へ折り畳むため
同じシートマーカーは文字がU+0000の1文字文字列であり、汎用リーダはこれを空値へ折り畳みます。オプションバイトオフセットの生コードポイントだけがこれを保持します

DDEとOLEをSupBook時点で分離できないのはなぜか

SupBookレコードが判別ビットを運んでいないからです。リンクが両者のどちらかであることしか教えてくれません。どちらであるかを決めるfOlefOleLinkフラグは、ストリームの後続で到着するExternNameレコード($0023)の中にあります。HotXLSはパース時にslkDdeOrOleを記録し、ParseExternalNameで絞り込みます。ExternNameが最後まで到着しなければ種別は永久に暫定のままです。それが正しいのです。ファイルは本当に何も言っていないのですから。下流の全消費者はこの暫定値を欠損ではなく実値として扱うので、呼び出し側がタイブレークをでっち上げる必要はありません。ここで「たぶんDDE」と推測すれば、整理された列挙型と、誰にも辿れない間違いのクラスが手に入るだけです

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

XTIインデックスはディスク上ではゼロ始まり、内部では1始まり

HotXLSはこのオフバイワン変換を、トークンが内部構文木に入る一点でのみ実行し、他のどこでも実行しません。PtgNameX.ixti([MS-XLS] §2.5.198.85)はExternSheetレコード($0017、§2.4.106)のrgXTI配列へのゼロ始まりインデックスであり、一方ライブラリ内部のExternID規約は1始まりで、0は「外部シートなし」のために予約されています。BIFF8読み込みパスはtNameXトークンをデコードするときFExternID := wValue + 1を実行し、書き込みパスはStoreExternID - 1を出力するので、生トークンの見え方とディスク上の意味論はどちらも無傷です。ここを間違えると異常に捕捉しづらくなります。外部定義名は隣のエントリへ解決され、XTIエントリが1個しかないファイルではインデックス0がインデックス1になってミスし、名前は静かに劣化します。再コンパイルされた数式テキストだけを検証する回帰テストでは決して見つかりません。再コンパイルはディスクインデックスに一切触れないからです。同じ罠が、シートやブックをまたぐ定義名を実際のバイトストリームで検証する価値の理由でもあります。解決は両端で有界です。TlxExternSheetSheet.TryResolveXtiは負のインデックスや欠落エントリに対してFalseを返し、TXLSSupBook.TryGetKindは配列外のSupBookインデックスに対してFalseを返し、ClassifyXtislkSelfslkSameSheetfrcInternalへ、slkExternalWorkbookfrcExternalWorkbookへ、slkAddInslkDdeslkOleslkDdeOrOlefrcExternalOtherへ対応付けます。残りすべて、範囲外のパスを含めて、frcUnknownOrMalformedに着地します

HotXLSがBIFF PtgNameXトークンのゼロ始まりXTIインデックスを単一点で1始まりの内部ExternIDへ変換するようす。両端の有界な解決と、それを消費する分類マップ付き
ゼロ始まりのディスクインデックスと1始まりの内部ExternIDの間のオフバイワンは、トークンが構文木に入るときに一度だけ適用され、解決不能なインデックスはすべて不正形クラスに着地します

値化する前に数式を分類する

TXLSCompiledFormula.ClassifyReferencesは、数式を逆コンパイルして角括弧を探すのではなく、保存されたBIFFトークンストリームを直接走査します。数式テキストでの括弧探しは、パーサのコートを着たテキストヒューリスティックです。文字列リテラルに一致し、構造化参照に一致し、逆コンパイル形では角括弧を運ばない外部定義名をまるごと見逃します。トークン走査はPtgNameXPtgRef3dPtgArea3dPtgRefErr3dPtgAreaErr3dだけを見て、BIFFストリームが生き残っていないときは構文木のウォークへフォールバックします。マージは意図的に悲観的です。優先順位はfrcUnknownOrMalformedfrcExternalWorkbookfrcExternalOtherfrcInternalの固定順で、読めないトークンが1個あるだけで数式全体が毒されます。外部定義名については名前インデックスも検証します。1始まりで、範囲内で、保持されたExternNameレコードに裏付けられていることです

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets は1始まり
    begin
      Sheet := Wb.Sheets[i];
      // frcExternalWorkbook に分類された数式のみ値化する
      // 内部参照、アドイン、DDE/OLE、不正形参照は数式のまま残る
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

OnlyExternal引数こそ、この分類学が回収する場所です。数式の値化は不可逆なので、この操作は参照が外部ブックだと疑うのではなく証明しなければなりません。アドイン呼び出しは生き残り、DDEとOLEリンクは生き残り、パーサが完全には理解できなかったものはすべて生き残ります。不確実性の安全な帰結は何も変えないことだからです。同じ規律がブック間でコピーした数式の再バインドにも及びます。ここで誤分類されると、大声で失敗する代わりに間違ったブックへ再バインドされます

パースできないレコードは無傷のまま書き戻す

HotXLSは元のSupBookペイロードを保持し、レコードが編集されていなければバイト単位で再出力します。パース失敗はslkUnknownを設定し派生状態をクリアしますが、捕捉した本体はFRawDataに残り、保存パスはアイテムがダーティでなくセルフレコードでもない限り、どの再構成よりもこれを優先します。代案——理解できなかったレコードをセルフ参照へ正規化して、ライタが整形済みの何かを出力できるようにする——は、理解できなかったレコードを確実に間違ったレコードへ変換します。この原則は、ロードとセーブのサイクルを通じてVBAプロジェクトとその外部参照に適用されるのと同じ契約であり、実世界のファイルを往復できるライブラリと、自分のテストスイートがたまたま含むファイルを往復できるライブラリを分ける差です。15年分のExcelバージョン、レポート生成器、移行ツール2種を経たブックには、現存する誰も設計していないレコードが含まれます。見つけたままの姿で書き戻すのです

SupBookとXTIレコードの型付き分類は、ここで述べた有界なXTI解決とより安全なConvertFormulasToValuesパスとともに、HotXLS 2.361.2から2.361.4で出荷されました。アドイン呼び出し、DDEまたはOLEリンク、外部定義名を運ぶレガシーxlsファイルを読むDelphiやC++Builderのコードを保守しているなら、HotXLS Delphi spreadsheet componentがこの分類学全体をネイティブに処理します。Excelのインストールも、処理を行うマシン上のOLEオートメーションも不要です