HotXLS Delphi Component зберігає рядок ODS, який несе table:number-rows-repeated і висоту рядка, як один запис TXLSXRowHeightRun — перший рядок, останній рядок, одна висота — замість одного запису висоти на кожен повторений рядок, і згортає стиль порожньої клітинки, який ці рядки успадковують, в одне інтервальне накладення стилю. Це і є вся причина, чому HotXLS 2.382.2 відкриває таблицю, чий хвіст повторює 1,048,530 порожніх рядків, за 0.02 секунди там, де 2.382.1 упирався в таймаут, і чому той самий файл зберігається назад в ODS з незміненою кількістю повторів, а не як мільйон буквальних рядків
Файл цей цілком звичайний. LibreOffice Calc пише аркуш на чотирнадцять колонок із 45 рядками даних, а потім описує все під ними одним елементом: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Стиль ro1 задає style:row-height="0.452cm", а кожен <table:table-column> несе table:default-cell-style-name, який успадковує кожна порожня клітинка в цьому повторі. Увесь content.xml — 103 KB. Ніщо у файлі не кричить «дорого»; дорого було цілком на нашому боці
Чому один повторений рядок доводить імпорт ODS до таймауту?
Бо імпортер колись його розгортав. У 2.382.1 фінішер рядків у циклі викликав SetRowHeight(RowIndex + i, RowHeight) по разу на кожен повторений рядок, записуючи кожну висоту в рядковий список Name=Value, ключований номером рядка. Кожна вставка в цей список проганяла пошук IndexOfName по всьому, що вже там лежало, тож мільйон висот коштував мільйон лінійних сканувань — той самий квадратичний пошук у списку, проти якого й було заведено HXLS-005. Водночас OdsCommitRow матеріалізував об'єкт клітинки для кожної колонки, що успадкувала стиль, на кожному з повторених рядків, бо порожня клітинка зі стилем усе ще рахувалася клітинкою
Бік збереження мав власну версію цієї проблеми. Файл LibreOffice закінчується ще одним рядком ro1 після великого повтору, тож найвищий рядок зі стилем сидів у самому низу аркуша, і OdsBuildTableXml проходив кожен рядок до нього, видаючи елементи <table:table-row> по одному. Навіть книга, імпортована дешево, записалася б дорого. Виправлення імпорту без виправлення експорту лише перенесло б таймаут, а не прибрало б його
Що таке діапазон висоти рядків у HotXLS?
Діапазон — це найменша річ, яка може описати «рядки з 46 по 1,048,575 усі заввишки 12.81 пункта», не кажучи цього 1,048,530 разів. TXLSXRowHeightRun — це запис із FirstRow, LastRow і Height; TXLSXRowHeightRuns — динамічний масив таких записів, і кожен TXLSXWorksheet тримає один у FRowHeightRuns поряд із наявним списком висот на рядок. При імпорті ODS фінішер рядків тепер розгалужується за кількістю повторів: кількість 1 і далі викликає SetRowHeight, а більша — один раз XlsxAssignRowHeightRun на весь проміжок. Проміжок обрізається до XlsxMaxRow, тобто 1,048,576, тож кількість повторів, яка виходить за межі аркуша, усікається, а не відкидається
XlsxAssignRowHeightRun — єдиний записувач цього масиву, і він за побудовою тримає діапазони несуміжними. Отримавши новий інтервал, він копіює кожен наявний діапазон, що лежить повністю поза ним, розрізає будь-який діапазон, що з ним перетинається, на частину до і частину після, а тоді додає новий інтервал, коли Present істинний, — або не додає нічого, коли Present хибний, і саме так ClearRowHeight пробиває дірку в один рядок. З цього випливає дві речі. Масив ніколи не містить інтервалів, що перетинаються, тож пошук може спинитися на першому влучанні. І масив ніколи не змінюється на місці; кожен виклик будує свіжу копію, що нічого не коштує на таких розмірах і прибирає цілий клас багів із аліасингу
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Workbook := TXLSXWorkbook.Create;
try
// Аркуш, чий хвостовий рядок повторюється 1,048,530 разів під одним стилем рядка
Workbook.OpenODS('conditional-formatting.ods');
Sheet := Workbook.Sheets[1];
// Обидва читання розв'язуються через той самий діапазон; нічого не розгорталося
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// Перекриття на один рядок затінює діапазон, не розрізаючи його
Sheet.RowHeight[500000] := 36;
// Очищення одного рядка всередині діапазону ріже діапазон на дві частини
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // False
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // усе ще висота діапазону
finally
Workbook.Free;
end;
end;
Порядок пошуку — це та частина, яку варто запам'ятати. TXLSXWorksheet.GetRowHeight спершу перевіряє список на рядок і звертається до діапазонів лише тоді, коли в рядка немає явного запису, і HasRowHeight робить так само. Тож Sheet.RowHeight[500000] := 36 взагалі не торкається діапазону — він додає один запис у список на рядок, і цей запис перемагає, бо його шукають першим. ClearRowHeight — навпаки: він прибирає будь-який запис на рядок, а потім викликає XlsxAssignRowHeightRun з Present = False, бо очищений рядок мусить читатися як «без висоти», навіть якщо його покриває діапазон. ClearRowHeights спорожнює обидві структури одразу
Куди подіваються успадковані стилі порожніх клітинок?
В одне інтервальне накладення стилю на колонку, а не в об'єкти клітинок. OdsCommitRow вирішує для кожної колонки, чи це компактна порожня клітинка: рядок повторюється більше одного разу, клітинка не має ні значення, ні формули, ні форматування rich text. Для компактної порожньої він створює справжню клітинку лише на першому рядку діапазону, застосовує до неї успадкований стиль, а тоді реєструє ті самі шість індексів стилю — шрифт, заливка, рамка, числовий формат, вирівнювання, захист — як одне StyleOverlays.Add, що покриває рядки від другого до кінця діапазону в цій колонці. Рядки після першого в циклі матеріалізації пропускаються повністю
Регресійний тест робить цю форму наочною. Після відкриття аркуша, чий другий рядок повторюється 1,048,575 разів під жирним типовим стилем колонки, Sheet.Cells.Count перевіряється як менший за 10, а Sheet.Cells[700000, 1].FontIndex усе ще розв'язується в жирний шрифт — накладення дає стиль тієї ж миті, коли координати торкаються. Це той самий механізм, який не дає форматувати-але-порожній колонці коштувати мільйона клітинок і на боці XLSX; замітки про блокове зберігання клітинок рядків та інтервальні накладення стилів описують, як накладення нашаровуються й розв'язуються. Нове тут те, що імпортер ODS створює їх сам, із кількості повторів, а не чекає, поки застосунок відформатує діапазон
Як SaveAsODS записує кількість повторів назад?
Розбиваючи порожній хвіст аркуша лише там, де щось справді змінюється. OdsBuildTableXml тепер відслідковує дві межі: contentMaxRow — останній рядок, що тримає значення, формулу, гіперпосилання або ручний розрив рядка, — і maxRow, який додатково тягнеться крізь порожні клітинки з самим лише стилем, висоти на один рядок, LastRow кожного діапазону і нижній край кожного накладення. Порожня клітинка з самим лише стилем більше не рахується вмістом — саме TXLSXCells.IsStyleOnlyBlank її виключає, — тож хвостовий рядок зі стилем у файлі LibreOffice перестає тягнути межу вмісту в самий низ аркуша
Вище contentMaxRow рядки пишуться по одному точно як раніше. Нижче записувач рахує nextRow як найменше з: FirstRow наступного діапазону, LastRow + 1 поточного діапазону, наступного запису висоти на один рядок, наступного краю накладення й наступної матеріалізованої клітинки. Усе від поточного рядка до nextRow - 1 тоді видається одним <table:table-row> з table:number-rows-repeated, рівним різниці, і несе по одному <table:table-cell/> на колонку з іменем стилю, розв'язаним через накладення, коли накладення покриває цю колонку. Сам стиль рядка береться з TOdsAutoStylePool.RowStyleFor(AHidden, ABreakBefore, AHeightSpec), який тепер вкладає текст висоти — скажімо, 12.81pt — у свій ключ дедуплікації поряд із прапорцями приховування й розриву сторінки, тож кожен рядок у діапазоні ділить один стиль ro<N> з єдиною властивістю 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);
// Порожній хвіст пишеться жменькою повторених рядків, а не мільйоном
Workbook.SaveAsODS(Saved);
Writeln('ODS size: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.Open(Saved);
// Перекриття, дірка й діапазон усі переживають 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); // висота діапазону
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
Тест, який це фіксує, перевіряє, що збережений потік менший за 64 KB для аркуша, чий діапазон висот охоплює 1,048,575 рядків із перекриттям і пробитою посередині діркою. Поруч із цим числом належить назвати дві чесні межі. По-перше, аркуш із будь-якими валідаціями даних ставить contentMaxRow у maxRow, тож валідації вимикають компакцію хвоста на цьому аркуші, і він знову пишеться рядок за рядком. По-друге, у XLSX немає атрибута повтору — <row> у SpreadsheetML описує один рядок, — тож експорт аркуша, що тримається на діапазоні, у .xlsx перелічує рядки, які діапазон покриває, і пише атрибут ht на кожному. Модель лишається компактною в пам'яті; формат файлу вирішує, як файл виглядає
Що тепер винна кожна правка, яка перенумеровує рядки?
Обслуговування. Нове представлення метаданих рядків правильне лише тоді, коли кожна операція, що змінює номери рядків, рухає його разом зі списками на рядок, поряд із якими воно сидить, і коміт торкається кожної з цих операцій. InsertRows і DeleteRows проходять через XlsxShiftRowHeightRuns, який перебудовує масив, зберігаючи ту частину кожного діапазону, що лежить до точки правки, відкидаючи все, що потрапляє у вікно видалення, і додаючи залишок зі зсувом на дельту — тож діапазон, що перетинає вставку, стає двома діапазонами з проміжком, а діапазон, що перетинає видалення, коротшає. TileRangeAxisMetadata очищає діапазони по всьому розмноженому проміжку, а тоді реєструє кожен вихідний діапазон по разу на копію зі своїм зсувом. TXLSXWorksheet.CopyFrom і TXLSXSheets.AddCopy беруть Copy() масиву, а не присвоюють його, саме тому тест може очистити всі висоти на клоні й усе одно знайти оригінальний аркуш цілим на рядку 1,048,576
var
Sheet: TXLSXWorksheet;
begin
Sheet := Workbook.Sheets[1];
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// Вставити два рядки на 500000: перекриття зсувається на 500002, дірка — на 500003
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // False
// Видалити їх знову: усе зсувається назад
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
// Розмножити рядки 2..4 двічі вниз по аркушу; висоти діапазону йдуть за кожною копією
Sheet.TileRangeAxisMetadata(2, 1, 3, 1, 2, 1);
Writeln(Sheet.RowHeight[7]:0:2); // висота діапазону
end;
Межі на боці читання мають той самий обов'язок. GetUsedRange піднімає свій нижній край до FirstRow і LastRow кожного діапазону, а BuildRowMajorCellOrder розтягує свій максимум рядка з урахуванням метаданих крізь кожен діапазон, щоб записувач XLSX усе одно відвідав рядки, що мають лише висоту. Якщо ви колись додасте власну структуру з ключем за рядком поверх об'єктної моделі HotXLS, ось чекліст: вставка, видалення, розмноження, копіювання, використаний діапазон і кожен серіалізатор. Пропустіть один — і збій буде тихим: висоти попливуть на кількість вставок, і ніщо не кине винятку
Що лишається на рядок і як тепер виглядають числа
Прапорці приховування, рівні структури й згорнутий стан усе ще розгортаються. Фінішер рядків у циклі викликає SetRowHidden і SetRowOutlineLevel по разу на кожен повторений рядок, тож аркуш, який ховає мільйонний хвіст або вкладає його в table:table-row-group, платить записом на рядок за кожен із цих атрибутів. Зміна 2.382.2 обмежена двома речами, які HXLS-005 власне й міряв — висотами й успадкованими стилями порожніх клітинок, — а той самий прийом із діапазонами застосувався б і до решти, якби якийсь файл цього зажадав. Читач ODS до того ж не реагує на style:use-optimal-row-height; стиль рядка, який каже «оптимальна» і дає висоту, імпортується саме з цією висотою
На корпусі conditional-formatting.ods тепер проходить цикл відкриття, твердження, збереження, повторного відкриття й повторного твердження за 0.178 секунди на Win32 і 0.158 секунди на Win64, причому сама стадія відкриття — 0.020 секунди, у межах 60-секундного бюджету, який він раніше вичерпував. Інтерфейси рівня книги, крізь які течуть формати, описані в огляді відкриття й збереження файлів ODS, а ширший набір важелів для великих файлів — у статті про продуктивність великих книг; сам елемент рядка ODF із його атрибутами повтору й стилю специфіковано в ODF 1.3 Part 3 §9.1.4
HotXLS читає та пише XLS, XLSX і ODS з нативного коду Delphi і C++Builder без встановлених Excel чи LibreOffice — саме тому повтор на мільйон рядків це те, що бібліотека мусить добре змоделювати, а не передати зовнішньому процесу; на сторінці HotXLS Delphi spreadsheet component перелічено підтримувані формати й версії RAD Studio