Das HotXLS Delphi Component speichert eine ODS-Zeile mit table:number-rows-repeated und einer Zeilenhöhe als einzelnen TXLSXRowHeightRun-Datensatz – erste Zeile, letzte Zeile, eine Höhe – statt eines Höheneintrags pro wiederholter Zeile, und faltet den Leerzellen-Stil, den diese Zeilen erben, in ein einzelnes Interval-Style-Overlay. Genau deshalb öffnet HotXLS 2.382.2 eine Tabelle, deren Schwanz 1.048.530 Leerzeilen wiederholt, in 0,02 Sekunden, wo 2.382.1 in den Timeout lief, und deshalb speichert dieselbe Datei mit intaktem Repeat-Zähler zurück nach ODS statt als eine Million ausgeschriebene Zeilen
Die Datei im Beispiel ist völlig normal. LibreOffice Calc schreibt ein vierzehnspaltiges Sheet mit 45 Datenzeilen und beschreibt dann alles darunter mit einem einzigen Element: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Der Stil ro1 setzt style:row-height="0.452cm", und jedes <table:table-column> trägt einen table:default-cell-style-name, den jede Leerzelle im Run erbt. Das ganze content.xml ist 103 KB groß. Nichts an der Datei schreit „teuer“; die Teuerheit war komplett unsere eigene
Warum wirft eine einzelne wiederholte Zeile einen ODS-Import in den Timeout?
Weil der Importer sie früher aufblies. In 2.382.1 lief der Row-Finisher SetRowHeight(RowIndex + i, RowHeight) einmal pro wiederholter Zeile durch und schrieb jede Höhe in eine Name=Wert-Stringliste, verschlüsselt nach Zeilennummer. Jeder Insert in diese Liste fuhr eine IndexOfName-Suche über alles, was schon drin war, also kostete eine Million Höhen eine Million lineare Scans – die quadratische Listensuche, gegen die HXLS-005 gemeldet wurde. Zur gleichen Zeit materialisierte OdsCommitRow ein Zellobjekt für jede Spalte mit geerbtem Stil, auf jeder einzelnen der wiederholten Zeilen, denn eine formatierte Leerzelle galt trotzdem als Zelle
Die Save-Seite hatte ihre eigene Version des Problems. Die LibreOffice-Datei endet mit einer weiteren ro1-Zeile nach der großen Wiederholung, also saß die höchste formatierte Zeile ganz am unteren Rand des Sheets, und OdsBuildTableXml lief jede Zeile bis dorthin ab und gab <table:table-row>-Elemente einzeln aus. Sogar eine Mappe, die billig importiert worden wäre, hätte teuer geschrieben. Den Import zu reparieren, ohne den Export zu reparieren, hätte den Timeout verschoben, nicht beseitigt
Was ist ein Row-Height-Run in HotXLS?
Ein Run ist das kleinste Ding, das „die Zeilen 46 bis 1.048.575 sind alle 12,81 Punkt hoch“ beschreiben kann, ohne es 1.048.530 Mal zu sagen. TXLSXRowHeightRun ist ein Record aus FirstRow, LastRow und Height; TXLSXRowHeightRuns ist ein dynamisches Array daraus, und jedes TXLSXWorksheet hält eines in FRowHeightRuns neben der bestehenden Höhenliste pro Zeile. Beim ODS-Import verzweigt der Row-Finisher jetzt über den Repeat-Zähler: Eine Zählung von 1 ruft weiterhin SetRowHeight, alles Größere ruft XlsxAssignRowHeightRun einmal für den ganzen Span. Der Span wird auf XlsxMaxRow geklemmt, das bei 1.048.576 liegt, also wird ein über das Sheet hinausschießender Repeat-Zähler abgeschnitten statt zurückgewiesen
XlsxAssignRowHeightRun ist der einzige Schreiber des Arrays und hält die Runs per Konstruktion disjunkt. Bei einem neuen Intervall kopiert es jeden bestehenden Run, der komplett außerhalb liegt, spaltet jeden Run, der sich mit ihm überschneidet, in das Stück davor und das Stück danach und hängt das neue Intervall an, wenn Present true ist – oder hängt nichts an, wenn Present false ist, so sticht ClearRowHeight ein Ein-Zeilen-Loch. Zwei Dinge folgen daraus. Das Array enthält nie überlappende Intervalle, also darf eine Suche beim ersten Treffer aufhören. Und das Array wird nie in place mutiert; bei jedem Aufruf entsteht eine frische Kopie, was bei den beteiligten Größen nichts kostet und eine ganze Klasse von Aliasing-Bugs aus dem Weg räumt
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Workbook := TXLSXWorkbook.Create;
try
// Ein Sheet, dessen Schwanzzeile 1,048,530 Mal unter einem Zeilenstil wiederholt wird
Workbook.OpenODS('conditional-formatting.ods');
Sheet := Workbook.Sheets[1];
// Beide Lesezugriffe lösen sich über denselben Run; nichts wurde aufgebläht
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// Ein Ein-Zeilen-Override überdeckt den Run, ohne ihn zu spalten
Sheet.RowHeight[500000] := 36;
// Eine Zeile im Run zu löschen schneidet den Run in zwei Stücke
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // False
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // weiterhin die Run-Höhe
finally
Workbook.Free;
end;
end;
Die Lookup-Reihenfolge ist der Teil, den man sich merken sollte. TXLSXWorksheet.GetRowHeight prüft zuerst die Per-Row-Liste und fragt die Runs nur, wenn die Zeile keinen expliziten Eintrag hat, und HasRowHeight macht es genauso. Also fasst Sheet.RowHeight[500000] := 36 den Run gar nicht an – es legt einen Eintrag in der Per-Row-Liste an, und dieser Eintrag gewinnt, weil er zuerst gesucht wird. ClearRowHeight ist das Gegenteil: Es entfernt jeden Per-Row-Eintrag und ruft dann XlsxAssignRowHeightRun mit Present = False, denn eine gelöschte Zeile muss als „keine Höhe“ lesen, selbst wenn ein Run sie abdeckt. ClearRowHeights leert beide Strukturen auf einen Schlag
Wohin mit den geerbten Leerzellen-Stilen?
In je ein Interval-Style-Overlay pro Spalte, nicht in Zellobjekte. OdsCommitRow entscheidet je Spaltenwert, ob er eine kompakte Leerzelle ist: Die Zeile wiederholt sich mehr als einmal, die Zelle hat keinen Wert, keine Formel und keinen Rich-Text. Für eine kompakte Leerzelle erzeugt es eine echte Zelle nur in der ersten Zeile des Runs, wendet den geerbten Stil darauf an und registriert dann dieselben sechs Stil-Indizes – Font, Fill, Border, Zahlenformat, Ausrichtung, Schutz – als StyleOverlays.Add, der in dieser Spalte die Zeilen zwei bis zum Ende des Runs abdeckt. Die Zeilen nach der ersten überspringt die Materialisierungsschleife komplett
Das Regressionstest-Muster macht die Gestalt konkret. Nach dem Öffnen eines Sheets, dessen zweite Zeile 1.048.575 Mal unter einem fetten Spalten-Default-Stil wiederholt wird, wird behauptet, dass Sheet.Cells.Count unter 10 bleibt, und Sheet.Cells[700000, 1].FontIndex löst sich weiterhin auf den fetten Font auf – das Overlay liefert den Stil in dem Moment, in dem die Koordinate angefasst wird. Das ist derselbe Mechanismus, der eine formatierte, aber leere Spalte auf der XLSX-Seite von einer Million Zellen verschont; die Notizen zur Row-Block-Zellspeicherung und den Interval-Style-Overlays behandeln, wie Overlays schichten und sich auflösen. Neu ist hier, dass der ODS-Importer sie selbstständig aus dem Repeat-Zähler erzeugt, statt darauf zu warten, dass eine Anwendung einen Bereich formatiert
Wie schreibt SaveAsODS den Repeat-Zähler zurück?
Indem es den leeren Schwanz des Sheets nur dort teilt, wo sich tatsächlich etwas ändert. OdsBuildTableXml verfolgt jetzt zwei Grenzen: contentMaxRow, die letzte Zeile mit Wert, Formel, Hyperlink oder manuellem Zeilenumbruch, und maxRow, das zusätzlich durch nur-formatierte Leerzellen, Ein-Zeilen-Höhen, das LastRow jedes Runs und den unteren Rand jedes Overlays reicht. Eine nur-formatierte Leerzelle zählt nicht mehr als Inhalt – TXLSXCells.IsStyleOnlyBlank ist der Ausschluss – also hört die formatierte Schlusszeile in der LibreOffice-Datei auf, die Inhalts-Grenze an den unteren Rand des Sheets zu ziehen
Oberhalb von contentMaxRow werden Zeilen weiterhin einzeln geschrieben, genau wie bisher. Unterhalb davon berechnet der Writer nextRow als das Minimum aus: dem FirstRow des nächsten Runs, dem LastRow + 1 des aktuellen Runs, dem nächsten Ein-Zeilen-Höheneintrag, dem nächsten Overlay-Rand und der nächsten materialisierten Zelle. Alles von der aktuellen Zeile bis nextRow - 1 wird dann als ein <table:table-row> mit auf die Differenz gesetztem table:number-rows-repeated ausgegeben, mit einem <table:table-cell/> pro Spalte und dem per Overlay aufgelösten Stilnamen, wenn ein Overlay diese Spalte abdeckt. Der Zeilenstil selbst kommt aus TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), das jetzt den Höhentext – etwa 12.81pt – neben den Hidden- und Seitenumbruch-Flags in seinen Deduplizierungs-Schlüssel faltet, sodass jede Zeile im Run sich einen ro<N>-Stil mit einer einzigen style:row-height-Eigenschaft teilt
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);
// Der leere Schwanz wird als Handvoll wiederholter Zeilen geschrieben, nicht als Million
Workbook.SaveAsODS(Saved);
Writeln('ODS size: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.Open(Saved);
// Override, Loch und Run überleben alle den 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); // Run-Höhe
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
Der Test, der das festnagelt, behauptet, dass der gespeicherte Stream unter 64 KB bleibt für ein Sheet, dessen Höhen-Run 1.048.575 Zeilen spannt, mit einem Override und einem in die Mitte geschlagenen Loch. Zwei ehrliche Grenzen gehören neben diese Zahl. Erstens setzt ein Worksheet mit irgendwelchen Datenvalidierungen contentMaxRow auf maxRow, also schalten Validierungen die Schwanz-Komprimierung auf diesem Sheet ab und es wird wieder Zeile für Zeile geschrieben. Zweitens hat XLSX kein Repeat-Attribut – ein SpreadsheetML-<row> beschreibt eine Zeile – also zählt der Export eines Run-getragenen Sheets nach .xlsx die Zeilen auf, die der Run abdeckt, und schreibt auf jede ein ht-Attribut. Das Modell bleibt im Speicher kompakt; das Dateiformat entscheidet, wie die Datei aussieht
Was schuldet jede zeilennummerndernde Änderung jetzt den Runs?
Wartung. Eine neue Repräsentation von Zeilen-Metadaten ist nur korrekt, wenn jede Operation, die Zeilennummern ändert, sie zusammen mit den Per-Row-Listen verschiebt, neben denen sie liegt, und der Commit fasst jede dieser Operationen an. InsertRows und DeleteRows laufen über XlsxShiftRowHeightRuns, das das Array neu baut, indem es den Teil jedes Runs behält, der vor dem Bearbeitungspunkt liegt, alles fallen lässt, was in ein Löschfenster fällt, und den Rest verschoben um das Delta wieder anfügt – ein Run, der eine Einfügung überspannt, wird so zu zwei Runs mit einer Lücke, und ein Run, der eine Löschung überspannt, schrumpft. TileRangeAxisMetadata räumt die Runs über die ganze geteilte Spanne weg und registriert dann jeden Quell-Run einmal pro Kopie an seinem Offset. TXLSXWorksheet.CopyFrom und TXLSXSheets.AddCopy nehmen ein Copy() des Arrays statt es zuzuweisen, weshalb der Test alle Höhen auf einem Klon löschen kann und das Original-Sheet trotzdem unversehrt bis Zeile 1.048.576 vorfindet
var
Sheet: TXLSXWorksheet;
begin
Sheet := Workbook.Sheets[1];
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// Zwei Zeilen bei 500000 einfügen: der Override wandert zu 500002, das Loch zu 500003
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // False
// Wieder löschen: alles rückt zurück
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
// Zeilen 2..4 zweimal nach unten kacheln; die Run-Höhen folgen jeder Kopie
Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
Writeln(Sheet.RowHeight[7]:0:2); // die Run-Höhe
end;
Die lese seitigen Grenzen haben dieselbe Pflicht. GetUsedRange hebt seinen unteren Rand auf das FirstRow und LastRow jedes Runs, und BuildRowMajorCellOrder zieht seine Metadaten-einschließende Maximalzeile durch jeden Run, damit der XLSX-Writer nur-Höhen-Zeilen weiterhin besucht. Wenn Sie irgendwann eine eigene Struktur mit Zeilenschlüssel oben auf dem HotXLS-Objektmodell ergänzen, ist das die Checkliste: Insert, Delete, Tile, Copy, Used Range und jeder Serializer. Einen vergessen, und der Fehler ist stumm – die Höhen driften um die Insert-Anzahl, und nichts wirft eine Exception
Was per Zeile bleibt und wie die Zahlen jetzt aussehen
Hidden-Flags, Outline-Level und Collapsed-Zustand blähen weiterhin auf. Der Row-Finisher durchläuft SetRowHidden und SetRowOutlineLevel einmal pro wiederholter Zeile, also zahlt ein Sheet, das einen Million-Zeilen-Schwanz versteckt oder ihn in eine table:table-row-group verschachtelt, einen Per-Row-Eintrag für jedes dieser Attribute. Die 2.382.2-Änderung ist auf die zwei Dinge begrenzt, die HXLS-005 tatsächlich gemessen hat – Höhen und geerbte Leerzellen-Stile –, und dieselbe Run-Technik ließe sich auf die anderen anwenden, sollte eine Datei es je verlangen. Der ODS-Reader reagiert auch nicht auf style:use-optimal-row-height; ein Zeilenstil, der „optimal“ sagt und eine Höhe angibt, wird mit dieser Höhe importiert
Im Korpus schafft conditional-formatting.ods den Zyklus aus Öffnen, Asserten, Speichern, Wiederöffnen und erneuten Asserten jetzt in 0,178 Sekunden auf Win32 und 0,158 Sekunden auf Win64, wobei die Öffnungs-Phase selbst bei 0,020 Sekunden liegt – in einem 60-Sekunden-Budget, das es vorher aufgebraucht hatte. Die Arbeitsmappen-Interfaces, durch die das Format fließt, beschreibt der Rundgang zum Öffnen und Speichern von ODS-Dateien, das breitere Set an Hebeln für große Dateien der Artikel zur Performance großer Arbeitsmappen; das ODF-Zeilenelement selbst mit seinen Repeat- und Stil-Attributen ist in ODF 1.3 Part 3 §9.1.4 spezifiziert
HotXLS liest und schreibt XLS, XLSX und ODS aus nativem Delphi- und C++Builder-Code ohne installiertes Excel oder LibreOffice, weshalb eine Million-Zeilen-Wiederholung etwas ist, das die Bibliothek gut modellieren muss, statt es an einen externen Prozess zu verfüttern – die HotXLS Delphi Tabellenkomponente-Seite listet die unterstützten Formate und RAD-Studio-Versionen