Technischer Artikel

Wiederholte ODS-Zeilen als Row-Height-Runs in Delphi

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

Wie HotXLS eine wiederholte ODS-Zeile in kompakten Zustand verwandelt: Das content.xml-Element mit table:number-rows-repeated 1048530 und Stil ro1 mappt auf einen einzigen TXLSXRowHeightRun-Datensatz über die Zeilen 46 bis 1048575 bei 12.81 pt plus einen StyleOverlays-Eintrag pro Spalten-Strecke desselben Stils, statt dasselbe Element in eine Million SetRowHeight-Einträge und Zellobjekte aufzublasen
Der Repeat-Zähler, die ro1-Zeilenhöhe und die Spalten-Default-Stile beschreiben jede Leerzeile unterhalb von Zeile 45, sodass der Importer einen Run-Datensatz und ein paar Spalten-Strecken-Overlays bauen kann, ohne eine Million Koordinaten anzufassen

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

Row-Height-Run-Chirurgie in HotXLS: Nach OpenODS deckt ein Run die Zeilen 46 bis 1048575 bei 12.81 pt ab, während ein Per-Row-Eintrag Zeile 500000 auf 36 pt setzt und die Suche gewinnt, weil RowHeight zuerst die Per-Row-Liste prüft, und ClearRowHeight von Zeile 500001 spaltet den Run in zwei disjunkte Stücke um das Loch
ClearRowHeight behält die Stücke des Runs außerhalb der gelöschten Zeile und speichert für die Zeile selbst nichts, Runs überlappen also nie, eine Suche darf beim ersten Treffer aufhören, und der Override in Zeile 500000 bleibt unangetastet

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

Was SaveAsODS für ein Run-getragenes Sheet schreibt: Die Zeilen 1 bis 45 halten Werte und werden mit einem Element pro Zeile geschrieben, der Schwanz bis Zeile 1048575 wird als wiederholte table-row-Elemente ausgegeben, die an Run-Kanten, Per-Row-Overrides und Overlay-Rändern splitten, Zeilen mit derselben Höhe teilen sich einen automatischen Zeilenstil, und Zellenstile lösen sich über die Overlays auf
Jedes wiederholte Element spannt eine gleichförmige Strecke auf und hält an der nächsten Run-Kante, Höheneintrag, Overlay-Kante, Regelbereich oder materialisierten Zelle an, ein leerer Schwanz speichert also als Handvoll Elemente, während ein XLSX-Export ein Zeilenelement pro abgedeckter Zeile schreibt
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