Teknisk artikel

ODS gentagne rækker som row-height runs i HotXLS til Delphi

HotXLS Delphi Component gemmer en ODS-række, der bærer table:number-rows-repeated og en rækkehøjde, som én enkelt TXLSXRowHeightRun-post — første række, sidste række, én højde — i stedet for én højdepost pr. gentagen række, og folder den tomcelle-style, rækkerne nedarver, ind i ét enkelt interval style-overlay. Det er hele grunden til, at HotXLS 2.382.2 åbner et regneark, hvis hale gentager 1.048.530 tomme rækker, på 0,02 sekunder, hvor 2.382.1 timede ud, og til at samme fil gemmer tilbage til ODS med repeat-tallet intakt frem for som en million bogstavelige rækker

Filen i spørgsmål er almindelig. LibreOffice Calc skriver et ark med fjorten kolonner og 45 datarækker og beskriver derefter alt under dem med ét element: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Stylen ro1 sætter style:row-height="0.452cm", og hver <table:table-column> bærer en table:default-cell-style-name, som hver tom celle i runet nedarver. Hele content.xml er 103 KB. Intet ved filen siger "dyr"; udgiften var udelukkende vores

Sådan forvandler HotXLS én ODS-gentaget række til kompakt tilstand: content.xml-elementet med table:number-rows-repeated 1048530 og stylen ro1 afbildes til én enkelt TXLSXRowHeightRun-post, der spænder over række 46 til 1048575 ved 12.81 pt plus én StyleOverlays-post pr. kolonne, mens version 2.382.1 ekspanderede samme element til en million SetRowHeight-poster og celleobjekter
Repeat-tallet, ro1-rækkehøjden og kolonnernes default-styles beskriver hver tom række under række 45, så importøren kan bygge én run-post og pr.-kolonne-overlays uden at røre en million koordinater

Hvorfor får én gentagen række en ODS-import til at time ud?

Fordi importøren plejede at ekspandere den. I 2.382.1 loopede row finisher'en SetRowHeight(RowIndex + i, RowHeight) én gang pr. gentagen række og skrev hver højde ind i en Name=Value-strengliste nøglet med rækkenummer. Hver indsættelse i den liste kørte et IndexOfName-opslag over alt, der allerede var i den, så en million højder kostede en million lineære scans — den kvadratiske listesøgning, HXLS-005 blev indgivet imod. Samtidig materialiserede OdsCommitRow et celleobjekt for hver kolonne, der nedarvede en style, på hver eneste af de gentagne rækker, fordi en stylet tom celle stadig talte som en celle

Save-siden havde sin egen version af problemet. LibreOffice-filen slutter med endnu en ro1-række efter den store gentagelse, så den højest stylede række lå helt i bunden af arket, og OdsBuildTableXml gik hver række op til den og emitterede <table:table-row>-elementer én ad gangen. Selv en arbejdsbog, der var blevet importeret billigt, ville være blevet skrevet dyrt. At rette importen uden at rette eksporten ville have flyttet timeouten, ikke fjernet den

Hvad er et row-height run i HotXLS?

Et run er det mindste, der kan beskrive "række 46 til og med 1.048.575 er alle 12,81 point høje" uden at sige det 1.048.530 gange. TXLSXRowHeightRun er en post af FirstRow, LastRow og Height; TXLSXRowHeightRuns er et dynamisk array af dem, og hver TXLSXWorksheet holder ét i FRowHeightRuns ved siden af den eksisterende per-række-højdeliste. Ved ODS-import forgrener row finisher'en sig nu på repeat-tallet: Et tal på 1 kalder stadig SetRowHeight, alt større kalder XlsxAssignRowHeightRun én gang for hele spændet. Spændet clamps til XlsxMaxRow, som er 1.048.576, så et repeat-tal, der skyder over arket, trunkeres frem for at blive afvist

XlsxAssignRowHeightRun er arrayets eneste writer, og den holder runsene disjunkte ved konstruktion. Givet et nyt interval kopierer den hver eksisterende run, der ligger helt uden for det, splitter enhver run, der overlapper det, op i stykket før og stykket efter og appenderer derefter det nye interval, når Present er True — eller appenderer intet, når Present er False, hvilket er sådan ClearRowHeight slår et ét-række-hul. To ting følger. Arrayet indeholder aldrig overlappende intervaller, så et opslag kan stoppe ved det første hit. Og arrayet muteres aldrig in place; en frisk kopi bygges ved hvert kald, hvilket ikke koster noget ved de størrelser, der er tale om, og fjerner en hel klasse af aliasing-bugs

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Et ark, hvis halerække gentages 1.048.530 gange under én row style
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // Begge læsninger opløses gennem samme run; intet blev ekspanderet
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // En enkelt-række-override skygger runet uden at splitte det
    Sheet.RowHeight[500000] := 36;
    // At rydde én række inde i runet skærer runet op i to stykker
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // stadig run-højden
  finally
    Workbook.Free;
  end;
end;

Opslagsrækkefølgen er den del, der er værd at huske. TXLSXWorksheet.GetRowHeight tjekker per-række-listen først og konsulterer runsene kun, når rækken ikke har nogen eksplicit post, og HasRowHeight gør det samme. Så rører Sheet.RowHeight[500000] := 36 slet ikke runet — det tilføjer én post til per-række-listen, og den post vinder, fordi den slås op først. ClearRowHeight er det modsatte: Den fjerner enhver per-række-post og kalder derefter XlsxAssignRowHeightRun med Present = False, fordi en ryddet række skal læses som "ingen højde", selv hvis et run dækker den. ClearRowHeights tømmer begge strukturer på én gang

Row-height run-kirurgi i HotXLS: Efter OpenODS dækker ét run række 46 til 1048575 ved 12.81 pt, mens en per-række-post sætter række 500000 til 36 pt og vinder opslaget, fordi GetRowHeight tjekker per-række-listen først, og ClearRowHeight af række 500001 splitter runet op i to disjunkte stykker omkring hullet
XlsxAssignRowHeightRun kopierer stykkerne uden for det ryddede interval og appenderer intet for intervallet selv, så runs forbliver disjunkte ved konstruktion, og et opslag kan stoppe ved det første hit, mens overriden ved række 500000 er urørt

Hvor bliver de nedarvede tomcelle-styles af?

Ind i ét interval style-overlay pr. kolonne, ikke ind i celleobjekter. OdsCommitRow afgør pr. kolonneværdi, om det er en kompakt tom celle: Rækken gentages mere end én gang, cellen har ingen værdi, ingen formel og ingen rich text. For en kompakt tom celle opretter den en rigtig celle kun på runets første række, anvender den nedarvede style på den og registrerer derefter de samme seks style-indekser — font, fill, border, number format, alignment, protection — som en StyleOverlays.Add, der dækker række to til og med runets slutning i den kolonne. Rækker efter den første springes helt over i materialiseringsløkken

Regressionstesten gør formen konkret. Efter at have åbnet et ark, hvis anden række gentages 1.048.575 gange under en fed kolonne-default-style, asseres Sheet.Cells.Count at være under 10, og Sheet.Cells[700000, 1].FontIndex opløses stadig til den fede font — overlayet leverer stylen i det øjeblik, koordinaten røres. Det er samme mekanisme, der forhindrer en formateret men tom kolonne i at koste en million celler på XLSX-siden; noterne om row-block cell storage og interval style overlays dækker, hvordan overlays lægger sig og opløses. Det nye her er, at ODS-importøren skaber dem på egen hånd, ud fra repeat-tallet, i stedet for at vente på, at en applikation formaterer et range

Hvordan skriver SaveAsODS repeat-tallet tilbage?

Ved at splitte arkets tomme hale kun dér, hvor noget faktisk ændrer sig. OdsBuildTableXml sporer nu to grænser: contentMaxRow, den sidste række, der holder en værdi, formel, hyperlink eller manuelt rækkeskift, og maxRow, som desuden strækker sig gennem style-only tomme celler, enkelt-række-højder, hvert runs LastRow og hvert overlays nederste kant. En style-only tom celle tæller ikke længere som indhold — TXLSXCells.IsStyleOnlyBlank er det, der udelukker den — så den afsluttende stylede række i LibreOffice-filen holder op med at slæbe indholdsgrænsen med ned i bunden af arket

Over contentMaxRow skrives rækker én ad gangen præcis som før. Under den beregner writeren nextRow som det mindste af: Næste runs FirstRow, aktuelle runs LastRow + 1, næste enkelt-række-højdepost, næste overlay-kant og næste materialiserede celle. Alt fra den aktuelle række op til nextRow - 1 emiteres derefter som én <table:table-row> med table:number-rows-repeated sat til differencen, med én <table:table-cell/> pr. kolonne med det overlay-opløste stylenavn, når et overlay dækker den kolonne. Rækkens style kommer selv fra TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), som nu folder højdeteksten — lad os sige 12.81pt — ind i sin deduplikeringsnøgle sammen med hidden- og page-break-flagene, så hver række i runet deler én ro<N>-style med en enkelt style:row-height-property

Hvad SaveAsODS skriver for et run-båret ark: contentMaxRow stopper ved række 45, hvor værdierne slutter, mens maxRow strækker sig gennem height run'et og dets overrides, rækker over grænsen skrives én ad gangen, og halen emiteres som gentagne table-row-elementer, hvis rækkestyle kommer fra RowStyleFor, og hvis cellistyles opløses gennem overlayene
Hvert gentaget element spænder over én ensartet strækning og stopper ved næste run-kant, højdepost, overlay-kant eller materialiseret celle, så et ark uden validations gemmes som en håndfuld elementer, mens validations eller en XLSX-eksport betaler pr. række
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);
    // Den tomme hale skrives som en håndfuld gentagne rækker, ikke en million
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // Override, hul og run overlever alle round trip'en
    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);   // run-højden
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

Testen, der fastlåser det, asserter at den gemte stream er under 64 KB for et ark, hvis height run spænder over 1.048.575 rækker med en override og et hul slået ind i midten. To ærlige grænser hører ved det tal. For det første sætter et regneark med data validations contentMaxRow til maxRow, så validations deaktiverer halekomprimering på det ark, og det skrives række for række igen. For det andet har XLSX ingen repeat-attribut — en SpreadsheetML <row> beskriver én række — så eksport af et run-båret ark til .xlsx enumererer de rækker, runet dækker, og skriver en ht-attribut på hver. Modellen forbliver kompakt i hukommelsen; filformatet afgør, hvordan filen ser ud

Hvad skylder hver række-omnummererende redigering nu runsene?

Vedligeholdelse. En ny repræsentation af rækkemetadata er kun korrekt, hvis hver operation, der ændrer rækkenumre, flytter den med sammen med de per-række-lister, den bor ved siden af, og commit'en rører hver af disse operationer. InsertRows og DeleteRows går gennem XlsxShiftRowHeightRuns, som genopbygger arrayet ved at beholde den del af hvert run, der ligger før redigeringspunktet, droppe alt, der falder inden for et sletningsvindue, og gen-tilføje resten forskudt med deltaet — så et run, der spænder hen over en indsættelse, bliver til to runs med et mellemrum, og et run, der spænder hen over en sletning, krymper. TileRangeAxisMetadata rydder runsene hen over hele det tiling-spænd og gen-registrerer derefter hver kildes run én gang pr. kopi på dens offset. TXLSXWorksheet.CopyFrom og TXLSXSheets.AddCopy tager en Copy() af arrayet frem for at tildele den, hvilket er grunden til, at testen kan rydde alle højder på en klon og stadig finde originalarket intakt ved række 1.048.576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Indsæt to rækker ved 500000: Overriden flytter til 500002, hullet til 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Slet dem igen: Alt forskubbes tilbage
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Tile rækker 2..4 to gange ned ad arket; run-højder følger hver kopi
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // run-højden
end;

Læsesidens grænser har samme forpligtelse. GetUsedRange skruer sin nederste kant op til hvert runs FirstRow og LastRow, og BuildRowMajorCellOrder strækker sin metadata-inklusive maksimale række gennem hvert run, så XLSX-writeren stadig besøger height-only rækker. Tilføjer du nogensinde din egen række-nøglede struktur oven på HotXLS-objektmodellen, er dette tjeklisten: insert, delete, tile, copy, used range og enhver serializer. Misser du én, er fejlen lydløs — højder driver med indsættelsestallet, og intet raiser

Hvad forbliver pr. række, og hvordan ser tallene ud nu

Hidden-flag, outline levels og collapsed state ekspanderer stadig. Row finisher'en looper SetRowHidden og SetRowOutlineLevel én gang pr. gentagen række, så et ark, der skjuler en million-række-hale, eller lægger den i en table:table-row-group, betaler en per-række-post for hver af de attributter. Ændringen i 2.382.2 er afgrænset til de to ting, HXLS-005 faktisk målte — højder og nedarvede tomme styles — og samme run-teknik ville gælde de andre, hvis en fil nogensinde krævede det. ODS-readeren gør heller ikke noget ved style:use-optimal-row-height; en rækkestyle, der siger "optimal" og giver en højde, importeres med den højde

Målt mod korpuset gennemfører conditional-formatting.ods nu open-, assert-, save-, reopen- og re-assert-cyklussen på 0,178 sekunder på Win32 og 0,158 sekunder på Win64, med selve open-steget på 0,020 sekunder, inden for et 60-sekundersbudget, det tidligere udtømte. Interface-ne på arbejdsbog-niveau, som formatet flyder gennem, er beskrevet i gennemgangen af at åbne og gemme ODS-filer, og det bredere sæt af greb til store filer i large workbook performance; selve ODF-række-elementet, med dets repeat- og style-attributter, er specificeret i ODF 1.3 Part 3 §9.1.4

HotXLS læser og skriver XLS, XLSX og ODS fra native Delphi- og C++Builder-kode uden Excel eller LibreOffice installeret, og det er derfor, en million-række-gentagelse er noget, biblioteket selv skal modellere godt frem for at lægge i hænderne på en ekstern proces — siden HotXLS Delphi spreadsheet-komponent lister de understøttede formater og RAD Studio-versioner