Technisch artikel

BIFF PivotCache-records afbakenen met HotXLS in Delphi

De BIFF PivotCache substream bewaart de gecachte dataset van een PivotTable los van de view die hem toont, en HotXLS leest en schrijft die substream door recordbodies te inspecteren in plaats van op recordnummers te vertrouwen. Dat onderscheid is het hele verhaal: hetzelfde recordnummer draagt twee onverenigbare bodylayouts afhankelijk van welke writer de file produceerde, dus de reader beslist de framing op basis van de eerste recordbody die hij ziet

U komt deze laag tegen op het moment dat een PivotTable een round trip moet overleven. Een pivot view zonder zijn cache is een lege schil, en Excel bouwt de cache opnieuw uit het source range zodra hij de file opent, wat prima gaat totdat het source range weg is, de data uit een query is geplakt, of de workbook een gearchiveerde afsluiting is die niet mag veranderen zodra iemand hem opent

Twee structuren, twee plekken in de file

De gecachte data en de cachedefinitie wonen in verschillende delen van de workbook, en ze door elkaar halen is het eerste wat goed moet zitten. De gecachte records vormen hun eigen substream, in [MS-XLS] §2.1.7.12 gegeven als PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Merk op wat ontbreekt: er staat geen BOF aan het hoofd van die productie

De definitie zit in plaats daarvan in de workbook globals, als PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), gepositioneerd na de opmaakrecords en vóór de BoundSheet- en Country-records. Eén enkele cache wordt dus op twee plekken beschreven die honderden records uit elkaar liggen, en de schakel tussen hen is een stream-identificator die op drie plekken tegelijk moet kloppen

HotXLS PivotCache-framing in BIFF8: de PIVOTCACHEDEFINITION met zijn SXStreamID zit in de workbook globals na de opmaak en vóór BoundSheet, terwijl de gecachte records in een stream onder de _SX_DB_CUR-storage wonen met een naam in viercijferige hoofdletter-hex en SXDB-, SXDBEx-, SXFORMULA-, FDB- en DBB-records bevatten zonder BOF, en SXStreamID.idStm, het SXDB-idstm-veld en de streamnaam moeten kloppen
Eén enkele pivot cache wordt op twee plekken beschreven die honderden records uit elkaar liggen, verbonden door een stream-identificator die in de globals, de SXDB-header en de substreamnaam tegelijk moet kloppen

Elke cache hoort in een stream onder _SX_DB_CUR waarvan de naam de viercijferige hoofdletter-hexadecimale spelling van zijn identificator is. SXStreamID.idStm, het idstm-veld dat in de SXDB-header herhaald wordt, en die streamnaam moeten alle drie matchen. Wijs u een nieuwe identificator toe, reserveer dan eerst elk nummer dat al uit de file is gelezen, anders kan een nieuwe cache een nummer claimen dat toebehoort aan een oudere cache waar de reader nog niet is geweest

Nog één identificator vangt mensen. De iCache-waarde in een pivot view is de nulgebaseerde positie van de bijbehorende SXStreamID in de globale reeks, geen cache-identificator die u zelf mag kiezen. Bij het schrijven moet hij van het cache-object naar zijn werkelijke outputpositie worden gemapped, en bestaande views moeten mee worden hernummerd, anders wijst het upgraden van één cache stilletjes een view naar een andere

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;     // vertrouw op de gecachte records
    Cache.SaveData := True;

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

    Cache.SetRecordCount(0);          // wissen, daarna de recordgrid vullen
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

De dubbele SetRecordCount is geen bijgeloof. RecordCount is een platte property-write die niets alloceert, en het interne groeipad initialiseert alleen de nieuw toegevoegde rijen, dus een cache waarvan de telling via het headerpad is gezet kan stranden met een indexgrid van lengte nul. Schrijfacties naar RecordIndices worden dan foutloos weggegooid. De telling op nul zetten en terug herstelt het grid, en dat moet gebeuren nadat elk veld is toegevoegd, want de rijbreedte komt uit het veldaantal

Waarom kan een recordnummer u niet de bodylayout vertellen?

Omdat recordnummers en bodylayouts op verschillende momenten veranderden, is de mapping tussen hen geen functie. Eén nummer in de legacy-set verschijnt uitsluitend in files van oudere writers, wat hem een betrouwbaar signaal in één richting maakt. Een ander nummer is echt dubbelzinnig: hij verschijnt zowel in correcte files als in een reeks tussenliggende versies die het nieuwe nummer met de oude bodylayout gebruikten

De framing moet daarom uit de body worden besloten, en één keer per cache substream in plaats van per record. HotXLS vergrendelt het dialect aan de lengte van de eerste SXDBB-record in elke substream. In de specificatieframing bevat één SXDBB precies één cache record, dus zijn lengte is gelijk aan één rijbreedte. In de oudere gepakte framing bevat de eerste record zoveel rijen als passen, dus voor elke cache met meer dan één rij is hij ten minste twee rijbreedtes. De vergelijking is doorslaggevend telkens wanneer de twee voorspellingen verschillen

HotXLS SXDBB framing-latch: één recordnummer draagt twee onverenigbare bodylayouts, dus de reader vergelijkt de lengte van de eerste SXDBB-record met de rijbreedte, één rijbreedte vergrendelt het specificatiedialect terwijl twee of meer rijbreedtes de legacy gepakte framing vergrendelen, bij gelijke stand wint de specificatielezing, en het dialect vergrendelt één keer per cache substream, niet per record
Recordnummers kunnen de bodylayout niet beslissen omdat de twee op verschillende momenten veranderden, dus HotXLS vergrendelt het dialect één keer per substream vanuit de eerste SXDBB-lengte en neemt bij gelijke stand de specificatielezing

Wanneer ze niet verschillen, neemt de reader de specificatielezing, op het principe dat files geschreven door Excel talrijker zijn dan files geschreven door een tussenliggende build. Dat blinde vlekje is van constructie smal en als het zich voordoet, speelt de file zelf nog steeds byte voor byte af. Alleen de getypeerde indices die aan aanroepers worden blootgesteld, zijn getroffen

De indexbreedte woont in een andere record

SXDBB (§2.4.276) draagt één index per cacheveld waarvan de distinct-value-flag is gezet, in veldvolgorde, en de breedte van elke index wordt elders beslist: de bijbehorende SXFDB-veldrecord (§2.4.283) declareert een short-items-flag, en die flag zegt of de index twee bytes of één beslaat. Twee records, één impliciet contract, en één enkele zin in de specificatie die ze verbindt

Die koppeling is precies waar een zelfgebouwde codering de fout in gaat. Een eerdere HotXLS-writer pakte elk veld in het minimale aantal bits, aangevuld tot een bytegrens tussen rijen, wat op zichzelf te verdedigen is en direct ingaat tegen de breedte die dezelfde writer net daarvoor in SXFDB had gedeclareerd. Een veld met drie verschillende waarden werd in de ene record als één byte breed beschreven en besloeg in de andere twee bits. De fix was niet de rekenkunde corrigeren, maar de breedtebeslissing extraheren naar één functie die beide emitters aanroepen, zodat de twee records niet meer uit elkaar kunnen drijven. Dat is dezelfde defectklasse als beschreven in BIFF record length declaration drift, waar een gedeclareerde grootte en een werkelijke body uit elkaar gaan

Het gevolg van deze records helemaal niet lezen is het uitspreken waard, want het is makkelijk te onderschatten. Toen de reader de recordindices oversloeg, meldde elke cache uit een file index nul voor elk veld van elke rij, wat betekent dat elke rij naar de eerste waarde van elk veld wees. Dat is niet slechts verminderde introspectie: de pivot-evaluatieroute en de cache-naar-cel-vulroute verbruiken beide dat grid. En een round-trip-test kan het niet detecteren, want een cache die nog op rauwe replay staat wordt teruggeschreven vanuit zijn originele bytes

// Provenance-flags vertellen u wat u in handen heeft en wat herschreven mag worden
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);
  // Opnieuw emitteren is alleen verliesvrij als elke record hier een model heeft
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

Wanneer is het herschrijven van een cache verliesvrij?

Alleen als drie voorwaarden tegelijk gelden, en CanUpgradeFraming is de enige property die de vraag beantwoordt. De cache moet nog op rauwe replay staan, de substream moet in een van de framings zitten die deze library vroeger foutief schreef, en de reader moet een volledig getypeerd model van elke record erin hebben gebouwd. Een cache die Excel schreef komt nooit in aanmerking, want zijn substream bevat records waar HotXLS geen model voor heeft, en opnieuw emitteren vanuit het model zou ze laten vallen

De volledigheidstest is strenger dan hij eerst lijkt. Een record die de reader alleen als opake bytes bewaarde, markeert het model als incompleet. Een gedeclareerde telling van formula records die de emitter niet kan reproduceren evenzo, want opnieuw emitteren zou een declaratie van meerdere formula records herschrijven als een declaratie van nul, en een waarde in de file die niet reproduceerbaar is komt aan een record gelijk die niet reproduceerbaar is

Bewust conservatisme loopt ook door de writer heen. Indices worden afgeklemd tot het legale bereik in plaats van als out-of-band sentinel gecodeerd, want de specificatie definieert een index in de distinct-value-reeks en niets anders, en een lege cel is zelf een waarde in die reeks. Een cache record body die het BIFF-recordplafond overschrijdt wordt helemaal niet geschreven, wat duizenden cachevelden zou vereisen en binnen de BIFF8-kolomlimiet sowieso onbereikbaar is; de fallback is dat Excel uit het source range ververst, wat gedefinieerd gedrag is in plaats van een corrupte file

Data dragen de laatste cross-record-afhankelijkheid. De serial-naar-datum-conversie hangt af van het workbook-datumsysteem, en de record-emitter kan de workbook niet zien, dus de basisdatumkeuze wordt als parameter meegegeven die standaard op het 1900-systeem staat en door het savepad op workbookniveau wordt geleverd. Onder het 1900-systeem is het serienummer direct de waarde; het 1904-systeem verschilt 1462 dagen. De bredere behandeling van datumserials staat in datumserials, het 1904-systeem en getalnotaties

Werkt u op de viewlaag in plaats van op de cachelaag, dan worden de records die de zichtbare pivot beschrijven behandeld in de BIFF8 PivotTable record set, en het gedrag aan rekenkant in calculated fields, calculated items en refresh. Alle drie de lagen worden geleverd in de HotXLS Delphi spreadsheet component, en dat is wat het mogelijk maakt om een legacy workbook te laden, te inspecteren wat zijn cache werkelijk bevat en te besluiten of herschrijven veilig is voordat u het doet