技術記事

HotXLSのBIFF8 Selectionレコードとペインスクロール

HotXLSはワークシートの選択範囲とスクロール位置をペインごとに、TXLSWorksheetとTXLSXWorksheetの両方で共通のペイン対応APIを使って保存します。対象はSelectAreas、GetSelectedAreas、ScrollWindow、TryGetWindowScrollです。クラシックな.xlsファイルでは、HotXLSはBIFF8のSelectionレコード(0x001D)を1レコード最大1369エリアで書き出し、論理的なペイン名を形式が定義するペインバイトへ変換し、各スクロール軸をExcelが期待するWindow2またはPaneレコードに載せます

問題が顔を出すのは、たいてい照合ツールや監査ツールの中です。ツールは台帳エクスポートを開き、ソースシステムと食い違うセルをすべて見つけ、そのセルを選択状態にしたままフリーズしたヘッダー行付きでワークブックを保存します。レビュアーはスクロールで探す代わりに、差分にそのまま着地できるわけです。差分40件なら何の問題もありません。月末ファイルは3,000件あり、3,000エリアを1つのSelectionレコードに収めることは物理的にできません。ボディには18,009バイト必要で、BIFF8の1レコードが運べる量の2倍以上です。スクロール位置にも似た罠があります。フリーズペインのあるシートでは「ユーザーがどこを見ていたか」は1つの座標ではなく、2つの行位置と2つの列位置を共有する4つのペインです

大きな選択範囲に複数のSelectionレコードが必要な理由

大きな選択範囲に複数のレコードが必要なのは、BIFF8のレコードボディが8224バイトで頭打ちであり、選択エリア1つが固定6バイトを消費するからです。[MS-XLS] §2.4.248はSelectionレコードを、9バイトの固定部(ペインバイト、アクティブセルのrwActとcolAct、アクティブエリアのirefAct、エリア数のcref)に続いてcref個のRefU構造、つまり16ビット行2つと8ビット列2つを持つ構造が並ぶ形として定義します。収まる最大数は(8224 − 9) / 6の切り捨てで1369であり、ボディは8223バイト、上限の1バイト手前です。TXLSWorksheet.StoreSelectionGroupはこの定数をMaxAreasPerRecordとして使い、それより大きなグループを同じペインの連続するSelectionレコードとして、1回に1369エリアずつ書き出します

効いてくる細部はirefActです。各チャンクは同じアクティブ行、アクティブ列、アクティブエリアインデックスを繰り返し持ち、irefActは全チャンクの集約シーケンスに索引を付けます。それを運ぶレコード内部のエリアではありません。上限を1エリアだけ超える選択範囲がこれを具体的に示します。最後のエリアがアクティブな1370エリアは2レコードになり、1つ目はcref 1369、2つ目はcref 1で、両方ともirefAct 1369を運びます。この値は2つ目のレコード自身のエリア数より大きいのです。irefActを各レコードのcrefと照合するリーダーは正常なファイルを拒否し、レコードごとに状態を置き換えるリーダーは最初の1369エリアを落とします。HotXLSのリーダーは同じペインの連続レコードを1つのグループに追加し、全チャンクがアクティブセルとインデックスで一致することを要求し、範囲チェックはワークシートのEOFレコードで、つまり全シーケンスが判明した時点でだけ実行します。したがってペイン指定が先に来るSelectAreasオーバーロードに1369エリアの上限はありません。ワークシートの書き込みロックを取る前にすべてのA1参照とアクティブインデックスを検証し、不正なものがあれば以前の選択範囲を変更しないままFalseを返します

HotXLSが大きなワークシート選択範囲を複数のBIFF8 Selectionレコードとして書き出す理由:8,224バイトのボディ上限には9バイトの固定部と6バイトのRefUエリア1369個が収まり、3,000エリアは同じペインの1369、1369、262の3レコードになり、irefActは集約シーケンスに索引を付けるため、最後がアクティブの1370エリアでは両レコードがirefAct 1369を持ちます
各チャンクは同じアクティブセルとインデックスを繰り返し、HotXLSのリーダーは同じペインの連続レコードを1つのグループに追加し、範囲チェックは全シーケンスが判明した後のEOFレコードでのみ走ります
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // ヘッダー行とA列はそのまま

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // フリーズすると保存済みの選択範囲がリセットされるため、フリーズ後に選択する
    // 3000エリアは1369 + 1369 + 262の3つのSelectionレコードとして保存される
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

Selectionレコードはどのペインバイトを使うのか

Selectionレコードは形式が定義する数値コードでペインを識別します。0が右下、1が右上、2が左下、3が左上です。公開列挙型TXLSPanePositionは読む順序で宣言されています。xlspTopLeft、xlspTopRight、xlspBottomLeft、xlspBottomRightの順なので、Ord(xlspTopLeft)は0となり、ファイルでは右下ペインを意味します。列挙型をペインバイトへそのままキャストすれば、左上への選択範囲がすべてエラーも出さず右下ペインに書き込まれます。HotXLSのペイン対応エントリポイントはどれも、明示的なcase文を通して列挙型を変換するため、呼び出し側が数値コードを扱うことは一切ありません。ペインの存在もチェックします。右上ペインは縦分割が、左下ペインは横分割が、右下ペインはその両方がなければ存在しません。現在の分割やフリーズのジオメトリが持たないペインに対しては、SelectAreasはFalseを返し、GetSelectedAreasはActiveAreaIndexを-1にした空配列を返します。ペインも選択オブジェクトもセルもワークブックに作りません

HotXLSがTXLSPanePositionをBIFF8 Selectionのペインバイトへ対応させる仕組み:列挙型は読む順序で宣言されているためOrd(xlspTopLeft)は0ですが、ファイルは0を右下、1を右上、2を左下、3を左上と定義しており、ペイン対応のエントリポイントはすべて明示的なcase文で変換します
列挙型をペインバイトへ直接キャストすると、左上の選択範囲がすべて右下ペインに書き込まれます。そこでHotXLSは書き込み前に、現在の分割やフリーズのジオメトリに対してペインの存在もチェックします

各ペインのスクロール位置はどこにあるのか

各ペインのスクロール位置は2つのレコードに分かれています。4つのペインが共有するのは行位置2つと列位置2つだけだからです。クラシックなワークブックでは、上側ペインの最初の可視行と左側ペインの最初の可視列がWindow2.rwTopとWindow2.colLeftであり、下側ペインの行と右側ペインの列がPane.rwTopとPane.colLeftです。したがってScrollWindow(xlspTopRight, R, C)はWindow2.rwTopとPane.colLeftを書き込み、右上ペインの列を設定すると右下ペインも動きます。Excelで両者が1つの水平スクロールバーを共有しているのと同じです。公開メソッドは行番号も列番号も1始まりです。存在しないペインはFalseを返して問い合わせ出力を両方ゼロにし、範囲外の座標はどちらの軸も変わる前に拒否されます。ここにあるものはどれも、ビューアがグリッドをどう描くかには依存しません。レンダリングコントロールは自身のTopRowとLeftColを持ちます。カスタムVCLグリッドでワークブックを描画する記事が述べるように、それらは実行時の状態であり、保存されるものではありません

HotXLSの各ペインスクロール軸がどこにあるか:4つのペインは行位置2つと列位置2つを共有するため、上側の行と左側の列はWindow2.rwTopとWindow2.colLeft、下側の行と右側の列はPane.rwTopとPane.colLeftであり、ScrollWindow(xlspTopRight, 1, 6)はWindow2フィールド1つとPaneフィールド1つを書き込み、右下も追従します
XLSXは同じデータをsheetViewとpaneのtopLeftCell属性に分散させており、この2層を1つに潰すことが、読み込み時に上側や左側のスクロール位置が音もなく消えるまさの原因です

XLSXは同じデータを2つの要素に分散させます。ウィンドウ全体のためのsheetView/@topLeftCell(ECMA-376 Part 1、§18.3.1.87)と、分割の右下側のための子要素pane/@topLeftCell(§18.3.1.66)です。両方の属性が同時に存在できます。HotXLSは外側の属性をまずウィンドウレベルのフィールドへ読み込み、paneの子要素にはペインレベルのフィールドだけを上書きさせ、両方を別々に書き戻します。この2層を1つに潰すことが、読み込み時に上側や左側のスクロール位置が音もなく消えるまさの原因です。ワークシートのコピーは両エンジンで両方の層を運びます。古いエントリポイントは元の挙動を保っています。クラシックなScrollRowとScrollColumnプロパティ、そして0始まりのXLSX向けSetPaneScrollとGetPaneScrollです。フリーズと分割のジオメトリそのものは、シート保護、ページ設定、印刷で扱うシートレベルの設定で構成します

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // 右下:下側の行軸(Pane.rwTop)と右側の列軸(Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // 右上は右側の列軸を共有するため、これで右下も列6へ動く
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // 右下は行500、列6から始まる
end;

Selectionレコードが壊れていたときどうなるのか

Selectionレコードが壊れていた場合、HotXLSはそれを不透明なバイト列として保持し、診断コード1304(xlsDiagnosticSelectionRecordInvalid)を報告し、保存時には元のボディをバイト単位でそのまま書き戻します。レコードが所属ペインのグループに加わる前に、リーダーは順にチェックします。ペインバイトは3以下であること。同じペインのレコードはストリーム内で連続していること。9バイトの固定部が存在すること。crefは1から1369の間で、ボディは正確に9 + cref × 6バイトの長さであること。グループ内の全チャンクはアクティブセルとirefActで一致し、irefActには符号ビットが立っておらず、アクティブ列はグリッド上にあり、どのエリアも境界が逆になっていません。単一の物理レコード内の問題はレコードごとに1回報告されます。集約後にしか現れない矛盾、たとえばirefActが合計エリア数を超えて指している、アクティブセルがインデックス化されたエリアの外にある、というものは、EOFでグループごとに1回報告されます。無効なグループは型付きAPIからは見えません。そのペインに対するGetSelectedAreasはインデックス-1の空配列を返し、他のペインはすべて動き続けます

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

選択範囲は行や列の挿入をどう生き延びるのか

選択範囲が構造編集を生き延びるのは、行全体や列全体の挿入・削除が、共有のリマッパーを1つ通して、クラシックとXLSX両エンジンの表現されたペイングループすべてを再マップするからです。生き残ったエリアは順序を保ち、アクティブエリアは同一性を保ちます。アクティブエリアが削除された場合は、最初に生き残った後続がアクティブになり、後続がなければ最後の生き残った先行がアクティブになります。エリアが全部消えた場合は、グループは削除境界の1セルに縮み、選択されたエリア内に収まらなくなったアクティブセルはそのエリアの左上隅へ移動します。インデックスと座標が矛盾することはありません。限界も意図的です。無効なクラシックグループはリマッパーによって、でっち上げの選択範囲に書き直されるのではなくスキップされるため、元のバイトはそのままラウンドトリップします。1つのペインの編集はそのペインのレコードだけを置き換え、他はバイト単位で同一のまま残します。ODSにはペイン選択状態がまったく付きません。それを運ぶ同等のワークシートビュー構造がODFにはないからです

フラグ付きセルの確認、中断した場所からの再開、フリーズ済みダッシュボードの共有など、ユーザーが開いて操作する.xlsファイルをアプリケーションが書き出すなら、ペイン対応の選択・スクロールAPIはHotXLS Delphiスプレッドシートコンポーネントの一部であり、XLSでもXLSXでも同じように動作します