Technical Article

Skrivning af BIFF8 PivotTables i Delphi: SXDB og SXLI

Næsten enhver del af det ældre Excel-binære format er en enkelt record med en ren to-byte type og en to-byte længde. A cell is a LABELSST or a NUMBER. A merged region is a MERGEDCELLS. You can read most of a worksheet by walking records one at a time and dispatching on the type word. PivotTables break that rhythm. A single pivot table is not a record, it is a small program made of dozens of cooperating records spread across two different places in the same OLE compound document stream, and the relationships between them are positional, bit-packed, and unforgiving. This is the structure that most BIFF8 readers either skip entirely or preserve as opaque bytes, because writing one from scratch means reproducing every cross-reference Excel itself maintains.

Grunden til, at en pivot-tabel er svær, er, at det reelt er to artefakter svejset sammen. Der er pivot-cachen, et selvstændigt øjebliksbillede af kildedataene med sin egen understrøm, og der er tabelvisningen, det layout, der fortæller, hvilke felter der sidder på hvilken akse. Cachen og visningen refererer til hinanden med indeks. Tag fejl af ét indeks, og filen åbnes med en opdateringsfejl eller et lydløst tomt gitter.

Pivot-cachen er sin helt egen understrøm

Cachen lever i workbook globals-strømmen som en komplet BIFF-understrøm, indrammet af en BOF-record, hvis dokumenttype er 0x0006 (den værdi, der markerer en pivotcache, i modsætning til 0x0005 for projektmappen eller 0x0010 for et regneark) og lukket af den matchende EOF. Inside that frame the structure is fixed. An SXDB record is the cache header. It carries the record count, the number of cache fields, and the stream identifier that the table view will quote to bind itself to this cache. Each source column then contributes an SXFDB field-definition record followed by an SXFDBType that classifies it, and then the unique values that column took, emitted as one typed item record per distinct value.

Element-records er der, hvor cachen tjener sig hjem. A text value becomes an SXSTRING, a numeric value an SXNUM, a logical value an SXBOOLEAN, and a formula error an SXERR. The cache does not store the source grid, it stores the distinct values per field plus an index table that says, for record n, which distinct item each field took. That is why building a pivot table programmatically is not a matter of copying cells. You have to scan the source range, infer each field's type from the values it holds, deduplicate them into a typed item list, and record every row as a tuple of item indices. HotXLS does exactly this: an all-numeric column is emitted with SXNUM items, a mixed-text column becomes SXSTRING items, and dates are carried as serial values through the same numeric path.

SXDBB og den bit-pakning, der gør det interessant

Indekstabellen pr. record er den absolut mest teknisk mærkelige del af hele strukturen, og den findes i SXDBB-recorden. The naive encoding would store each field's item index as a 16-bit word. Excel does not do that. It packs each field's index into exactly the number of bits required to address that field's items, and no more. The width is ceil(log2(itemCount + 1)) bits. The + 1 matters: the extra value is a sentinel meaning "blank, no value for this field in this record", so a field with three distinct items needs to represent four states and therefore takes two bits, not the one bit that three items alone would suggest. A field with no items at all contributes zero bits and is skipped entirely during packing.

Bits for one record are concatenated across all fields, then the next record begins on a fresh byte boundary. Records are byte-aligned, not bit-packed end to end, which makes random access into the table tractable at the cost of a few padding bits per row. The packing within a byte is least-significant-bit first. Once you accept those two rules the encoder is a straightforward bit pump, and the decoder is its mirror.

// Width of one field's index in the SXDBB stream.
// citmTotal distinct items need ceil(log2(citmTotal + 1)) bits,
// the +1 reserving a "blank" sentinel value.
function BitsForFieldItems(itemCount: Integer): Integer;
var
  capacity: Integer;
begin
  Result := 0;
  if itemCount <= 0 then
    Exit;            // empty field contributes zero bits
  Result := 1;
  capacity := 2;
  while capacity < itemCount + 1 do
  begin
    Inc(Result);
    capacity := capacity * 2;
  end;
end;

Grunden til, at denne detalje ikke kan ignoreres, er loftet på 8224 bytes for en enkelt BIFF-record. Every record in the format, pivot records included, must fit its payload in at most 8224 bytes, and a busy pivot cache with thousands of source rows will blow past that long before it has emitted every row. So the index table is split. HotXLS begrænser et enkelt SXDBB-indhold til 8220 bytes, hvilket er rekordgrænsen på 8224 minus det fire-byte rekordhoved af type og længde, deler dette med byte-bredden for én pakket record for at finde ud af, hvor mange hele rækker der passer, og afgiver derefter så mange fortsatte SXDBB-records, som rækkeantallet kræver. Hver fortsættelse genstarter rent på en rekordgrænse, so no row is ever cut across two records. A reader that knows the per-record bit width can stride through every SXDBB in sequence as if they were one contiguous bit array.

Visningslayoutet: SXLI for kroppen, SXPI for siden

Sideaksen, de filterrullemenuer, der sidder over en pivot-tabel, er en separat record. SXPI (recordtype 0x00B6, [MS-XLS] §2.4.276) bærer én ti-byte indgang pr. sidefelt: pivotfeltindekset isxvd, det valgte cache-element iCache, et positionsord ipos og et ældre objekt-id objId. Værdien iCache er den, man skal holde øje med. Et sidefelt, der viser "(All)", som intet filtrerer, gemmer sentinel-værdien 0x7FFD i stedet for et reelt elementindeks. En programmatisk bygget pivot åbnes med hvert sidefelt indstillet to "(All)", indtil kalderen forhåndsvælger et element, hvorefter dette elements cache-indeks erstatter sentinel-værdien, og Excel åbner med filteret allerede anvendt. Sideløbende med disse sidder de understøttende records, der beskriver de enkelte felter og deres formatering, SXVD og SXVDEx for feltvisningsdefinitioner, SXIVD for feltindekslisterne, der ordner hver akse, og SXFormat for nummerformatering, som hver især indekserer tilbage til den samme cache, som kroppens linjer refererer til.

To skrivere i én: rå blobs og den typede model

Der is en strukturel grund til, at HotXLS beholder to fuldstændig separate stier til at skrive en pivot-tabel, og det kommer direkte fra kravene til troskab (fidelity). Når en projektmappe læses fra disken, dens pivot-records blev skrevet af Excel eller af en anden producent, og de kan bruge rekordvarianter, mærkelige sorteringer eller udvidelsesrecords, som ingen tredjepartsskriver fuldt ud modellerer. Det eneste sikre at gøre med disse bytes er at give dem uændret tilbage. Så en pivot-tabel, der kom ind fra en fil, flages FromRawBlobs = True, og ved gem genafspiller skriveren de bevarede rekord-blobs ordret. Intet genoprettes, intet genfortolkes, og en rundtursrejse gennem åbning og gemning er byte-stabil.

En pivot-tabel, som programmet har bygget, er det modsatte tilfælde. Der er ingen originale bytes at bevare, kun den typede objektmodel: en TXLSPivotCache med dens felter og elementlister, og en TXLSPivotTable med dens aksetildelinger. Denne tabel flages FromRawBlobs = False, og skriveren serialiserer den på den hårde måde ved at afgive en ny BOF = 0x0006 cacheunderstrøm, pakke SXDBB-indekstabellen ud fra de elementindekser, som den typede model indeholder, og udlægge SXLI- og SXPI-records ud fra aksekonfigurationen. Flaget er det, der lader begge typer eksistere side om side i én projektmappe. Uden det ville en enkelt skriver enten skulle kassere troskaben for indlæste tabeller eller nægte at generere nye. Eventuelle producentspecifikke udvidelsesrecords, som en indlæst tabel bar på, gemmes som supplerende records, der kan nås via tabellens SupplementalRecords-liste, så en tabel, der inspiceres via den typede model, ikke mister de dele, som modellen ikke beskriver.

Opbygning af en pivot-tabel i kode

uses
  lxHandle, lxPivot;

var
  Book : TXLSWorkbook;
  Sheet: IXLSWorkSheet;
  Pivot: TXLSPivotTable;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('Sales.xls');
    Sheet := Book.Sheets[1];

    // Source A1:E500 on 'Data'; anchor the pivot at row 3, col 1.
    Pivot := Sheet.AddPivotTable('Data!$A$1:$E$500', 3, 1, 'SalesByRegion');
    if Pivot <> nil then
    begin
      Pivot.AddRowField('Region');
      Pivot.AddColumnField('Quarter');
      Pivot.AddDataFieldByName('Revenue', xlpaSum);
    end;

    Book.SaveAs('Sales-Pivot.xls');
  finally
    Book.Free;
  end;
end;

Den første række i kildeområdet læses som overskriften, der navngiver cachefelterne, så AddRowField('Region') matcher en kolonne på dens overskriftstekst frem for på position. Fordi den returnerede tabel er en typet model med FromRawBlobs = False, skriveren tager stien fra bunden: den bygger en selvstændig cache, der ikke afhænger af, at kildeområdet stadig er til stede på opdateringstidspunktet, hvilket er netop den egenskab, du ønsker, når pivoten skal sendes til en modtager, der kan flytte eller slette de underliggende data.

Læsning og afstemning af pivot- og cacherecords i en fil, du ikke selv har produceret, inklusive stien til bevarelse af rå blobs, er dækket i gennemgangen af projektmappe-audit og konverteringsværktøjskasse. Når kildeområdet strækker sig over titusinder af rækker, og SXDBB-strømmen spænder over many continued records, the techniques in the large-workbook performance notes keep the cache build from dominating your runtime. Both pair with the pivot writer that ships in the HotXLS spreadsheet component for Delphi and C++Builder alongside the cell, formula, chart, and formatting APIs covered elsewhere on this blog.