Artykuł techniczny

Powtarzane wiersze ODS jako przedziały wysokości w HotXLS

HotXLS Delphi Component trzyma wiersz ODS niosący table:number-rows-repeated i wysokość wiersza jako jeden rekord TXLSXRowHeightRun — pierwszy wiersz, ostatni wiersz, jedna wysokość — zamiast jednego wpisu wysokości na każdy powtórzony wiersz, a styl pustych komórek dziedziczony przez te wiersze zwija w jedną nakładkę stylu na przedział. To cały powód, dla którego HotXLS 2.382.2 otwiera arkusz, którego ogon powtarza 1 048 530 pustych wierszy, w 0,02 sekundy tam, gdzie 2.382.1 przekraczał limit czasu — i dlaczego ten sam plik zapisuje się z powrotem do ODS z zachowanym licznikiem powtórzeń, a nie jako milion dosłownych wierszy

Ten plik jest zwyczajny. LibreOffice Calc zapisuje arkusz o czternastu kolumnach z 45 wierszami danych, a wszystko poniżej opisuje jednym elementem: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Styl ro1 ustawia style:row-height="0.452cm", a każdy <table:table-column> niesie table:default-cell-style-name, który dziedziczy każda pusta komórka w tym przedziale. Cały content.xml ma 103 KB. Nic w tym pliku nie mówi „drogo”; koszt był w całości nasz

Jak HotXLS zamienia jeden powtarzany wiersz ODS w zwarty stan: element content.xml z table:number-rows-repeated 1048530 i stylem ro1 mapuje się na jeden rekord TXLSXRowHeightRun obejmujący wiersze od 46 do 1048575 przy 12.81 pt plus jeden wpis StyleOverlays na kolumnę, podczas gdy wersja 2.382.1 rozwijała ten sam element na milion wywołań SetRowHeight i obiektów komórek
Licznik powtórzeń, wysokość wiersza ze stylu ro1 i domyślne style kolumn opisują każdy pusty wiersz poniżej wiersza 45, więc importer może zbudować jeden rekord przedziału i nakładki na kolumnę, nie dotykając miliona współrzędnych

Dlaczego jeden powtarzany wiersz przekracza limit czasu importu ODS?

Bo importer kiedyś go rozwijał. W 2.382.1 finiszer wiersza pętlił SetRowHeight(RowIndex + i, RowHeight) raz na każdy powtórzony wiersz, zapisując każdą wysokość do listy łańcuchów w formacie Name=Value kluczowanej numerem wiersza. Każde wstawienie do tej listy uruchamiało wyszukiwanie IndexOfName po wszystkim, co już w niej było, więc milion wysokości kosztował milion liniowych skanów — to właśnie kwadratowe przeszukiwanie listy, przeciw któremu zgłoszono HXLS-005. Równocześnie OdsCommitRow materializował obiekt komórki dla każdej kolumny dziedziczącej styl, w każdym z powtórzonych wierszy, bo pusta komórka ze stylem wciąż liczyła się jako komórka

Strona zapisu miała własną wersję tego problemu. Plik z LibreOffice kończy się jeszcze jednym wierszem ro1 po wielkim powtórzeniu, więc najwyższy wiersz ze stylem siedział na samym dnie arkusza, a OdsBuildTableXml przechodził wszystkie wiersze aż do niego, emitując elementy <table:table-row> pojedynczo. Nawet skoroszyt zaimportowany tanio zostałby zapisany drogo. Naprawa importu bez naprawy eksportu przesunęłaby limit czasu, a nie usunęła go

Czym jest przedział wysokości wierszy w HotXLS?

Przedział to najmniejsza rzecz, która potrafi opisać „wiersze od 46 do 1 048 575 mają wszystkie 12,81 punktu” bez mówienia tego 1 048 530 razy. TXLSXRowHeightRun to rekord z FirstRow, LastRow i Height; TXLSXRowHeightRuns to dynamiczna tablica takich rekordów, a każdy TXLSXWorksheet trzyma jedną w FRowHeightRuns obok istniejącej listy wysokości wierszy. Przy imporcie ODS finiszer wiersza rozgałęzia się teraz na liczniku powtórzeń: licznik 1 wciąż woła SetRowHeight, cokolwiek większe woła XlsxAssignRowHeightRun raz dla całego przedziału. Przedział jest przycinany do XlsxMaxRow, czyli 1 048 576, więc licznik powtórzeń wykraczający poza arkusz jest obcinany, a nie odrzucany

XlsxAssignRowHeightRun jest jedynym piszącym do tej tablicy i z założenia trzyma przedziały rozłączne. Dostając nowy przedział, kopiuje każdy istniejący przedział leżący w całości poza nim, rozcina każdy przedział, który się z nim nakłada, na część przed i część po, a potem dopisuje nowy przedział, gdy Present jest prawdą — albo nie dopisuje nic, gdy Present jest fałszem, i tak właśnie ClearRowHeight wybija dziurę na jeden wiersz. Wynikają z tego dwie rzeczy. Tablica nigdy nie zawiera przedziałów nachodzących na siebie, więc wyszukiwanie może się zatrzymać na pierwszym trafieniu. I tablica nigdy nie jest modyfikowana w miejscu; przy każdym wywołaniu powstaje świeża kopia, co w tych rozmiarach nic nie kosztuje, a usuwa całą klasę błędów aliasingu

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Arkusz, którego wiersz ogona powtarza się 1 048 530 razy pod jednym stylem wiersza
    Workbook.OpenODS('conditional-formatting.ods');
    Sheet := Workbook.Sheets[1];
    // Oba odczyty rozwiązują się przez ten sam przedział; nic nie zostało rozwinięte
    Writeln(Sheet.RowHeight[46]:0:2, ' pt');
    Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
    // Nadpisanie jednego wiersza przesłania przedział, nie rozcinając go
    Sheet.RowHeight[500000] := 36;
    // Wyczyszczenie jednego wiersza wewnątrz przedziału rozcina przedział na dwie części
    Sheet.ClearRowHeight(500001);
    Writeln(Sheet.HasRowHeight(500001)); // False
    Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // wciąż wysokość przedziału
  finally
    Workbook.Free;
  end;
end;

Kolejność wyszukiwania to część, którą warto zapamiętać. TXLSXWorksheet.GetRowHeight najpierw sprawdza listę na wiersz i do przedziałów zagląda tylko wtedy, gdy wiersz nie ma jawnego wpisu, a HasRowHeight robi tak samo. Zatem Sheet.RowHeight[500000] := 36 nie dotyka przedziału wcale — dodaje jeden wpis do listy na wiersz i ten wpis wygrywa, bo jest sprawdzany pierwszy. ClearRowHeight działa odwrotnie: usuwa wpis na wiersz, a potem woła XlsxAssignRowHeightRun z Present = False, bo wyczyszczony wiersz musi czytać się jako „brak wysokości”, nawet jeśli obejmuje go jakiś przedział. ClearRowHeights opróżnia obie struktury naraz

Operacje na przedziale wysokości wierszy w HotXLS: po OpenODS jeden przedział obejmuje wiersze od 46 do 1048575 przy 12.81 pt, a wpis na wiersz ustawia wiersz 500000 na 36 pt i wygrywa wyszukiwanie, bo GetRowHeight najpierw sprawdza listę na wiersz, natomiast ClearRowHeight wiersza 500001 rozcina przedział na dwie rozłączne części wokół dziury
XlsxAssignRowHeightRun kopiuje części poza wyczyszczonym przedziałem i nie dopisuje nic dla samego przedziału, więc przedziały pozostają rozłączne z założenia, a wyszukiwanie może się zatrzymać na pierwszym trafieniu, podczas gdy nadpisanie w wierszu 500000 zostaje nienaruszone

Gdzie trafiają dziedziczone style pustych komórek?

Do jednej nakładki stylu na przedział i na kolumnę, a nie do obiektów komórek. OdsCommitRow rozstrzyga dla każdej wartości kolumny, czy jest zwartą pustą komórką: wiersz powtarza się więcej niż raz, a komórka nie ma wartości, formuły ani tekstu sformatowanego. Dla zwartej pustej komórki tworzy prawdziwą komórkę tylko w pierwszym wierszu przedziału, stosuje do niej dziedziczony styl, a potem rejestruje te same sześć indeksów stylu — czcionka, wypełnienie, obramowanie, format liczby, wyrównanie, ochrona — jako StyleOverlays.Add obejmujące wiersze od drugiego do końca przedziału w tej kolumnie. Wiersze po pierwszym są w pętli materializacji pomijane w całości

Test regresyjny czyni ten kształt konkretnym. Po otwarciu arkusza, którego drugi wiersz powtarza się 1 048 575 razy pod domyślnym stylem kolumny z pogrubieniem, Sheet.Cells.Count ma być mniejsze niż 10, a Sheet.Cells[700000, 1].FontIndex wciąż rozwiązuje się do pogrubionej czcionki — nakładka dostarcza styl w momencie dotknięcia tej współrzędnej. To ten sam mechanizm, który po stronie XLSX nie pozwala sformatowanej, ale pustej kolumnie kosztować miliona komórek; notatki o przechowywaniu komórek w blokach wierszy i nakładkach stylu na przedział omawiają, jak nakładki się warstwują i rozwiązują. Nowe jest tu to, że importer ODS tworzy je sam, z licznika powtórzeń, zamiast czekać, aż aplikacja sformatuje zakres

Jak SaveAsODS zapisuje licznik powtórzeń z powrotem?

Dzieląc pusty ogon arkusza tylko tam, gdzie naprawdę coś się zmienia. OdsBuildTableXml śledzi teraz dwie granice: contentMaxRow, czyli ostatni wiersz trzymający wartość, formułę, hiperłącze albo ręczne złamanie wiersza, oraz maxRow, która rozciąga się dodatkowo przez puste komórki mające tylko styl, wysokości pojedynczych wierszy, LastRow każdego przedziału i dolną krawędź każdej nakładki. Pusta komórka mająca tylko styl nie liczy się już jako treść — wyklucza ją TXLSXCells.IsStyleOnlyBlank — więc końcowy wiersz ze stylem z pliku LibreOffice przestaje ciągnąć granicę treści na dno arkusza

Powyżej contentMaxRow wiersze są zapisywane pojedynczo, dokładnie jak wcześniej. Poniżej zapisujący liczy nextRow jako najmniejszą z: FirstRow następnego przedziału, LastRow + 1 bieżącego przedziału, kolejnego wpisu wysokości pojedynczego wiersza, kolejnej krawędzi nakładki i kolejnej zmaterializowanej komórki. Wszystko od bieżącego wiersza do nextRow - 1 jest potem emitowane jako jeden <table:table-row> z table:number-rows-repeated ustawionym na tę różnicę, niosący po jednym <table:table-cell/> na kolumnę, z nazwą stylu rozwiązaną przez nakładkę, gdy nakładka obejmuje tę kolumnę. Sam styl wiersza pochodzi z TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), który zwija teraz tekst wysokości — powiedzmy 12.81pt — do swojego klucza deduplikacji obok flag ukrycia i złamania strony, więc każdy wiersz w przedziale dzieli jeden styl ro<N> z jedną właściwością style:row-height

Co SaveAsODS zapisuje dla arkusza opartego na przedziale: contentMaxRow zatrzymuje się na wierszu 45, gdzie kończą się wartości, a maxRow rozciąga się przez przedział wysokości i jego nadpisania, wiersze powyżej granicy idą pojedynczo, a ogon jest emitowany jako powtarzane elementy table-row, których styl wiersza pochodzi z RowStyleFor, a style komórek rozwiązują się przez nakładki
Każdy powtórzony element obejmuje jeden jednorodny odcinek i zatrzymuje się na następnej krawędzi przedziału, wpisie wysokości, krawędzi nakładki albo zmaterializowanej komórce, więc arkusz bez walidacji zapisuje się jako garść elementów, a walidacje albo eksport do XLSX płacą za każdy wiersz
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);
    // Pusty ogon jest zapisywany jako garść powtórzonych wierszy, a nie milion
    Workbook.SaveAsODS(Saved);
    Writeln('ODS size: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.Open(Saved);
    // Nadpisanie, dziura i przedział przeżywają 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);   // wysokość przedziału
  finally
    Saved.Free;
    Reopened.Free;
    Workbook.Free;
  end;
end;

Test, który to przypina, wymaga, by zapisany strumień miał mniej niż 64 KB dla arkusza, którego przedział wysokości obejmuje 1 048 575 wierszy z nadpisaniem i wybitą pośrodku dziurą. Obok tej liczby należą się dwie uczciwe granice. Po pierwsze, arkusz z jakimikolwiek walidacjami danych ustawia contentMaxRow na maxRow, więc walidacje wyłączają zagęszczanie ogona na tym arkuszu i jest on zapisywany znów wiersz po wierszu. Po drugie, XLSX nie ma atrybutu powtórzenia — <row> w SpreadsheetML opisuje jeden wiersz — więc eksport arkusza opartego na przedziale do .xlsx wylicza wiersze objęte przedziałem i zapisuje w każdym atrybut ht. Model pozostaje zwarty w pamięci; o tym, jak wygląda plik, decyduje format pliku

Co każda edycja przenumerowująca wiersze jest teraz winna przedziałom?

Utrzymanie. Nowa reprezentacja metadanych wiersza jest poprawna tylko wtedy, gdy każda operacja zmieniająca numery wierszy przenosi ją razem z listami na wiersz, obok których siedzi, a ten commit dotyka każdej z tych operacji. InsertRows i DeleteRows idą przez XlsxShiftRowHeightRuns, który przebudowuje tablicę, zachowując część każdego przedziału leżącą przed punktem edycji, wyrzucając wszystko, co wpada w okno usunięcia, i dodając resztę z powrotem przesuniętą o deltę — więc przedział rozciągający się przez wstawienie staje się dwoma przedziałami z luką, a przedział rozciągający się przez usunięcie się kurczy. TileRangeAxisMetadata czyści przedziały na całym kafelkowanym obszarze, a potem rejestruje każdy przedział źródłowy raz na kopię pod jej przesunięciem. TXLSXWorksheet.CopyFrom i TXLSXSheets.AddCopy biorą Copy() tablicy, zamiast ją przypisywać, dlatego test może wyczyścić wszystkie wysokości na klonie i wciąż znaleźć oryginalny arkusz nienaruszony w wierszu 1 048 576

var
  Sheet: TXLSXWorksheet;
begin
  Sheet := Workbook.Sheets[1];
  Sheet.RowHeight[500000] := 36;
  Sheet.ClearRowHeight(500001);
  // Wstaw dwa wiersze na 500000: nadpisanie idzie na 500002, dziura na 500003
  Sheet.InsertRows(500000, 2);
  Writeln(Sheet.RowHeight[500002]:0:2);   // 36.00
  Writeln(Sheet.HasRowHeight(500003));    // False
  // Usuń je ponownie: wszystko wraca na swoje miejsce
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);   // 36.00
  // Skafelkuj wiersze 2..4 dwa razy w dół arkusza; wysokości przedziału idą za każdą kopią
  Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
  Writeln(Sheet.RowHeight[7]:0:2);        // wysokość przedziału
end;

Granice po stronie odczytu mają ten sam obowiązek. GetUsedRange podnosi swoją dolną krawędź do FirstRow i LastRow każdego przedziału, a BuildRowMajorCellOrder rozciąga swój obejmujący metadane maksymalny wiersz przez każdy przedział, żeby zapisujący XLSX wciąż odwiedzał wiersze mające samą wysokość. Jeśli kiedyś dołożysz na modelu obiektowym HotXLS własną strukturę kluczowaną wierszem, oto lista kontrolna: wstawianie, usuwanie, kafelkowanie, kopiowanie, zakres używany i każdy serializator. Przegapisz jeden i awaria jest cicha — wysokości rozjeżdżają się o liczbę wstawień, a nic nie zgłasza wyjątku

Co zostaje na wiersz i jak wyglądają teraz liczby

Flagi ukrycia, poziomy konspektu i stan zwinięcia wciąż się rozwijają. Finiszer wiersza pętli SetRowHidden i SetRowOutlineLevel raz na każdy powtórzony wiersz, więc arkusz, który ukrywa milionowy ogon albo zagnieżdża go w table:table-row-group, płaci wpisem na wiersz za każdy z tych atrybutów. Zmiana w 2.382.2 jest ograniczona do dwóch rzeczy, które HXLS-005 faktycznie zmierzył — wysokości i dziedziczonych stylów pustych komórek — a ta sama technika przedziałów zastosowałaby się do pozostałych, gdyby jakiś plik jej zażądał. Czytnik ODS nie reaguje też na style:use-optimal-row-height; styl wiersza, który mówi „optymalna” i podaje wysokość, jest importowany z tą wysokością

Na korpusie conditional-formatting.ods przechodzi teraz cykl otwarcia, asercji, zapisu, ponownego otwarcia i ponownej asercji w 0,178 sekundy na Win32 i 0,158 sekundy na Win64, przy samym otwarciu na poziomie 0,020 sekundy, mieszcząc się w 60-sekundowym budżecie, który wcześniej wyczerpywał. Interfejsy na poziomie skoroszytu, przez które płynie ten format, opisuje przewodnik po otwieraniu i zapisywaniu plików ODS, a szerszy zestaw dźwigni dla dużych plików — wydajność dużych skoroszytów; sam element wiersza ODF, z jego atrybutami powtórzenia i stylu, jest wyspecyfikowany w ODF 1.3 Part 3 §9.1.4

HotXLS czyta i zapisuje XLS, XLSX oraz ODS z natywnego kodu Delphi i C++Buildera bez zainstalowanego Excela czy LibreOffice, dlatego milionowe powtórzenie wiersza to coś, co biblioteka musi dobrze zamodelować, a nie przekazać zewnętrznemu procesowi — strona komponentu HotXLS Delphi do arkuszy wymienia obsługiwane formaty i wersje RAD Studio