技術記事

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

照合ツールが台帳と食い違う3,000セルを見つけ、保存される.xlsが、凍結されたヘッダー行の下でその全セルを選択状態にして開かれることを望んでいます。紙の上では1つのSelectionレコードです。バイトでは9 + 3,000 × 6 = 18,009であり、BIFF8のレコードボディは8,224で止まります。同じワークブックは、些細に聞こえて些細ではないもう1つの問いを持ち出します。B2で凍結されたシートでは、"the scroll position"(スクロール位置)とは、2つの行オフセットと2つの列オフセットを共有する4つのペインであり、その4つの数字は2つの異なるレコードに住んでいます

どちらの問題も形式の算術です。選択範囲はリーダーが縫い合わせなければならないレコードに切り分けられ、ペインは読む順序に従わないコードで名付けられ、各スクロール軸は、Excelがそこから読むレコードへ着地します

なぜ3,000の選択エリアに3つのSelectionレコードが要るのか

[MS-XLS] §2.4.248はSelectionレコード(0x001D)を、9バイトの固定部に続くエリアとして定義します。固定部はペインバイト、アクティブセルのrwActとcolAct、アクティブエリアのインデックスirefAct、エリア数のcrefを保持します。各エリアは、16ビット行2つと8ビット列2つを持つRefU構造です。収まる最大数は(8,224 − 9) / 6の切り捨てで、1,369エリア、ボディは8,223バイト、上限の1バイト手前です

HotXLSは1,369をレコードあたりの天井として使います。TXLSWorksheetがそれより多いエリアの選択グループを保存するとき、同じペインの連続するSelectionレコードに、1回に1,369エリアずつ書き出し、各レコードは同じペインバイト、アクティブセル、アクティブインデックスを繰り返します。3,000セルの照合選択は、1,369、1,369、262のエリアを持つ3つのレコードとして出てきます。呼び出し側がその分割を見ることはありません。A1参照の配列を渡すだけです

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;

ここでは呼び出し順序が効きます。FreezePanesはペインレイアウトを組み立て直し、シートが抱えていたペインごとの選択をすべて消すため、前もっての選択は保存時点で消えています。凍結していないシートでは右下ペインはまだ存在せず、同じSelectAreas呼び出しは素直にFalseを返します。凍結してから選択、です

選択範囲が複数レコードにまたがるとき、irefActは何を指すのか

分割された選択範囲では、irefActはすべてのチャンクにまたがる集約シーケンス全体に索引を付け、すべてのチャンクが同じ値を運びます。上限を1エリアだけ超える選択範囲がこれを具体化します。最後の1つがアクティブな1,370エリアは2レコードになり、1つ目はcref 1,369、2つ目はcref 1で、両方ともirefAct 1,369を運びます。2つ目のレコードでは、そのインデックスは the record's own area count(レコード自身のエリア数)より大きいのです。1レコードずつ読むと壊れているように見え、2種類のリーダーがここでつまずきます

  • 各レコードの内側でirefActをcrefと照合するリーダーは、完全に正当なファイルを2つ目のチャンクで拒否します
  • Selectionレコードごとに選択状態を置き換えるリーダーは、最後のチャンクだけを保持し、最初の1,369エリアを落とします

HotXLSのリーダーは、同じペインの連続レコードを1つのグループへ追加し、すべてのチャンクがアクティブ行、アクティブ列、irefActで一致することを要求します。irefActの範囲検査と、アクティブセルが実際にその指す先のエリアの内側にいることの検査は、the worksheet's EOF record(ワークシートのEOFレコード)が届き、全シーケンスが判明したときにだけ走ります。もっと早く検査する方がコードは単純になりますが、1,369エリアを超えるどんな選択範囲にも間違った答えです

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

書き込み側では、ペインが先に来るSelectAreasオーバーロードには、1,369エリアの上限が自分自身にはありません。シートに触れる前にすべての参照をパースします。$マーカーは受け入れられて剥がされ、C9:A2のような逆転した範囲はA2:C9へ正規化され、空のエントリーやパースできないエントリーは呼び出し全体を失敗させます。アクティブインデックスは配列の内側に収まり、32,767以下でなければなりません。irefActは符号付き16ビットフィールドだからです。どんな失敗もFalseを返し、前の選択範囲をあったままにします

ペインコードは読む順序に従わない

Selectionレコードは、形式が定義する数値コードでペインを名指します。右下が0、右上が1、左下が2、左上が3です。公開列挙型TXLSPanePositionは読む順序で宣言されています。xlspTopLeft、xlspTopRight、xlspBottomLeft、xlspBottomRightです。したがってOrd(xlspTopLeft)は0であり、ファイルで0は右下を意味します。列挙型をペインバイトへそのままキャストするライターは、すべての左上選択を右下ペインに書き込みます。そしてファイルは構造的に妥当なままです。どちらも合法なペインコードだからです

HotXLSのペイン対応エントリーポイントはどれも、明示的なcase文を通して列挙型を変換するため、アプリケーションコードが数値コードを扱うことはありません。ペインの存在も、現在の凍結や分割のジオメトリに対して検査されます。左上ペインは常に存在します。右上は縦分割を、左下は横分割を、右下は両方を要ります。シートが持たないペインに対しては、SelectAreasがFalseを返し、GetSelectedAreasがActiveAreaIndexを-1にした空配列を返し、どちらの呼び出しも、道中でペインや選択やセルを作りません

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

ワークシートは、古い2引数のSelectAreas(Areas, ActiveAreaIndex)も保持しています。こちらは現在アクティブなペインへ書き込み、Integerのステータスを返します。ペインを凍結したシートでは、それは自分で設定していない状態についての推測になりがちです。凍結や分割のシートで走るものには、先にTXLSPanePositionを受け取るオーバーロードを使ってください。ペインが呼び出しの一部になり、Falseの結果が、ジオメトリとリクエストの食い違いを伝えます。結果の読み戻しは同じ形に従います:

var
  Areas: TXLSSelectedAreas;
  Active: Integer;
begin
  Areas := Sheet.GetSelectedAreas(xlspBottomRight, Active);
  if Active < 0 then
    Memo1.Lines.Add('No usable selection on the bottom-right pane')
  else
    Memo1.Lines.Add(Format('%d areas, active area %s',
      [Length(Areas), Areas[Active]]));
end;

各 pane's scroll position(ペインのスクロール位置)はどこにあるのか

各ペインのスクロール位置は2つのレコードに分かれています。4つのペインが共有するのは、行位置2つと列位置2つだけだからです。クラシックなワークブックでは、the upper panes'(上側ペインたち)の最初の可視行と the left panes'(左側ペインたち)の最初の可視列がWindow2.rwTopとWindow2.colLeftです。the lower panes'(下側ペインたち)の行と the right panes'(右側ペインたち)の列はPane.rwTopとPane.colLeftです。したがって右上は行をWindow2から、列をPaneから取ります。左下はその逆です

ScrollWindowとTryGetWindowScrollはその配置に正確に従い、1始まりの行番号と列番号を使います。ScrollWindow(xlspTopRight, R, C)はWindow2.rwTopとPane.colLeftを書くため、右上の列を設定することは右下ペインも動かします。2つの右側ペインが1つの水平スクロールバーを共有するExcelと同じです。ペインが欠けているとScrollWindowはFalseを返し、TryGetWindowScrollは両方の出力をゼロにします。範囲外の行や列は、どちらの軸も変わる前に拒否されるため、悪い列が半分適用された行を置き去りにすることはありません

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;
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つの層を2つの要素に保存します。ウィンドウ全体のsheetView/@topLeftCell(ECMA-376 Part 1、§18.3.1.87)と、分割の右下側のための子のpane/@topLeftCell(§18.3.1.66)です。両方の属性は同時に存在でき得ます。どちらか1つだけを保持するローダーは、触れたすべての凍結シートで、上側の行か左側の列を失います。TXLSXWorksheetは外側の属性をウィンドウレベルのフィールドへ先に読み、paneの子にペインレベルのフィールドだけを上書きさせ、両方を別々に書き戻します。ScrollWindowとTryGetWindowScrollはTXLSXWorksheetでも同じシグネチャと意味を持ち、ワークシートのコピーはどちらの形式でも両方の層を運びます

1つの軸ペアしか要らないコードのための、より狭いプロパティもまだあります。クラシックなワークシートのScrollRowとScrollColumn、そしてXLSXの右下ペインのための0始まりのSetPaneScrollとGetPaneScrollです。レンダリングコントロールは自分のTopRowとLeftColを持ちます。カスタムVCLグリッドでワークブックを描画する記事が述べるとおり、その実行時状態は保存されるものとは別物です。凍結と分割のジオメトリそのものは、シート保護、ページ設定、印刷で扱うシートレベルの設定で行います

壊れたSelectionレコードはバイト単位で保持される

Selectionレコードが不正な形式のとき、HotXLSはそのボディを不透明なバイト列として保持し、診断コード1304(xlsDiagnosticSelectionRecordInvalid)を警告として報告し、保存時に元のボディを変わらずに書き戻します。未知のエクスポーターや手作りのBIFFライターのファイルは、そういうレコードを含み得ます。レコードが its pane's group(所属ペインのグループ)に加わる前に、リーダーは、3以下のペインバイト、ペインごとの連続するレコード、9 + cref × 6バイトちょうどのボディを持つ1〜1,369のcref、アクティブセルと符号ビットのないirefActで一致するチャンク、グリッド上のアクティブ列、そして逆転したエリアがないことを検査します。1つの物理レコードの内側の問題はレコードごとに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は自前のTStrings。HotXLSが埋めるのはDiagnosticsだけ
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

レコードを修復する道、つまりirefActを丸め込んだり逆転したエリアを落としたりすることは、もう1つの選択肢でした。保持する方が安全なデフォルトです。修復された選択範囲は誰も選んでいないものであり、元のバイトはもう戻りませんから。診断メッセージは失敗した検査を名指します。たとえば "the area count does not match the record length"(エリア数がレコード長と一致しない)や "records for the same pane are not contiguous"(同じペインのレコードが連続していない)であり、このコードではRecordIdは常に$001Dです

行と列の挿入は every pane's selection(ペインごとの選択範囲)を再マップする

行全体や列全体の挿入と削除は、1つの共有ルーチンを通して、クラシックとXLSXの両エンジンで、すべてのペインの選択を再マップします。生き残ったエリアは順序を保ち、覆っているセルと一緒に動き、アクティブエリアは同一性を保ちます。アクティブエリアが削除されたときは、その後の最初の生き残りエリアがアクティブになり、後ろに何もなければ前の最後の生き残りエリアがそうなります。すべてのエリアが削除されたときは、グループは削除境界の1つのセルへ縮みます。選ばれたエリアの内側に収まらなくなったアクティブセルは that area's top-left corner(そのエリアの左上隅)へ動くため、インデックスと座標が保存されたファイルの中で矛盾することは決してありません

ロード時に無効とされたグループは、リマッパーが手を付けないため、編集の後も元のバイトがそのままラウンドトリップします。1つのペインの選択変更は only that pane's records(そのペインのレコード)だけを書き換え、the other panes' records(他のペインのレコード)はバイト単位で同一のまま残します。ODSファイルはペイン選択状態をまったく運びません。OpenDocumentには、ペインごとのワークシートビュー構造に相当するものがないからです。選択とペインスクロール位置が適用されるのはXLSとXLSXです

フラグ付きセルを開いて確認する、中断した場所から再開する、凍結されたダッシュボードを読む、といったワークブックをアプリケーションが保存するなら、ペイン対応の選択とスクロールの呼び出しはHotXLS Delphiスプレッドシートコンポーネントに同梱され、.xlsでも.xlsxでも同じように振る舞います