Technischer Artikel

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

Ein Abstimmungswerkzeug findet 3.000 Zellen, die vom Hauptbuch abweichen, und möchte, dass die gespeicherte .xls sich öffnet, mit allen ausgewählt unter einer fixierten Kopfzeile. Auf dem Papier ist das ein Selection-Record. In Bytes sind es 9 + 3.000 × 6 = 18.009, und ein BIFF8-Record-Body endet bei 8.224. Dieselbe Mappe stellt eine zweite Frage, die trivial klingt und es nicht ist: Auf einem bei B2 fixierten Blatt sind "die Scroll-Position" vier Panes, die sich zwei Zeilen-Offsets und zwei Spalten-Offsets teilen, und diese vier Zahlen leben in zwei verschiedenen Records

Beide Probleme sind Format-Arithmetik: Die Auswahl wird in Records geschnitten, die ein Reader wieder zusammensetzen muss, das Pane wird über einen Code benannt, der nicht der Lesereihenfolge folgt, und jede Scroll-Achse landet in dem Record, aus dem Excel sie liest

Warum 3.000 ausgewählte Bereiche drei Selection-Records brauchen

[MS-XLS] §2.4.248 legt den Selection-Record (0x001D) als einen 9-Byte-Festteil gefolgt von den Bereichen an. Der Festteil hält das Pane-Byte, rwAct und colAct für die aktive Zelle, irefAct für den Index des aktiven Bereichs und cref für die Bereichsanzahl. Jeder Bereich ist eine RefU-Struktur mit zwei 16-Bit-Zeilen und zwei 8-Bit-Spalten. Die größte Anzahl, die hineinpasst, ist (8.224 − 9) / 6 abgerundet, was 1.369 Bereiche und einen 8.223-Byte-Body ergibt, ein Byte unter dem Limit

HotXLS verwendet 1.369 als seine Deckelung pro Record. Wenn TXLSWorksheet eine Auswahlgruppe mit mehr Bereichen als das speichert, schreibt es aufeinanderfolgende Selection-Records für dasselbe Pane, je 1.369 Bereiche, und jeder Record wiederholt dasselbe Pane-Byte, dieselbe aktive Zelle und denselben aktiven Index. Die 3.000-Zellen-Abstimmungsauswahl kommt als drei Records mit 1.369, 1.369 und 262 Bereichen heraus. Aufrufer sehen diese Aufteilung nie; sie reichen ein Array von A1-Referenzen weiter

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // Headerzeile und Spalte A bleiben fixiert

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

    // FreezePanes löscht die gespeicherte Auswahl, also erst nach dem Fixieren auswählen.
    // 3000 Bereiche werden als drei Selection-Records gespeichert: 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;

Die Aufrufreihenfolge zählt hier. FreezePanes baut das Pane-Layout neu auf und löscht jede pane-spezifische Auswahl, die das Blatt trug, also wäre eine frühere Auswahl zur Speicherzeit weg. Auf einem nicht fixierten Blatt existiert das untere rechte Pane noch nicht, und derselbe SelectAreas-Aufruf würde schlicht False zurückgeben. Erst fixieren, dann auswählen

Auf was zeigt irefAct, wenn eine Auswahl mehrere Records umfasst?

In einer geteilten Auswahl indiziert irefAct die gesamte aggregierte Folge von Bereichen über alle Chunks hinweg, und jeder Chunk trägt denselben Wert. Eine Auswahl von einem Bereich über dem Limit macht das konkret: 1.370 Bereiche mit dem letzten aktiven werden zwei Records, der erste mit cref 1.369 und der zweite mit cref 1, und beide tragen irefAct 1.369. Im zweiten Record ist dieser Index größer als die eigene Bereichsanzahl des Records. Liest man die Records einzeln, sieht's kaputt aus, und zwei Arten von Readern stolpern darüber

  • Ein Reader, der irefAct innerhalb jedes Records gegen cref prüft, weist eine vollkommen gültige Datei am zweiten Chunk ab
  • Ein Reader, der seinen Auswahlzustand bei jedem Selection-Record ersetzt, behält nur den letzten Chunk und verliert die ersten 1.369 Bereiche

Der HotXLS-Reader hängt aufeinanderfolgende Records für dasselbe Pane zu einer Gruppe zusammen und verlangt, dass jeder Chunk bei der aktiven Zeile, der aktiven Spalte und irefAct übereinstimmt. Die Bereichsprüfung auf irefAct und die Prüfung, dass die aktive Zelle wirklich innerhalb des Bereichs sitzt, auf den sie zeigt, laufen erst, wenn der EOF-Record des Worksheets ankommt und die volle Sequenz bekannt ist. Früher zu prüfen hieße einfacheren Code, und's hieße die falsche Antwort für jede Auswahl über 1.369 Bereiche

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

Auf der Schreibseite hat die Pane-zuerst-Überladung von SelectAreas keine eigene 1.369-Bereiche-Deckelung. Sie parst jede Referenz, bevor sie das Blatt anfasst: $-Marker werden akzeptiert und entfernt, ein umgedrehter Bereich wie C9:A2 wird zu A2:C9 normalisiert, und ein leerer oder unparsbarer Eintrag lässt den ganzen Aufruf scheitern. Der aktive Index muss innerhalb des Arrays liegen und bei oder unter 32.767 bleiben, denn irefAct ist ein vorzeichenbehaftetes 16-Bit-Feld. Jeder Fehlschlag liefert False und lässt die vorherige Auswahl exakt so, wie sie war

Pane-Codes folgen nicht der Lesereihenfolge

Ein Selection-Record benennt sein Pane mit dem 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 Aufzählung TXLSPanePosition ist in Lesereihenfolge deklariert, xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight. Also ist Ord(xlspTopLeft) 0, und 0 in der Datei bedeutet unten rechts. Ein Writer, der das Enum direkt in das Pane-Byte castet, legt jede Auswahl oben links auf das Pane unten rechts, und die Datei bleibt strukturell gültig, weil beide legale Pane-Codes sind

Jeder Pane-bewusste Einstiegspunkt in HotXLS konvertiert das Enum über ein explizites case-Statement, also fasst Anwendungscode die numerischen Codes nie an. Die Pane-Existenz wird ebenfalls gegen die aktuelle Fixier- oder Split-Geometrie geprüft. Das Pane oben links existiert immer. Oben rechts braucht einen vertikalen Split, unten links einen horizontalen, und unten rechts beide. Für ein Pane, das das Blatt nicht hat, liefert SelectAreas False und GetSelectedAreas ein leeres Array mit ActiveAreaIndex auf -1, und keiner der beiden Aufrufe erzeugt unterwegs ein Pane, eine Auswahl oder eine Zelle

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

Das Worksheet hält außerdem den älteren Zwei-Argument-SelectAreas(Areas, ActiveAreaIndex), der in das gerade aktive Pane schreibt und einen Integer-Status zurückgibt. Auf einem Blatt mit fixierten Panes ist das oft eine Vermutung über einen Zustand, den Sie nicht selbst gesetzt haben. Verwenden Sie zuerst die Überladung mit einem TXLSPanePosition für alles, was auf fixierten oder geteilten Blättern läuft; das Pane ist dann Teil des Aufrufs, und ein False-Ergebnis sagt Ihnen, dass Geometrie und Anfrage sich widersprechen. Das Zurücklesen folgt derselben Gestalt:

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;

Wo wohnt jedes Pane's Scroll-Position?

Die Scroll-Position jedes Panes ist über zwei Records verteilt, denn's sind nur zwei Zeilenpositionen und zwei Spaltenpositionen, die sich vier Panes teilen. In einer klassischen Mappe ist's die erste sichtbare Zeile der oberen Panes und die erste sichtbare Spalte der linken Panes, abgelegt als Window2.rwTop und Window2.colLeft. Die Zeile der unteren Panes und die Spalte der rechten Panes sind Pane.rwTop und Pane.colLeft, und's bleibt dabei: Oben rechts nimmt seine Zeile aus Window2 und seine Spalte aus Pane, macht's unten links umgekehrt

ScrollWindow und TryGetWindowScroll folgen diesem Layout exakt und verwenden 1-basierte Zeilen- und Spaltennummern. ScrollWindow(xlspTopRight, R, C) schreibt Window2.rwTop und Pane.colLeft, was bedeutet, dass das Setzen der Spalte oben rechts auch das Pane unten rechts bewegt. Das entspricht Excel, wo sich die beiden rechten Panes eine horizontale Scrollbar teilen. Ein fehlendes Pane lässt ScrollWindow False zurückgeben und TryGetWindowScroll beide Ausgaben auf null setzen. Eine Zeile oder Spalte außerhalb des Bereichs wird abgelehnt, bevor sich eine Achse ändert, also kann eine schlechte Spalte keine halb angewandte Zeile zurücklassen

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, also verschiebt dies unten rechts ebenfalls 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;
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 speichert dieselben beiden Schichten in zwei Elementen: 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. Ein Loader, der nur eines von beiden behält, verliert entweder die oberen Zeilen oder die linken Spalten jedes fixierten Blatts, das er anfasst. TXLSXWorksheet liest das äußere Attribut zuerst in die Fenster-Level-Felder, lässt das pane-Kind nur die Pane-Level-Felder überschreiben und schreibt beide getrennt zurück. ScrollWindow und TryGetWindowScroll haben auf TXLSXWorksheet dieselben Signaturen und dieselbe Bedeutung, und das Kopieren eines Worksheets trägt beide Schichten in beiden Formaten

Die schmaleren Eigenschaften sind weiterhin da für Code, der nur ein Achsenpaar braucht: ScrollRow und ScrollColumn auf dem klassischen Worksheet, und die nullbasierten SetPaneScroll und GetPaneScroll für das Pane unten rechts auf XLSX. Ein Rendering-Control hält sein eigenes TopRow und LeftCol, wie der Artikel über das Rendern von Workbooks in einem custom VCL grid beschreibt, und dieser Laufzeitzustand ist getrennt von dem, was gespeichert wird. Die Fixier- und Split-Geometrie selbst wird mit den Einstellungen auf Blattebene aus Blattschutz, Seiteneinrichtung und Drucken gesetzt

Korrupte Selection-Records werden Byte für Byte behalten

Wenn ein Selection-Record malformed ist, behält HotXLS seinen Body als opake Bytes, meldet den Diagnosecode 1304 (xlsDiagnosticSelectionRecordInvalid) als Warnung und schreibt den ursprünglichen Body beim Speichern unverändert zurück. Dateien von unbekannten Exporteuren oder handgestrickten BIFF-Writern können solche Records enthalten. Bevor's ein Record in seine Pane-Gruppe schafft, prüft der Reader ein Pane-Byte von 3 oder weniger, zusammenhängende Records pro Pane, cref zwischen 1 und 1.369 mit einem Body von exakt 9 + cref × 6 Bytes, Chunks, die bei der aktiven Zelle und einem irefAct ohne Vorzeichenbit übereinstimmen, eine aktive Spalte auf dem Raster und keine umgedrehten Bereiche. Probleme innerhalb eines physischen Records werden einmal pro Record gemeldet; Widersprüche, die erst nach der Aggregation auftauchen, etwa ein irefAct, das über die Gesamtzahl der Bereiche hinauszeigt, oder eine aktive Zelle außerhalb des indizierten Bereichs, werden einmal pro Gruppe am EOF gemeldet

Eine ungültige Gruppe bleibt draußen vor der getypten API: GetSelectedAreas liefert für dieses Pane ein leeres Array mit Index -1, und die anderen Panes arbeiten normal weiter

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 ist Ihr eigenes TStrings; HotXLS füllt nur Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Den Record zu reparieren, indem man irefAct einklemmt oder den umgedrehten Bereich fallen lässt, war die andere Option. Ihn zu bewahren ist der sicherere Default: Eine reparierte Auswahl ist eine, die niemand gewählt hat, und die ursprünglichen Bytes wären weg. Die Diagnosemeldung benennt die Prüfung, die scheiterte, etwa "the area count does not match the record length" oder "records for the same pane are not contiguous", und RecordId ist für diesen Code immer $001D

Zeilen- und Spalteneinfügungen mappen jedes Pane's Auswahl um

Ganze Zeilen oder Spalten einzufügen oder zu löschen mappt die Auswahl jedes Panes über eine gemeinsame Routine um, in beiden Engines, der klassischen und der XLSX. Überlebende Bereiche behalten ihre Reihenfolge und verschieben sich mit den Zellen, die sie abdecken, und der aktive Bereich behält seine Identität. Wird der aktive Bereich gelöscht, wird der erste überlebende Bereich nach ihm aktiv; folgt nichts, der letzte überlebende Bereich vor ihm. Werden alle Bereiche gelöscht, kollabiert die Gruppe zu einer einzelnen Zelle an der Löschgrenze. Eine aktive Zelle, die nicht mehr innerhalb des gewählten Bereichs liegt, wandert an dessen obere linke Ecke, in der gespeicherten Datei gibt's für Index und Koordinate also keinen Widerspruch

Gruppen, die beim Laden als ungültig markiert wurden, lässt die UmMapping-Routine in Ruhe, also geht's mit ihren ursprünglichen Bytes auch nach der Bearbeitung noch sauber durch den Roundtrip. Ändert sich die Auswahl auf einem Pane, werden nur die Records dieses Panes umgeschrieben und's bleibt bei den Records der anderen Panes byte-identisch. ODS-Dateien tragen überhaupt keinen Pane-Auswahlzustand, denn OpenDocument hat keine entsprechende Struktur pro Pane in der Worksheet-View; Auswahlen und Pane-Scroll-Positionen gelten für XLS und XLSX

Wenn Ihre Anwendung Workbooks speichert, die Leute öffnen, um markierte Zellen zu prüfen, dort weiterzumachen, wo jemand aufgehört hat, oder ein fixiertes Dashboard zu lesen, kommen die Pane-bewussten Auswahl- und Scroll-Aufrufe mit der HotXLS-Delphi-Tabellenkalkulationskomponente und verhalten sich für .xls- und .xlsx-Ausgabe gleichermaßen