技術記事

HotXLSのBIFF8フォントインデックス4スキップとリッチテキスト

HotXLSはBIFF8のフォント参照をすべて、[MS-XLS] §2.5.129 FontIndexの定義どおりに番号付けします。0から3は0始まり、4超は1始まり、4は決して現れない。つまり5番目のFONTレコードがifnt 5であり、有効なifntの最大値はFONTレコード数と等しくなります。HotXLS 2.384.4以降、XFライター、XFリーダー、リッチテキスト文字列のrun、ワークブック横断のrun移行がすべてこの規則に従い、2.384.5と2.384.6はコメントとテキストボックスのrunへ拡張しました。コピーと行挿入を経由する場合も含みます

この規則は、実際に踏むまでタイプミスに見えます。誰かがFONTレコード8個のワークブックを開き、フォント8を指すXFを見つけて、ライターが範囲外のインデックスを出したと結論づける。まさにその推論がHotXLS 2.384.1で「修正」として出荷され、正しい実装を、Excelが開いたファイルのカスタムフォントがすべて1スロット早く着地する実装へ変えました。面白いのはオフバイワンそのものではなく、BIFF8ライブラリのあちこちに同じ規則が載っていること、そしてフォントの結び付きが1回目の保存を生き延びて2回目で壊れ得ることです。BIFF8 XLUnicodeStringのcchとfHighのデコードで扱った長さとエンコーディングの癖とすでに戦ったことがあるなら、これは同族のバグです。ファイルは正常、算術がおかしい

[MS-XLS]のFontIndex規則は実際には何と言っているのか

[MS-XLS] §2.5.129によれば、4未満のFontIndexは0始まりのレコード位置、4超のFontIndexは1始まりのレコード位置であり、値4はMUST NOT、つまり使ってはいけません。同じFontIndex型をXFレコード、SST書式run、TXO書式runが使うので、規則を1つ読み違えると3つまとめて壊れます。Excel製ファイルなら検証は簡単です。Office同梱のSOLVSAMP.XLSはFONTレコード19個でXFのifnt最大が19、43レコードのワークブックは43が上限、そしてFONTレコード30個でExcel 16が保存したファイルはCourier Newのセルをifnt 22、つまり22番目のレコードに向けています。どこにも4は出てきません。診断ツールで自分でマッピングを分析する必要があるなら、変換は短い関数2つで済みます

// [MS-XLS] 2.5.129 FontIndex:0..3は0始まり、> 4は1始まり、4は無効
function FontIndexToRecordNo(Ifnt: Word): Integer;  // 1始まりのFONTレコード
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4は現れてはならない
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
MS-XLS 2.5.129に基づくHotXLSのFontIndexマッピングの図。ifnt 0から3は0始まりのFONTレコード位置、ifnt 5以上は1始まりで、値4は決して現れません。FontIndexToRecordNo変換と、SOLVSAMP.XLSのようなExcel製ワークブックからの証拠も示します
5番目のFONTレコードは4ではなくifnt 5です。19レコードのワークブックはifnt 19が上限であり、Excel製ファイルが間の禁じられた値を保存することはありません

HotXLS内部では、同じ規則が鏡像の2か所に住んでいます。TXLSFontList.GetSaveIndexはフォントの被参照リストでの1始まり位置を受け取り、位置1から4だけデクリメントするので、位置5はifnt 5として書き込まれます。TXLSReader.ParseXFはロード時に逆を行います。ifntが5以上なら0始まりのフォントリストスロットへデクリメントし、それ未満はそのまま。SSTリッチrunのリマップとCountRichRunFontRefsも同じifnt >= 5変換を適用します。ここが肝です。1つの規則を、すべての消費者が使う

// TXLSFontList.GetSaveIndex(ライター側)
Result := inherited GetSaveIndex(Index);   // 1始まりの被参照位置
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4は0..3になり、5以上は無変更

// TXLSReader.ParseXF(リーダー側)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5はフォントリストのスロット4

0始まりの「修正」はなぜカスタムフォントを1つずらしたのか

HotXLS 2.384.1の0始まり書き直しがカスタムフォントをすべて1つずらしたのは、1始まりのインデックスを0始まりとして読み、その読み違えに合わせて4つの呼び出し箇所を書き換えたからです。GetSaveIndex、ParseXF、SST runリマップ、そしてSheets.AddCopyのワークブック横断run移行の4か所です。HotXLSのラウンドトリップは問題なく見えました。ライターとリーダーが互いに一致していたからです。しかしExcelは一致しません。2.384.1が書いたファイルは最初のカスタムフォントをifnt 4に置きました。Excelはこれをデフォルトフォントとして扱います。それ以降のカスタムフォントは1レコード早く着地し、Excelファイルを開く側は逆に、各フォントを1レコード遅く結び付けました

HotXLS 2.384.1のリグレッションを示す図。GetSaveIndexとParseXFが1始まりのFontIndex値を0始まりとして読み、最初のカスタムフォントを禁じられたifnt 4として書き込んだためExcelはデフォルトフォントに解決し、以降のフォントは1レコード早く着地しました。ラウンドトリップのテストは通ったままです
ライターとリーダーは同じ読み違えで一致していたため、保存後に開き直すテストはグリーンのままで、Excelが開いたフォントはすべて1スロットずれて着地しました。1つの規則が7か所にあり4か所を変えたのなら、疑うべきは自分の変更が先です

変更を止めるはずの手がかりは、同じコードベースの中にありました。CountRichRunFontRefs、チャートのFONTXとFBIリマップ、スタイルエンジンのフォントリストは一切触られずskip-4を使い続けたので、ライブラリは2.384.1が着地した瞬間に自己矛盾しました。リッチテキストのフォントはたいていどこかのXFからも参照されるという偶然のおかげで、矛盾は見えずに済んでいたのです。1つの規則が7か所に現れていて4か所を変えるのなら、他の3か所を疑う前に自分の変更を疑いましょう。バージョン2.384.4は4か所すべてで仕様の番号付けを復活させ、ifnt < FontCountをアサートして読み違えを符号化していた旧リグレッションテストは、書き込んだすべてのifntを仕様の式でFONTレコード名へマップし直すテストに置き換わりました。正直な限界が1つ残ります。2.384.1から2.384.3が保存した、フォント5個以上のファイルは、リーダーには正規データと区別のつかないずれたインデックスを運んでいるので、唯一の治療法は再生成です

コメントのフォントrunが2回目の保存でのみ壊れる理由

コメントとテキストボックスのrunが2回目の保存で壊れたのは、HotXLSが最初のN-1個のFONTレコードを無条件に保持し、どこからも参照されない最後の1個だけを落としていた一方で、TXO書式run([MS-XLS] §2.4.329)は番号付けし直さずにバイト単位で書き戻していたからです。Excel製の.xlsファイルは必ず未参照の末尾フォントで終わるため、1回目の保存ではコメントrunだけが使うフォントが最後になることはなく、見た目上は何も動きません。しかし1回目の保存は末尾フォントを落とし、コメント専用フォントを最後尾へ昇格させました。2回目の保存はそれを未参照として捨て、runのifntは末尾の先を指し、Excelはデフォルトフォントへフォールバックします。その間にワークブックが新しいフォントを増やしていた場合は、runは黙ってそちらへ結び付き、テストでは書式付きテキストボックスのrunがArialに化けました。コメントとハイパーリンクのレビューワークフローを組む記事で扱ったようなコメント多めのファイルは、開いて注釈を付けて保存を繰り返すものなので、まさにこの問題が刺さる場所です

2回の保存にわたるHotXLSのフォントテーブルの図。Excelが必ず書き込む未参照の末尾FONTレコードが最初に落ち、コメント専用フォントが最後尾になったところで、TXO書式runが参照数を数えずに書き戻されていたために捨てられます。2.384.5でCountRichRunFontRefsが生存フィルタを修正するまで
1回目の保存は末尾フォントが損失を引き受けたので綺麗に見え、コメントフォントが消えたのは2回目でした。メモリ上のテストを信じる代わりに、保存をまたいで各ifntをFONTレコード名へマップしましょう

HotXLS 2.384.5はTXO runをSST runと同じように扱います。CountRichRunFontRefsはいまや全ワークシートのすべてのTMSOShapeTextBoxを歩き、各runのskip-4のifntをスロットへ変換して参照として数えるので、run専用のフォントも保存フィルタを生き延びます。できあがったスロット対保存インデックスの表は各描画のFontRunRemapに入り、TMSOShapeTextBox.Storeは生のrunバイトの非公開コピー上でrunインデックスを書き換えます。フォントを運ばない末尾のTxOLastRunはそのまま放置です。アプリケーションコードへの契約は単純です。TXLSComment.TextRuns.FontIndexとTXLSTextBox.TextRuns.FontIndexはファイルの番号付け(4スキップ)を読んだとおりそのまま使い、runインデックスは1始まり、CharIndexはrunの開始文字オフセットです。保存後、格納された番号は設定した番号と違うかもしれませんが、同じフォントを指したままです

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // ファイルの番号付け、4スキップ
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

コピー、行挿入、ワークブック横断のrun移行

HotXLS 2.384.6以降、クラシックエンジンのコピー経路はすべてコメントの書式runを保持します。Range.Copy、CopyRange、Sheets.AddCopy、そしてRange.InsertとRange.Deleteの背後にあるセルシフトが、すべてTXLSRange.CopyCellを通るのに、CopyCellはコメントのテキストと作成者しかコピーしていなかったからです。シフトはコピー+クリアなので、2 runのコメントの上に1行挿入すると、run 0個・フォント1個の状態になりました。修正では各runをコピーし、フォントをTXLSWorkbook.MigrateRunFontIndex経由で移動します。skip-4のインデックスをスロットへ変換し、フォントを値渡しで移動先のフォントテーブルへ移し、ファイルの番号付けへ戻す、という流れです。Sheets.AddCopyのSSTリッチテキスト移行も、算術の独自コピーを抱える代わりに同じ関数を呼ぶようになりました。エッジケースも2つ来ました。コピー元とコピー先が同じコメントであるその場ペーストは、runを読む前にクリアしてはならない。そしてSheets.AddCopyは、保存済みセルレコードのないセルに付いたコメントについて、以前は丸ごとスキップしていた2周目のパスを行うようになりました。ワークブック横断コピーのフォントテーブル側は、ワークブック横断コピーと数式の再結合で扱った数式側と同じ値渡しの論理に従います。XLSXエンジンのコピー経路はrunを前から値渡しで複製していました。隙間はコメント部分そのものにあり、リーダーはrFont、strike、u、vertAlignを無視し、ライターはuとvertAlignを出力していなかったので、runはいまや保存と開き直しを対称に生き延びます

BIFF8ファイルのフォントインデックスはどうテストすべきか

フォントインデックスのテストは、保存して開き直すことで、できれば複数世代にわたって行い、数値範囲のアサートではなく各ifntをFONTレコードへマップし戻すことで行います。この記事のバグはどれもメモリ上のテストを通過しました。2.384.1のリグレッションは整合したライターとリーダーのペアの中に住み、TXOのドリフトには間にフォントテーブル変更を挟む2回の保存が必要で、XLSXで失われたコメントrunは開き直しの後にしか現れませんでした。有用なハーネスは、Excel製サンプルを開き、HotXLSで2回保存し、保存の間にフォントを1つ足すか削り、それからrun位置と、バイトレベルでは各ifntの背後のフォント名を確認します。保存の前後でFontIndex値を比較してはいけません。番号の付け直しは正当だからです

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // Excel製、C2にrunが2つ
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2はC3へ移動
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // 開き直し、メモリは信用しない
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

DelphiやC++BuilderからクラシックXLSを読み書きしていて、ライブラリの多くのフォント消費者のどれがまだ[MS-XLS] §2.5.129に一致しているかを追いかけたくないなら、ここで述べたskip-4の番号付け、保存時のrun番号付け直し、値渡しのrun移行は、HotXLS Delphiスプレッドシートコンポーネントに組み込み済みです。ExcelやOLEオートメーションなしでXLSとXLSXを読み書きします