Technisch artikel

Herhaalde ODS-rijen als rijhoogte-runs in Delphi

De HotXLS Delphi Component bewaart een ODS-rij met table:number-rows-repeated en een rijhoogte als één record TXLSXRowHeightRun — eerste rij, laatste rij, één hoogte — in plaats van één hoogte-item per herhaalde rij, en vouwt de stijl van lege cellen die die rijen erven samen in één interval-stijloverlay. Dat is precies waarom HotXLS 2.382.2 een spreadsheet waarvan de staart 1,048,530 lege rijen herhaalt in 0.02 seconden opent waar 2.382.1 een timeout kreeg, en waarom hetzelfde bestand weer naar ODS opslaat met de herhalingsteller intact in plaats van als een miljoen letterlijke rijen

Het bestand in kwestie is heel gewoon. LibreOffice Calc schrijft een blad met veertien kolommen en 45 rijen data, en beschrijft daarna alles daaronder met één element: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Stijl ro1 zet style:row-height="0.452cm", en elke <table:table-column> draagt een table:default-cell-style-name die elke lege cel in de reeks erft. De hele content.xml is 103 KB. Niets aan het bestand zegt "duur"; de kosten waren volledig de onze

Hoe HotXLS één herhaalde ODS-rij in compacte staat omzet: het content.xml-element met table:number-rows-repeated 1048530 en stijl ro1 verwijst naar één record TXLSXRowHeightRun dat rijen 46 tot 1048575 op 12.81 pt omspant plus één StyleOverlays-item per kolom, terwijl versie 2.382.1 datzelfde element uitklapte naar een miljoen SetRowHeight-items en celobjecten
De herhalingsteller, de ro1-rijhoogte en de kolomstandaardstijlen beschrijven elke lege rij onder rij 45, dus de importer kan één runrecord en overlays per kolom bouwen zonder een miljoen coördinaten aan te raken

Waarom geeft één herhaalde rij een timeout bij een ODS-import?

Omdat de importer hem vroeger uitklapte. In 2.382.1 liet de rij-afronder SetRowHeight(RowIndex + i, RowHeight) één keer per herhaalde rij lopen, en schreef elke hoogte in een Name=Value-stringlijst met het rijnummer als sleutel. Elke invoeging in die lijst deed een IndexOfName-opzoeking over alles wat er al in stond, dus een miljoen hoogtes kostten een miljoen lineaire scans — het kwadratische lijstzoeken waar HXLS-005 tegen was geregistreerd. Tegelijk materialiseerde OdsCommitRow een celobject voor elke kolom die een stijl erfde, op elke herhaalde rij, omdat een lege cel met stijl nog steeds als cel telde

De opslagkant had zijn eigen versie van het probleem. Het LibreOffice-bestand eindigt na de grote herhaling met nog één ro1-rij, dus de hoogst gestijlde rij zat helemaal onderaan het blad, en OdsBuildTableXml liep elke rij tot daar langs om <table:table-row>-elementen één voor één uit te schrijven. Zelfs een workbook die goedkoop was geïmporteerd zou duur zijn weggeschreven. De import fixen zonder de export te fixen zou de timeout hebben verplaatst, niet weggenomen

Wat is een rijhoogte-run in HotXLS?

Een run is het kleinste ding dat "rijen 46 tot en met 1,048,575 zijn allemaal 12.81 punten hoog" kan beschrijven zonder dat 1,048,530 keer te zeggen. TXLSXRowHeightRun is een record met FirstRow, LastRow en Height; TXLSXRowHeightRuns is een dynamische array daarvan, en elke TXLSXWorksheet houdt er één bij in FRowHeightRuns, naast de bestaande hoogtelijst per rij. Bij ODS-import vertakt de rij-afronder nu op de herhalingsteller: een teller van 1 roept nog steeds SetRowHeight aan, alles groter roept één keer XlsxAssignRowHeightRun aan voor de hele spanne. De spanne wordt afgekapt op XlsxMaxRow, dat 1,048,576 is, dus een herhalingsteller die over het blad heen schiet wordt afgekapt in plaats van geweigerd

XlsxAssignRowHeightRun is de enige schrijver van de array, en hij houdt de runs door zijn constructie disjunct. Krijgt hij een nieuw interval, dan kopieert hij elke bestaande run die er volledig buiten ligt, splitst hij elke run die eroverheen valt in het stuk ervoor en het stuk erna, en voegt hij daarna het nieuwe interval toe wanneer Present true is — of voegt hij niets toe wanneer Present false is, en zo slaat ClearRowHeight een gat van één rij. Twee dingen volgen daaruit. De array bevat nooit overlappende intervallen, dus een opzoeking kan bij de eerste treffer stoppen. En de array wordt nooit ter plekke gemuteerd; bij elke aanroep wordt een verse kopie gebouwd, wat op deze groottes niets kost en een hele klasse aliasing-bugs wegneemt

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Een blad waarvan de staartrij 1,048,530 keer herhaalt onder één rijstijl
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // Beide uitlezingen lossen op via dezelfde run; er is niets uitgeklapt
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Een override van één rij overschaduwt de run zonder hem te splitsen
    Sheet.RowHeight[500000] := 36;
    // Eén rij binnen de run wissen knipt de run in twee stukken
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // nog steeds de runhoogte
  finally
    Workbook.Free;
  end;
end;

De opzoekvolgorde is het deel dat het onthouden waard is. TXLSXWorksheet.GetRowHeight kijkt eerst in de lijst per rij en raadpleegt de runs alleen wanneer de rij geen expliciet item heeft, en HasRowHeight doet hetzelfde. Dus Sheet.RowHeight[500000] := 36 raakt de run helemaal niet — het voegt één item toe aan de lijst per rij, en dat item wint omdat het eerst wordt opgezocht. ClearRowHeight is het omgekeerde: het verwijdert een eventueel item per rij en roept dan XlsxAssignRowHeightRun aan met Present = False, want een gewiste rij moet als "geen hoogte" lezen, ook als een run hem dekt. ClearRowHeights maakt beide structuren in één keer leeg

Rijhoogte-run-chirurgie in HotXLS: na OpenODS dekt één run rijen 46 tot 1048575 op 12.81 pt terwijl een item per rij rij 500000 op 36 pt zet en de opzoeking wint omdat GetRowHeight eerst de lijst per rij bekijkt, en ClearRowHeight van rij 500001 splitst de run in twee disjuncte stukken rond het gat
XlsxAssignRowHeightRun kopieert de stukken buiten het gewiste interval en voegt voor het interval zelf niets toe, dus runs blijven door hun constructie disjunct en een opzoeking kan bij de eerste treffer stoppen terwijl de override op rij 500000 onaangeroerd blijft

Waar blijven de geërfde stijlen van lege cellen?

In één interval-stijloverlay per kolom, niet in celobjecten. OdsCommitRow beslist per celwaarde of het een compacte lege cel is: de rij herhaalt meer dan één keer, en de cel heeft geen waarde, geen formule en geen rich text. Voor een compacte leegte maakt hij alleen op de eerste rij van de run een echte cel, past daarop de geërfde stijl toe, en registreert daarna dezelfde zes stijlindexen — font, vulling, rand, getalnotatie, uitlijning, beveiliging — als een StyleOverlays.Add die in die kolom de rijen twee tot het eind van de run dekt. Rijen na de eerste worden in de materialisatielus volledig overgeslagen

De regressietest maakt de vorm concreet. Na het openen van een blad waarvan de tweede rij 1,048,575 keer herhaalt onder een vette kolomstandaardstijl, wordt gecontroleerd dat Sheet.Cells.Count onder 10 blijft, en dat Sheet.Cells[700000, 1].FontIndex nog steeds naar het vette font verwijst — de overlay levert de stijl op het moment dat die coördinaat wordt aangeraakt. Dat is hetzelfde mechanisme dat een opgemaakte maar lege kolom op de XLSX-kant geen miljoen cellen laat kosten; de notities over rijblokcelopslag en interval-stijloverlays behandelen hoe overlays stapelen en oplossen. Wat hier nieuw is, is dat de ODS-importer ze zelf aanmaakt, vanuit de herhalingsteller, in plaats van te wachten tot een applicatie een bereik opmaakt

Hoe schrijft SaveAsODS de herhalingsteller terug?

Door de lege staart van het blad alleen te splitsen waar echt iets verandert. OdsBuildTableXml houdt nu twee grenzen bij: contentMaxRow, de laatste rij met een waarde, formule, hyperlink of handmatige rij-einde, en maxRow, die ook doorloopt over alleen-gestijlde lege cellen, hoogtes van één rij, de LastRow van elke run en de onderrand van elke overlay. Een alleen-gestijlde lege cel telt niet langer als inhoud — TXLSXCells.IsStyleOnlyBlank sluit hem uit — dus de gestijlde staartrij in het LibreOffice-bestand sleept de inhoudgrens niet meer naar de onderkant van het blad

Boven contentMaxRow worden rijen één voor één geschreven, precies zoals voorheen. Daaronder berekent de schrijver nextRow als de kleinste van: de FirstRow van de volgende run, de LastRow + 1 van de huidige run, het volgende hoogte-item van één rij, de volgende overlayrand, en de volgende gematerialiseerde cel. Alles van de huidige rij tot nextRow - 1 wordt dan uitgeschreven als één <table:table-row> met table:number-rows-repeated op het verschil, met één <table:table-cell/> per kolom met de via de overlay opgeloste stijlnaam wanneer een overlay die kolom dekt. De rijstijl zelf komt van TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), die de hoogtetekst — 12.81pt, zeg maar — nu in zijn deduplicatiesleutel vouwt naast de flags voor verbergen en pagina-einde, zodat elke rij in de run één ro<N>-stijl deelt met één eigenschap style:row-height

Wat SaveAsODS schrijft voor een blad met runs: contentMaxRow stopt bij rij 45 waar de waarden eindigen terwijl maxRow doorloopt over de hoogte-run en zijn overrides, rijen boven de grens worden één voor één geschreven, en de staart gaat eruit als herhaalde table-row-elementen waarvan de rijstijl van RowStyleFor komt en de celstijlen via de overlays oplossen
Elk herhaald element omspant één uniform stuk en stopt bij de volgende runrand, het volgende hoogte-item, de volgende overlayrand of de volgende gematerialiseerde cel, dus een blad zonder validaties slaat op als een handvol elementen terwijl validaties of een XLSX-export per rij betalen
var
  Workbook, Reopened: TXLSXWorkbook;
  Saved: TMemoryStream;
begin
  Workbook := TXLSXWorkbook.Create;
  Reopened := TXLSXWorkbook.Create;
  Saved := TMemoryStream.Create;
  try
    Workbook.OpenODS('conditional-formatting.ods');
    Workbook.Sheets[1].RowHeight[500000] := 36;
    Workbook.Sheets[1].ClearRowHeight(500001);
    // De lege staart wordt als een handvol herhaalde rijen geschreven, niet als een miljoen
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // Override, gat en run overleven alle drie de round trip
    Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2);   // 36.00
    Writeln(Reopened.Sheets[1].HasRowHeight(500001));    // False
    Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2);   // runhoogte
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

De test die dit vastpint, controleert dat de opgeslagen stream onder 64 KB blijft voor een blad waarvan de hoogte-run 1,048,575 rijen omspant met een override en een gat erin geslagen. Naast dat getal horen twee eerlijke grenzen. Ten eerste zet een werkblad met welke data-validatie dan ook contentMaxRow op maxRow, dus validaties schakelen de staartcompressie op dat blad uit en het wordt weer rij voor rij geschreven. Ten tweede heeft XLSX geen herhalingsattribuut — een SpreadsheetML-<row> beschrijft één rij — dus een blad met runs naar .xlsx exporteren somt de rijen op die de run dekt en schrijft op elk een ht-attribuut. Het model blijft compact in het geheugen; het bestandsformaat bepaalt hoe het bestand eruitziet

Wat is elke bewerking die rijnummers verschuift nu verplicht aan de runs?

Onderhoud. Een nieuwe representatie van rijmetadata is alleen correct als elke bewerking die rijnummers verandert hem meebeweegt met de lijsten per rij waar hij naast staat, en de commit raakt elk van die bewerkingen aan. InsertRows en DeleteRows lopen via XlsxShiftRowHeightRuns, die de array opnieuw opbouwt door van elke run het deel te houden dat vóór het bewerkingspunt ligt, weg te laten wat binnen een verwijderingsvenster valt, en de rest verschoven met het delta opnieuw toe te voegen — dus een run die een invoeging overspant wordt twee runs met een gat, en een run die een verwijdering overspant krimpt. TileRangeAxisMetadata wist de runs over de hele getegelde spanne en registreert daarna elke bronrun één keer per kopie op zijn offset. TXLSXWorksheet.CopyFrom en TXLSXSheets.AddCopy nemen een Copy() van de array in plaats van hem toe te wijzen, en daarom kan de test alle hoogtes op een kloon wissen en het originele blad op rij 1,048,576 nog intact vinden

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Voeg twee rijen in op 500000: de override schuift naar 500002, het gat naar 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Verwijder ze weer: alles schuift terug
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Tegel rijen 2..4 twee keer naar beneden; runhoogtes volgen elke kopie
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // de runhoogte
end;

De grenzen aan de leeskant hebben dezelfde verplichting. GetUsedRange verhoogt zijn onderrand naar de FirstRow en LastRow van elke run, en BuildRowMajorCellOrder rekt zijn metadata-inclusieve maximumrij op over elke run, zodat de XLSX-writer ook rijen bezoekt die alleen hoogte hebben. Als je ooit je eigen op rijen gebaseerde structuur bovenop het HotXLS-objectmodel zet, is dit de checklist: invoegen, verwijderen, tegelen, kopiëren, gebruikt bereik, en elke serializer. Mis je er één, dan is de fout stil — hoogtes schuiven mee met het aantal invoegingen, en er wordt niets gegooid

Wat blijft per rij, en hoe zien de cijfers er nu uit

Verborgen flags, overzichtsniveaus en samengevouwen toestand klappen nog steeds uit. De rij-afronder loopt SetRowHidden en SetRowOutlineLevel één keer per herhaalde rij langs, dus een blad dat een staart van een miljoen rijen verbergt, of die in een table:table-row-group nestelt, betaalt een item per rij voor elk van die attributen. De wijziging in 2.382.2 is beperkt tot de twee dingen die HXLS-005 werkelijk heeft gemeten — hoogtes en geërfde lege stijlen — en dezelfde runtechniek zou op de andere van toepassing zijn als een bestand dat ooit zou eisen. De ODS-reader doet ook niets met style:use-optimal-row-height; een rijstijl die "optimaal" zegt en een hoogte geeft, wordt met die hoogte geïmporteerd

Tegen het corpus rondt conditional-formatting.ods de cyclus openen, controleren, opslaan, heropenen en opnieuw controleren nu af in 0.178 seconden op Win32 en 0.158 seconden op Win64, waarbij de openingsfase zelf 0.020 seconden kost, binnen een budget van 60 seconden dat eerder werd uitgeput. De interfaces op workbookniveau waar het formaat doorheen loopt, staan in de walkthrough van ODS-bestanden openen en opslaan, en de bredere set knoppen voor grote bestanden in prestaties van grote workbooks; het ODF-rij-element zelf, met zijn herhalings- en stijlattributen, staat gespecificeerd in ODF 1.3 Part 3 §9.1.4

HotXLS leest en schrijft XLS, XLSX en ODS vanuit native Delphi- en C++Builder-code zonder geïnstalleerde Excel of LibreOffice, en daarom is een herhaling van een miljoen rijen iets wat de library goed moet modelleren in plaats van uit te besteden aan een extern proces — de HotXLS Delphi spreadsheet component-pagina somt de ondersteunde formaten en RAD Studio-versies op