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
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
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
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