Technischer Artikel

HotXLS BIFF8-Selection-Records und Pane-Scroll in Delphi

HotXLS speichert Worksheet-Auswahlen und Scroll-Positionen pro Pane über eine einzige pane-bewusste API, und zwar auf TXLSWorksheet wie auf TXLSXWorksheet: SelectAreas, GetSelectedAreas, ScrollWindow und TryGetWindowScroll. Für klassische .xls-Dateien schreibt HotXLS BIFF8-Selection-Records (0x001D) mit jeweils höchstens 1369 Bereichen, übersetzt logische Pane-Namen in die vom Format definierten Pane-Bytes und legt jede Scroll-Achse auf den Window2- oder Pane-Record, wo Excel sie erwartet

Das Problem taucht meist in einem Abgleich- oder Audit-Tool auf. Das Tool öffnet einen Ledger-Export, findet jede Zelle, die vom Quellsystem abweicht, und speichert die Arbeitsmappe mit diesen Zellen als Auswahl unter einer eingefrorenen Kopfzeile, damit der Prüfer direkt bei den Abweichungen landet, statt danach zu scrollen. Bei vierzig Abweichungen klappt das bestens. Die Monatsabschluss-Datei hat 3,000, und ein einzelner Selection-Record mit 3,000 Bereichen kann nicht existieren: Sein Body bräuchte 18,009 Bytes, mehr als das Doppelte dessen, was ein BIFF8-Record tragen kann. Die Scroll-Position hat eine ähnliche Falle. Auf einem Sheet mit eingefrorenen Fenstern ist "wo der Nutzer hingesehen hat" nicht eine Koordinate, sondern vier Panes, die sich zwei Zeilen- und zwei Spaltenpositionen teilen

Warum braucht eine große Auswahl mehr als einen Selection-Record?

Eine große Auswahl braucht mehrere Records, weil ein BIFF8-Record-Body auf 8224 Bytes begrenzt ist und jeder ausgewählte Bereich feste sechs Bytes kostet. [MS-XLS] §2.4.248 legt den Selection-Record als 9-Byte-Fixed-Teil an (das Pane-Byte, rwAct und colAct für die aktive Zelle, irefAct für den aktiven Bereich und cref für die Bereichsanzahl), gefolgt von cref RefU-Strukturen, die jeweils zwei 16-Bit-Zeilen und zwei 8-Bit-Spalten halten. Die größte Anzahl, die hineinpasst, ist (8224 − 9) / 6 abgerundet, also 1369, und das ergibt einen 8223-Byte-Body, ein Byte unter dem Limit. TXLSWorksheet.StoreSelectionGroup benutzt diese Konstante als MaxAreasPerRecord und schreibt eine größere Gruppe als aufeinanderfolgende Selection-Records für denselben Pane, je 1369 Bereiche

Das Detail, das beißt, ist irefAct. Jeder Chunk wiederholt dieselbe aktive Zeile, aktive Spalte und denselben aktiven Bereichsindex, und irefAct indiziert die aggregierte Folge aller Chunks, nicht die Bereiche innerhalb des Records, der ihn trägt. Eine Auswahl, die das Limit um einen Bereich überschreitet, macht das konkret: 1370 Bereiche mit dem letzten aktiven werden zu zwei Records, der erste mit cref 1369, der zweite mit cref 1, und beide tragen irefAct 1369. Dieser Wert ist größer als die eigene Bereichsanzahl des zweiten Records. Ein Reader, der irefAct in jedem Record gegen cref prüft, verwirft eine gültige Datei, und ein Reader, der seinen State bei jedem Record ersetzt, verliert die ersten 1369 Bereiche. Der HotXLS-Reader hängt aufeinanderfolgende Same-Pane-Records an eine Gruppe an, verlangt von jedem Chunk Übereinstimmung bei aktiver Zelle und Index und führt die Bereichsprüfung erst am EOF-Record des Worksheets aus, wenn die vollständige Folge bekannt ist. Die pane-first-Überladung von SelectAreas hat daher keine 1369-Bereiche-Grenze. Sie validiert jede A1-Referenz und den aktiven Index, bevor sie sich den Worksheet-Write-Lock nimmt, und gibt False zurück, ohne die bisherige Auswahl zu verändern, wenn etwas missgestaltet ist

Warum HotXLS eine große Worksheet-Auswahl als mehrere BIFF8-Selection-Records schreibt: Das 8,224-Byte-Body-Limit fasst 9 feste Bytes plus 1369 Sechs-Byte-RefU-Bereiche, also werden 3,000 Bereiche zu drei Same-Pane-Records mit 1369, 1369 und 262, und irefAct indiziert die aggregierte Folge, sodass 1370 Bereiche mit dem letzten aktiven beiden Records irefAct 1369 geben
Jeder Chunk wiederholt dieselbe aktive Zelle und denselben Index, der HotXLS-Reader hängt aufeinanderfolgende Same-Pane-Records an eine Gruppe an, und die Bereichsprüfung läuft erst am EOF-Record, wenn die vollständige Folge bekannt ist
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // Kopfzeile und Spalte A bleiben fixiert

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

    // Einfrieren setzt die gespeicherte Auswahl zurück, also erst danach auswählen.
    // 3000 Bereiche werden zu drei Selection-Records: 1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

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

Welches Pane-Byte benutzt ein Selection-Record?

Ein Selection-Record identifiziert seinen Pane über den numerischen Code, den das Format definiert: 0 für unten rechts, 1 für oben rechts, 2 für unten links und 3 für oben links. Die öffentliche Enumeration TXLSPanePosition ist in Lesereihenfolge deklariert, xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight, Ord(xlspTopLeft) ist also 0 – was in der Datei der untere rechte Pane ist. Das Enum direkt in das Pane-Byte zu casten, würde jede Oben-links-Auswahl fehlerfrei auf den unteren rechten Pane schreiben. Jeder pane-bewusste HotXLS-Einstiegspunkt konvertiert das Enum stattdessen über ein explizites case-Statement, sodass Aufrufer mit den numerischen Codes nie in Berührung kommen. Geprüft wird auch die Pane-Existenz: Der obere rechte Pane existiert nur mit vertikalem Split, der untere linke nur mit horizontalem und der untere rechte nur mit beidem. Für einen Pane, den die aktuelle Split- oder Freeze-Geometrie nicht hat, gibt SelectAreas False zurück, und GetSelectedAreas liefert ein leeres Array mit ActiveAreaIndex -1 – ohne einen Pane, ein Selection-Objekt oder eine Zelle in der Arbeitsmappe anzulegen

Wie HotXLS TXLSPanePosition auf das BIFF8-Selection-Pane-Byte mappt: Das Enum ist in Lesereihenfolge deklariert, Ord(xlspTopLeft) ist also 0, während die Datei 0 für unten rechts, 1 für oben rechts, 2 für unten links und 3 für oben links definiert, sodass jeder pane-bewusste Einstiegspunkt über ein explizites case-Statement konvertiert
Das Enum direkt ins Pane-Byte zu casten, würde jede Oben-links-Auswahl auf den unteren rechten Pane schreiben, daher prüft HotXLS vor dem Schreiben auch die Pane-Existenz gegen die aktuelle Split- oder Freeze-Geometrie

Wo wohnt die Scroll-Position jedes Panes?

Die Scroll-Position jedes Panes verteilt sich auf zwei Records, denn vier Panes teilen sich nur zwei Zeilen- und zwei Spaltenpositionen. In einer klassischen Arbeitsmappe sind die erste sichtbare Zeile der oberen Panes und die erste sichtbare Spalte der linken Panes Window2.rwTop und Window2.colLeft, während die Zeile der unteren Panes und die Spalte der rechten Panes in Pane.rwTop und Pane.colLeft liegen. ScrollWindow(xlspTopRight, R, C) schreibt daher Window2.rwTop und Pane.colLeft, und die Spalte des oberen rechten Panes zu setzen verschiebt auch den unteren rechten – genau wie die beiden sich in Excel eine horizontale Scrollbar teilen. Die öffentlichen Methoden arbeiten mit 1-basierten Zeilen- und Spaltennummern. Ein fehlender Pane gibt False zurück und setzt beide Abfrage-Ausgaben auf null, und eine Koordinate außerhalb des Bereichs wird abgelehnt, bevor sich eine Achse ändert. Nichts davon hängt davon ab, wie ein Viewer das Raster zeichnet. Ein Rendering-Control hält sein eigenes TopRow und LeftCol, wie der Artikel zum Rendern von Arbeitsmappen in einem eigenen VCL-Grid beschreibt, und das ist Runtime-State, nicht das, was gespeichert wird

Wo jede HotXLS-Pane-Scroll-Achse wohnt: Vier Panes teilen sich zwei Zeilen- und zwei Spaltenpositionen, obere Zeile und linke Spalte liegen also in Window2.rwTop und Window2.colLeft, während untere Zeile und rechte Spalte in Pane.rwTop und Pane.colLeft liegen, und ScrollWindow(xlspTopRight, 1, 6) schreibt ein Window2-Feld plus ein Pane-Feld, sodass unten rechts mitzieht
XLSX verteilt dieselben Daten über die topLeftCell-Attribute von sheetView und pane, und die beiden Ebenen in eine zusammenzufassen ist genau die Art, wie eine obere oder linke Scroll-Position beim Laden stillschweigend verschwindet

XLSX verteilt dieselben Daten auf zwei Elemente: sheetView/@topLeftCell (ECMA-376 Part 1, §18.3.1.87) für das Fenster als Ganzes und das Kind pane/@topLeftCell (§18.3.1.66) für die untere rechte Seite eines Splits. Beide Attribute können gleichzeitig vorhanden sein. HotXLS liest das äußere Attribut zuerst in die Felder auf Fensterebene, lässt das pane-Kind nur die Felder auf Pane-Ebene überschreiben und schreibt beide getrennt zurück. Die beiden Ebenen in eine zusammenzufassen ist genau die Art, wie eine obere oder linke Scroll-Position beim Laden stillschweigend verschwindet. Worksheet-Kopien tragen beide Ebenen, in beiden Engines. Die älteren Einstiegspunkte behalten ihr ursprüngliches Verhalten: die klassischen Properties ScrollRow und ScrollColumn sowie die nullbasierten XLSX-Varianten SetPaneScroll und GetPaneScroll. Die Freeze- und Split-Geometrie selbst konfigurieren Sie über die Einstellungen auf Sheet-Ebene, die der Artikel zu Sheet-Schutz, Seiteneinrichtung und Drucken abdeckt

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

  // Unten rechts: untere Zeilenachse (Pane.rwTop) und rechte Spaltenachse (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // Oben rechts teilt die rechte Spaltenachse, daher schiebt dies auch unten rechts auf Spalte 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]));
    // Unten rechts beginnt bei Zeile 500, Spalte 6
end;

Was passiert, wenn ein Selection-Record korrupt ist?

Ist ein Selection-Record korrupt, behält HotXLS ihn als opake Bytes, meldet den Diagnose-Code 1304 (xlsDiagnosticSelectionRecordInvalid) und schreibt den ursprünglichen Body beim Speichern Byte für Byte zurück. Bevor ein Record der Gruppe seines Panes beitritt, prüft der Reader ihn der Reihe nach. Das Pane-Byte muss 3 oder kleiner sein. Records für einen Pane müssen im Stream zusammenhängend liegen. Die 9 festen Bytes müssen vorhanden sein. cref muss zwischen 1 und 1369 liegen, und der Body muss exakt 9 + cref × 6 Bytes lang sein. Jeder Chunk in einer Gruppe muss bei aktiver Zelle und irefAct übereinstimmen, irefAct darf kein Vorzeichenbit gesetzt haben, die aktive Spalte muss auf dem Raster liegen, und kein Bereich darf vertauschte Grenzen haben. Probleme in einem einzelnen physischen Record werden einmal pro Record gemeldet. Widersprüche, die erst nach der Aggregation auftauchen – etwa ein irefAct, der hinter die Gesamtzahl der Bereiche zeigt, oder eine aktive Zelle außerhalb des indizierten Bereichs – werden einmal pro Gruppe am EOF gemeldet. Eine ungültige Gruppe bleibt für die typisierte API unsichtbar: GetSelectedAreas liefert für diesen Pane ein leeres Array mit Index -1, während alle anderen Panes weiterarbeiten

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;

Wie überleben Auswahlen Zeilen- und Spalteneinfügungen?

Auswahlen überleben strukturelle Änderungen, weil das Einfügen oder Löschen ganzer Zeilen oder Spalten jede dargestellte Pane-Gruppe in der Classic- wie in der XLSX-Engine über einen gemeinsamen Remapper neu abbildet. Überlebende Bereiche behalten ihre Reihenfolge, und der aktive Bereich behält seine Identität. Wird der aktive Bereich gelöscht, wird der erste überlebende Nachfolger aktiv, sonst der letzte überlebende Vorgänger, wenn nichts mehr folgt. Werden alle Bereiche gelöscht, kollabiert die Gruppe auf eine einzelne Zelle an der Löschgrenze, und eine aktive Zelle, die nicht mehr im gewählten Bereich liegt, wandert in dessen obere linke Ecke – Index und Koordinate widersprechen sich also nie. Die Grenzen sind bewusst so gezogen. Ungültige Classic-Gruppen überspringt der Remapper, statt sie in eine erfundene Auswahl umzuschreiben, sodass ihre Original-Bytes weiterhin unverändert round-trippen. Das Bearbeiten eines Panes ersetzt nur die Records dieses Panes und lässt die anderen byteidentisch. ODS bekommt gar keinen Pane-Selection-State, weil ODF keine äquivalente Worksheet-View-Struktur hat, die ihn tragen könnte

Wenn Ihre Anwendung .xls-Dateien schreibt, die Nutzer öffnen und darin navigieren müssen – ob um markierte Zellen zu prüfen, dort weiterzuarbeiten, wo sie aufgehört haben, oder ein eingefrorenes Dashboard zu teilen –, dann ist die pane-bewusste Selection- und Scroll-API Teil der HotXLS-Delphi-Tabellenkomponente, und sie funktioniert für XLS wie für XLSX gleich