Eine vierzehnspaltige Tabelle mit 45 Datenzeilen, als ODS gespeichert, endet meistens mit einem Element mehr, als man erwartet: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Diese eine Zeile beschreibt jede Zeile von 46 bis zum unteren Rand des Gitters. Wörtlich ausgeschrieben, als diese Zeile 1.048.530 Mal notiert, wird aus einem kleinen content.xml eine Million Zeilen-Datensätze, eine Million Höheneinträge und, wenn die Spalten einen Default-Zellstil tragen, knapp fünfzehn Millionen formatierte Leerzellen
Warum eine einzelne wiederholte Zeile beim Import teuer ist
Die Kosten kommen vom Aufblähen, und das Aufblähen ist der naheliegende Weg, einen Importer zu schreiben. Der Stil ro1 setzt style:row-height="0.452cm", was 12,81 Punkt sind. Ein Row-Finisher, der das mit einem SetRowHeight(RowIndex + i, RowHeight)-Aufruf pro wiederholter Zeile anwendet, schreibt 1.048.530 Einträge in die Per-Row-Höhentabelle, die das Objektmodell auch immer hält. Ist diese Tabelle eine Schlüssel-Stringliste, in der jeder Schreibvorgang erst nach einem existierenden Schlüssel sucht, ist der Import quadratisch im Repeat-Zähler, und ein Repeat-Zähler von einer Million ist exakt die Eingabe, bei der quadratisch aufhört, theoretisch zu sein
Leerzellen machen es schlimmer. Jedes <table:table-column> in so einer Datei nennt meist einen table:default-cell-style-name, und jede leere Zelle in der wiederholten Zeile erbt ihn. Eine formatierte Leerzelle ist für die meisten Objektmodelle trotzdem eine Zelle. Pro Spalte und wiederholter Zeile eine zu materialisieren, und die Speicherrechnung überstrahlt die Höhentabelle bei Weitem
Die Save-Seite hat das spiegelbildliche Problem. Behauptet das Modell einmal, Zeile 1.048.575 hätte eine Höhe, gibt ein Writer, der von Zeile 1 bis zur letzten Zeile mit irgendetwas drauf läuft, eine Million <table:table-row>-Elemente aus. Ein billiger Import, gefolgt von einem teuren Save, verschiebt die Kosten nur auf einen anderen Knopf
Wie HotXLS eine wiederholte Zeilenhöhe speichert
Das HotXLS Delphi Excel Component speichert eine wiederholte ODS-Zeilenhöhe als Run: einen TXLSXRowHeightRun-Datensatz mit FirstRow, LastRow und einer einzigen Height. Beendet TXLSXWorkbook.OpenODS eine Zeile mit Repeat-Zähler 1, ruft es SetRowHeight wie jeder andere Code auch. Bei jedem größeren Zähler hält es die ganze Strecke als einen Run fest, der Schwanz im Beispiel wird also zu den Zeilen 46 bis 1.048.575 bei 12,81 pt. Ein Repeat-Zähler, der über das Ende des Gitters hinausschießt, wird an Zeile 1.048.576 abgeschnitten statt zurückgewiesen
Eine Regel im Importer überrascht Leute, die das Ergebnis gegen die Quelldatei diffen. Tabellenkalkulationsanwendungen, die ODS schreiben, geben jedem Zeilenstil gern ein style:row-height mit, auch Zeilen, die nie jemand umgrößen, und markieren die mit style:use-optimal-row-height="true". Für eine Zeile, die gar keine Zellen speichert, ist eine automatische Höhe schlicht die Default-Höhe, HotXLS hält also keine Höhe für sie fest. Zeilen, die etwas speichern, behalten ihre Höhe, auch wenn sie als optimal markiert ist, denn eine Zeile mit umbrochenem Text, die auf ihren Inhalt gewachsen ist, würde sonst beim Wiederöffnen als XLSX wieder schrumpfen. Dieser Trade-off ist der richtige: Eine echte Höhe fallen zu lassen erzeugt eine sichtbare Layout-Änderung, eine redundante zu behalten kostet einen einzigen Run
Das Zurücklesen der Höhen läuft über die gewöhnliche RowHeight-Property. Sie prüft zuerst die Per-Row-Liste und greift erst auf die Runs zurück, wenn die Zeile keinen expliziten Eintrag hat; HasRowHeight folgt derselben Ordnung. Runs überlappen nie, der Fallback darf also beim ersten Run, der die Zeile enthält, aufhören
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Spans: TXLSXAxisSpans;
I: Integer;
begin
Workbook := TXLSXWorkbook.Create;
try
// Ein Sheet, dessen letztes Zeilenelement sich 1.048.530 Mal wiederholt
Workbook.OpenODS('tail-repeat.ods');
Sheet := Workbook.Sheets[1]; // Sheets ist 1-basiert
// Gespeicherte Zeilen-Metadaten: Einzeleinträge zuerst, dann Runs
Spans := Sheet.GetStoredAxisSpans(True);
for I := 0 to High(Spans) do
Writeln(Spans[I].FirstIndex, '..', Spans[I].LastIndex);
// Beide Lesezugriffe kommen aus demselben Run
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// Ein Per-Row-Eintrag wird vor dem Run gesucht, er gewinnt also
Sheet.RowHeight[500000] := 36;
// Eine Zeile im Run zu löschen schneidet ein Ein-Zeilen-Loch hinein
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 beiden Änderungen in dem Beispiel verhalten sich absichtlich unterschiedlich. Sheet.RowHeight[500000] := 36 lässt den Run in Ruhe und legt einen Eintrag in der Per-Row-Liste an, der den Run überdeckt, weil er zuerst befragt wird. ClearRowHeight kann nicht so arbeiten, denn eine gelöschte Zeile muss „keine Höhe“ melden, obwohl ein Run sie weiterhin überspannt. Es löscht also jeden Per-Row-Eintrag und baut dann die Run-Liste neu: Jeder Run außerhalb der gelöschten Zeile bleibt, wie er ist, der Run, der sie enthielt, wird in den Teil davor und den Teil danach gespalten, und für Zeile 500.001 selbst wird nichts gespeichert. GetStoredAxisSpans(True) listet jetzt 46..500000 und 500002..1048575 als zwei getrennte Runs, neben dem Einzeleintrag für 500.000. ClearRowHeights leert die Per-Row-Liste und die Runs in einem Aufruf
Wohin mit den geerbten Leerzellen-Stilen?
Geerbte Leerzellen-Stile werden zu Style-Overlays: Rechtecken auf dem Worksheet, die einen Stil tragen, ohne Zellobjekte dahinter. Beim Lesen einer Zeile läuft der Importer die wiederholten Zellen in Strecken benachbarter Spalten ab, die zum selben Stil auflösen. Eine Strecke, deren Stil exakt wie Default aussieht, speichert nichts. Eine Strecke mit echter Formatierung, etwa einem zentrierten oder fetten Spalten-Default, wird zu einem Zeilenwert, der alle ihre Spalten abdeckt
Wird dieser Zeilenwert committet und die Zeile wiederholt sich, materialisiert HotXLS eine einzige Zelle oben links in der Strecke, wendet den geerbten Stil auf sie an und registriert die sechs Stil-Indizes (Font, Fill, Border, Zahlenformat, Ausrichtung, Schutz) über Sheet.StyleOverlays.Add für das ganze Rechteck: jede Spalte der Strecke, von der ersten wiederholten Zeile bis zur letzten. Für die übrigen 1.048.529 Zeilen entsteht nichts weiter. Sheet.Cells.Count bleibt nahe an der Anzahl der Zellen, die tatsächlich Daten halten
Eine Zelle innerhalb des Rechtecks bekommt das Overlay in dem Moment, in dem der eigene Code sie anfasst. Nach dem Öffnen eines Sheets, dessen Schwanz von einem fetten Spalten-Default formatiert wird, löst Sheet.Cells[700000, 1].FontIndex auf den fetten Font auf, obwohl an dieser Koordinate eine Zeile früher keine Zelle existierte. XLSX-Dateien mit einer formatierten, aber leeren Spalte laufen durch dieselbe Maschinerie, und der Artikel zur Row-Block-Zellspeicherung und den Interval-Style-Overlays behandelt, wie mehrere Overlays sich schichten und auflösen. Der ODS-Importer unterscheidet sich nur darin, woher die Overlays kommen: Er leitet sie selbst aus den Repeat-Zählern ab
Wie SaveAsODS den Repeat-Zähler zurückschreibt
TXLSXWorkbook.SaveAsODS schreibt jede Strecke identischer Zeilen als ein <table:table-row> mit table:number-rows-repeated und beendet eine Strecke erst, wo sich an den Zeilen etwas ändert. Die Tabelle reicht hinunter bis zur untersten Zeile, die irgendetwas trägt: einen Wert, eine nur-formatierte Leerzelle, eine Einzeilen-Höhe, das Ende eines Höhen-Runs, den unteren Rand eines Overlays, einen Hidden- oder Outline-Eintrag oder einen Validierungs- oder Conditional-Format-Bereich
Ab Zeile 1 schaut der Writer auf die nächste Grenze voraus. Kandidaten sind die nächste materialisierte Zelle, der Anfang oder das Ende eines Höhen-Runs, jeder Einzeleinträge (Höhe, Hidden-Flag, Outline-Level, Collapsed-Zustand, manueller Seitenumbruch), Overlay-Ränder, verbundene Bereiche, Hyperlinks, Kommentare, Bilder und die Ränder jedes Validierungs- und Conditional-Format-Rechtecks. Alles bis zu dieser Grenze ist ein Element. Innerhalb jedes Elements läuft dieselbe Logik über die Spalten, eine leere Strecke wird also zu einem <table:table-cell> mit table:number-columns-repeated und dem Stilnamen, zu dem das Overlay auflöst
Zeilenstile werden geteilt. Die Zeilenhöhe wird als Text formatiert, im Beispiel 12.81pt, und dieser Text bildet zusammen mit dem Hidden-Flag und dem Seitenumbruch-Flag den Schlüssel für den automatischen Zeilenstil. Jede Zeile mit denselben drei Werten zeigt auf denselben ro<N>-Stil, der eine einzige style:row-height-Property hält. Eine Zeile ohne Höhe, sichtbar und ohne Umbruch bekommt gar keinen Zeilenstil
var
Workbook, Reopened: TXLSXWorkbook;
Saved: TMemoryStream;
begin
Workbook := TXLSXWorkbook.Create;
Reopened := TXLSXWorkbook.Create;
Saved := TMemoryStream.Create;
try
Workbook.OpenODS('tail-repeat.ods');
Workbook.Sheets[1].RowHeight[500000] := 36;
Workbook.Sheets[1].ClearRowHeight(500001);
// Der Schwanz wird als ein paar wiederholte Zeilenelemente geschrieben
Workbook.SaveAsODS(Saved);
Writeln('ODS stream: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.OpenODS(Saved);
// Erwartet: Override, Loch und beide Run-Stücke überleben 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); // die Run-Höhe
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
Für das Sheet oben teilt sich der Schwanz in vier Stücke: die Zeilen 46 bis 499.999 mit der Run-Höhe, Zeile 500.000 mit 36 pt, Zeile 500.001 ohne Zeilenstil und die Zeilen 500.002 bis 1.048.575 wieder mit der Run-Höhe. Jede Spalte, deren Default-Stil ein Overlay hinterlassen hat, kann eine zusätzliche Teilung dort einfügen, wo das Overlay endet. Die 45 Datenzeilen über dem Schwanz werden mit einem Element pro Zeile geschrieben, denn jede von ihnen hält Zellen
XLSX ist eine andere Geschichte. SpreadsheetML hat kein Repeat-Attribut, und ein <row>-Element beschreibt exakt eine Zeile, das Speichern eines Run-getragenen Sheets als .xlsx zählt also die Zeilen auf, die ein Run abdeckt, und schreibt auf jede ht und customHeight="1". Das Modell bleibt im Speicher kompakt; das Zielformat entscheidet, wie groß die Datei wird. Kommt eine Mappe als ODS an und muss nur als ODS wieder hinaus, zahlt sie diese Per-Row-Kosten nie
Änderungen, die Zeilen neu nummerieren
Eine zweite Repräsentation von Zeilenhöhen ist nur korrekt, wenn jede Operation, die Zeilen bewegt, sie mitbewegt. TXLSXWorksheet.InsertRows und DeleteRows verschieben die Runs zusammen mit den Per-Row-Listen: Der Teil eines Runs vor dem Bearbeitungspunkt bleibt, wo er ist, Zeilen innerhalb eines gelöschten Fensters fallen weg, und der Rest rückt um die eingefügte oder gelöschte Anzahl. Ein Run, der eine Einfügung überspannt, wird deshalb zu zwei Runs mit einer Lücke ungrößener Zeilen dazwischen, und ein Run, der eine Löschung überspannt, wird kürzer
procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// Zwei neue Zeilen bei 500000: 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
Writeln(Sheet.HasRowHeight(500000)); // FALSE: eingefügte Zeilen haben keine Höhe
// Wieder entfernen: Override und Loch kehren auf ihre alten Zeilennummern zurück
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500001)); // FALSE
Writeln(Sheet.UsedRange.Row2); // enthält das Ende des verschobenen Runs
end;
Der Round Trip lässt den Schwanz nicht genau dort, wo er war. Die Einfügung schiebt den Schwanz-Run von 500.002..1.048.575 auf 500.004..1.048.577, und das Gitter endet bei Zeile 1.048.576, der Teil hinter dieser Zeile wird also abgeschnitten. Die Löschung verschiebt dann, was übrig ist, um zwei nach oben, und der Run endet bei Zeile 1.048.574: Zeile 1.048.575 hat keine gespeicherte Höhe mehr. Wenn die letzten Zeilen des Gitters einem wichtig sind, ihre Höhe nach einem Insert-und-Delete-Paar neu setzen. Zeilen, die mitten in einen Run eingefügt werden, übernehmen dessen Höhe ebenfalls nicht: Sie öffnen eine Lücke ungrößener Zeilen und erben nicht die Run-Höhe, deshalb hat HasRowHeight(500000) oben FALSE gedruckt
Die übrigen zeilenbewegenden Operationen folgen demselben Muster. TileRangeAxisMetadata räumt die Runs über die ganze gekachelte Spanne weg und registriert dann jeden Quell-Run einmal pro Kopie an seinem Offset. TXLSXWorksheet.CopyFrom und TXLSXSheets.AddCopy geben dem neuen Blatt eine eigene Kopie der Run-Liste, ClearRowHeights auf einem Klon lässt also die Höhen des Originals in Ruhe. Auf der Leseseite erstreckt sich UsedRange auf die erste und letzte Zeile jedes Runs, und der Zeilenlauf des XLSX-Writers reicht durch die Runs, sodass nur-Höhen-Zeilen weiterhin geschrieben werden
Versteckte Zeilen und Outline-Levels bleiben pro Zeile
Zeilenhöhen und geerbte Leerzellen-Stile sind die zwei Dinge, die zu Runs und Overlays kollabieren. Hidden-Flags, Outline-Levels und Collapsed-Zustand werden einmal pro Zeile aufgezeichnet. Eine wiederholte Zeile, die versteckt ist oder in einer <table:table-row-group> sitzt, produziert einen Eintrag pro wiederholter Zeile für jedes dieser Attribute, und beim Speichern ist jeder dieser Einträge eine eigene Grenze, die versteckte Strecke wird also mit einem <table:table-row> pro Zeile geschrieben
Für Templates, die man selbst kontrolliert, den versteckten und gruppierten Teil eines Sheets so knapp halten, wie die Daten es brauchen. Zwanzig Zeilen unter einem Report zu verstecken ist gratis. Alles von Zeile 46 bis zum unteren Rand des Gitters zu verstecken bedeutet eine Million Einträge beim Laden und eine Million Zeilenelemente beim Speichern, und der einzige sichtbare Unterschied ist, dass niemand mehr am Report vorbeiscrollen kann
Die Arbeitsmappen-Seite des Formats, inklusive der Frage, welche ODS-Features auf welche Teile des Objektmodells mappen, steht im Rundgang zum Öffnen und Speichern von ODS-Dateien, weitere Hebel für große Dateien sind unter Performance großer Arbeitsmappen gesammelt. Das Zeilenelement selbst mit seinen Repeat- und Stil-Attributen ist im Tabellen-Kapitel von ODF 1.3 Part 3 definiert
Eine Million-Zeilen-Wiederholung ist ein Format-Detail, das ein nativer Reader gut modellieren muss, denn im Hintergrund gibt es keine Office-Suite, die es abfangen könnte. Die HotXLS Delphi Spreadsheet Component-Seite listet die XLS-, XLSX- und ODS-Features, die sie liest und schreibt, samt den unterstützten Delphi- und C++Builder-Versionen