Technischer Artikel

BIFF-PivotCache-Records mit HotXLS in Delphi framen

Der BIFF-PivotCache-Substream speichert den gecachten Datensatz einer PivotTable getrennt von der View, die ihn anzeigt, und HotXLS liest und schreibt diesen Substream, indem es Record-Bodies inspiziert, statt Record-Nummern zu vertrauen. Dieser Unterschied ist die ganze Geschichte: Dieselbe Record-Nummer trägt zwei inkompatible Body-Layouts, je nachdem, welcher Writer die Datei erzeugt hat, also entscheidet der Reader das Framing aus dem ersten Record-Body, den er sieht

Dieser Layer begegnet einem in dem Moment, in dem eine PivotTable eine Runde überstehen muss. Eine Pivot-View ohne ihren Cache ist eine Hülse, und Excel baut den Cache beim Öffnen der Datei aus dem Quellbereich neu — solange in Ordnung, bis der Quellbereich weg ist, die Daten aus einer Abfrage eingefügt wurden oder die Arbeitsmappe ein archivierter Abschluss ist, der sich beim Öffnen nicht verändern darf

Zwei Strukturen, zwei Orte in der Datei

Die gecachten Daten und die Cache-Definition leben in verschiedenen Teilen der Arbeitsmappe, und sie zu verwechseln ist das Erste, was man richtig machen muss. Die gecachten Records bilden ihren eigenen Substream, angegeben in [MS-XLS] §2.1.7.12 als PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Beachten Sie, was fehlt: Am Kopf dieser Produktion steht keine BOF

Die Definition sitzt stattdessen in den Workbook-Globals, als PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), positioniert nach den Formatierungs-Records und vor den BoundSheet- und Country-Records. Ein einzelner Cache wird also an zwei Stellen beschrieben, die hunderte Records auseinanderliegen, und das Band dazwischen ist ein Stream-Identifikator, der an drei Stellen gleichzeitig übereinstimmen muss

HotXLS-PivotCache-Framing in BIFF8: Die PIVOTCACHEDEFINITION mit ihrer SXStreamID sitzt in den Workbook-Globals nach der Formatierung und vor BoundSheet, während die gecachten Records in einem Stream unter dem _SX_DB_CUR-Storage leben, benannt mit vierstelligem Hex in Großbuchstaben, der SXDB-, SXDBEx-, SXFORMULA-, FDB- und DBB-Records ohne BOF hält, und SXStreamID.idStm, das SXDB-idstm-Feld und der Stream-Name müssen übereinstimmen
Ein einzelner Pivot-Cache wird an zwei Stellen beschrieben, hunderte Records auseinander, verbunden durch einen Stream-Identifikator, der in den Globals, im SXDB-Header und im Substream-Namen gleichzeitig übereinstimmen muss

Jeder Cache gehört in einen Stream unter _SX_DB_CUR, dessen Name die vierstellige Hex-Schreibweise seines Identifikators in Großbuchstaben ist. SXStreamID.idStm, das im SXDB-Header wiederholte Feld idstm und dieser Stream-Name müssen alle übereinstimmen. Wenn man einen neuen Identifikator vergibt, zuerst jede bereits aus der Datei gelesene Nummer reservieren, sonst kann ein neuer Cache eine Nummer beanspruchen, die einem älteren Cache gehört, zu dem der Reader noch nicht vorgedrungen ist

Noch ein Identifikator erwischt Leute. Der iCache-Wert in einer Pivot-View ist die nullbasierte Position der zugehörigen SXStreamID in der globalen Sequenz, kein Cache-Identifikator, den man sich aussuchen darf. Beim Schreiben muss er vom Cache-Objekt auf seine tatsächliche Ausgabeposition gemappt werden, und bestehende Views müssen mit umnummeriert werden, sonst zeigt das Upgrade eines Caches still eine View auf einen anderen

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET, MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // den gecachten Records vertrauen
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // leeren, dann das Record-Gitter dimensionieren
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

Das doppelte SetRecordCount ist kein Aberglaube. RecordCount ist ein schlichtes Property-Write, das nicht allokiert, und der interne Wachstumspfad initialisiert nur die neu hinzugefügten Zeilen, also kann ein Cache, dessen Count über den Header-Pfad gesetzt wurde, mit einem Index-Gitter der Länge null enden. Schreibvorgänge auf RecordIndices werden dann ohne Fehler verworfen. Den Count auf null und zurück zu setzen stellt das Gitter wieder her, und das muss passieren, nachdem jedes Feld hinzugefügt wurde, denn die Zeilenbreite kommt aus der Feldzahl

Warum kann eine Record-Nummer nicht das Body-Layout verraten?

Weil Record-Nummern und Body-Layouts zu unterschiedlichen Zeiten geändert wurden, ist die Abbildung zwischen ihnen keine Funktion. Eine Nummer aus dem Legacy-Set taucht nur in Dateien älterer Writer auf, was sie zu einem verlässlichen Signal in eine Richtung macht. Eine andere Nummer ist wirklich mehrdeutig: Sie erscheint sowohl in korrekten Dateien als auch in einer Reihe von Zwischenversionen, die die neue Nummer mit dem alten Body-Layout benutzten

Das Framing muss daher aus dem Body entschieden werden, einmal pro Cache-Substream statt pro Record. HotXLS verriegelt den Dialekt an der Länge des ersten SXDBB-Records jedes Substreams. Im Spezifikations-Framing hält ein SXDBB exakt einen Cache-Record, seine Länge ist also eine Zeilenbreite. Im älteren gepackten Framing hält der erste Record so viele Zeilen wie hineinpassen, ist bei jedem Cache mit mehr als einer Zeile also mindestens zwei Zeilenbreiten. Der Vergleich ist entscheidend, wann immer die beiden Vorhersagen differieren

HotXLS-SXDBB-Framing-Verriegelung: Eine Record-Nummer trägt zwei inkompatible Body-Layouts, also vergleicht der Reader die Länge des ersten SXDBB-Records mit der Zeilenbreite — eine Zeilenbreite verriegelt den Spezifikationsdialekt, zwei oder mehr Zeilenbreiten das legacy-gepackte Framing, Gleichstände nehmen die Spezifikationslesung, und der Dialekt verriegelt einmal pro Cache-Substream, nicht pro Record
Record-Nummern können das Body-Layout nicht entscheiden, weil beide zu unterschiedlichen Zeiten geändert wurden, also verriegelt HotXLS den Dialekt einmal pro Substream aus der ersten SXDBB-Länge und nimmt bei Gleichstand die Spezifikationslesung

Differieren sie nicht, nimmt der Reader die Spezifikationslesung, nach dem Prinzip, dass von Excel geschriebene Dateien die von einem Zwischenbuild geschriebenen zahlenmäßig übersteigen. Dieser blinde Fleck ist von der Konstruktion her schmal, und falls er doch auftritt, spielt die Datei sich weiterhin Byte für Byte ab. Betroffen sind nur die typisierten Indizes, die den Aufrufern zugänglich sind

Die Indexbreite wohnt in einem anderen Record

SXDBB (§2.4.276) trägt einen Index pro Cache-Feld, dessen Distinct-Value-Flag gesetzt ist, in Feldordnung, und die Breite jedes Index wird woanders entschieden: Der zugehörige SXFDB-Feld-Record (§2.4.283) deklariert ein Short-Items-Flag, und dieses Flag sagt, ob der Index zwei Byte oder eines belegt. Zwei Records, ein impliziter Vertrag, und ein einziger Satz in der Spezifikation, der sie verbindet

Genau an dieser Kopplung geht eine selbstgestrickte Kodierung schief. Ein früherer HotXLS-Writer packte jedes Feld in die minimale Bitzahl und füllte zwischen Zeilen auf Bytegrenzen auf — isoliert vertretbar und im direkten Widerspruch zu der Breite, die derselbe Writer eben erst in SXFDB deklariert hatte. Ein Feld mit drei Distinct-Werten wurde in dem einen Record als ein Byte breit beschrieben und belegte in dem anderen zwei Bits. Der Fix war nicht, die Arithmetik zu korrigieren, sondern die Breitenentscheidung in eine Funktion zu ziehen, die beide Emitter aufrufen, damit die beiden Records nicht mehr auseinanderdriften können. Das ist dieselbe Defektklasse wie in BIFF-Record-Längen-Deklarationsdrift beschrieben, wo eine deklarierte Größe und ein tatsächlicher Body sich trennen

Die Konsequenz, diese Records überhaupt nicht zu lesen, verdient es, ausbuchstabiert zu werden, denn sie ist leicht zu unterschätzen. Als der Reader die Record-Indizes übersprang, meldete jeder aus einer Datei geladene Cache für jedes Feld jeder Zeile den Index null — jede Zeile zeigte also auf den ersten Wert jedes Feldes. Das ist nicht bloß reduzierte Introspektion: Sowohl der Pivot-Auswertungspfad als auch der Cache-zu-Zelle-Füllpfad konsumieren dieses Gitter. Und ein Round-Trip-Test kann das nicht entdecken, denn ein Cache, der noch auf Raw-Replay ist, wird aus seinen ursprünglichen Bytes zurückgeschrieben

// Provenance-Flags verraten, was man in der Hand hält und was umgeschrieben werden darf
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // Re-Emitting ist nur verlustfrei, wenn hier jeder Record ein Modell hat
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

Wann ist das Umschreiben eines Caches verlustfrei?

Nur wenn drei Bedingungen zusammen gelten, und CanUpgradeFraming ist die eine Eigenschaft, die die Frage beantwortet. Der Cache muss noch auf Raw-Replay sein, der Substream muss in einem der Framings stecken, die diese Bibliothek früher falsch schrieb, und der Reader muss ein vollständiges typisiertes Modell jedes Records darin gebaut haben. Ein von Excel geschriebener Cache qualifiziert sich nie, denn sein Substream trägt Records, für die HotXLS kein Modell hat, und Re-Emitting aus dem Modell würde sie fallen lassen

Der Vollständigkeitstest ist strenger, als er zunächst aussieht. Ein Record, den der Reader nur als opake Bytes hielt, markiert das Modell als unvollständig. Ebenso eine deklarierte Zahl von Formel-Records, die der Emitter nicht reproduzieren kann, denn Re-Emitting würde eine Deklaration mehrerer Formel-Records in eine Deklaration von null umschreiben, und ein Wert in der Datei, der nicht reproduzierbar ist, ist äquivalent zu einem Record, der nicht reproduzierbar ist

Bewusster Konservatismus zieht sich auch durch den Writer. Indizes werden in den legalen Bereich geklemmt, statt als Out-of-Band-Sentinel kodiert zu werden, denn die Spezifikation definiert einen Index in die Distinct-Value-Sequenz und nichts sonst, und eine leere Zelle ist selbst ein Wert in dieser Sequenz. Ein Cache-Record-Body, der die BIFF-Record-Obergrenze übersteigt, wird gar nicht geschrieben — das bräuchte tausende Cache-Felder und ist innerhalb des BIFF8-Spaltenlimits ohnehin unerreichbar; der Fallback ist, dass Excel aus dem Quellbereich refreshed, was definiertes Verhalten ist statt einer korrupten Datei

Daten tragen die letzte Record-übergreifende Abhängigkeit. Die Serial-zu-Datum-Konvertierung hängt vom Datumssystem der Arbeitsmappe ab, und der Record-Emitter kann die Arbeitsmappe nicht sehen, also wird die Basisdatum-Wahl als Parameter hereingereicht, der per Default auf das 1900er-System steht und vom Workbook-Level-Save-Pfad geliefert wird. Unter dem 1900er-System ist die Serial-Nummer direkt der Wert; das 1904er-System weicht um 1462 Tage ab. Die breitere Behandlung von Datumsserials steht in Datumsserials, das 1904er-System und Zahlenformate

Arbeiten Sie auf dem View-Layer statt auf dem Cache-Layer, sind die Records, die das sichtbare Pivot beschreiben, in dem BIFF8-PivotTable-Record-Set abgedeckt, und das Verhalten auf der Berechnungsseite in berechneten Feldern, berechneten Items und Refresh. Alle drei Layer liegen der HotXLS Delphi spreadsheet component bei, was erst möglich macht, eine Legacy-Arbeitsmappe zu laden, zu inspizieren, was ihr Cache tatsächlich enthält, und zu entscheiden, ob ein Umschreiben sicher ist, bevor man es tut