Техническая статья

Повторяющиеся строки ODS как наборы высот в HotXLS

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 КБ. Ничто в файле не говорит «дорого»; дорого было целиком с нашей стороны

Как HotXLS превращает одну повторённую строку ODS в компактное состояние: элемент content.xml с table:number-rows-repeated 1048530 и стилем ro1 отображается в одну запись TXLSXRowHeightRun, охватывающую строки с 46 по 1048575 высотой 12,81 пт, плюс одна запись StyleOverlays на столбец, тогда как версия 2.382.1 разворачивала тот же элемент в миллион вызовов SetRowHeight и объектов ячеек
Счётчик повторов, высота строки ro1 и стили столбцов по умолчанию описывают каждую пустую строку ниже строки 45, поэтому импортёр может построить одну запись набора и оверлеи по столбцам, не касаясь миллиона координат

Почему одна повторённая строка уводит импорт 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 опустошает обе структуры разом

Хирургия набора высот строк в HotXLS: после OpenODS один набор покрывает строки с 46 по 1048575 высотой 12,81 пт, тогда как построчная запись ставит строке 500000 высоту 36 пт и побеждает в поиске, потому что GetRowHeight сначала проверяет список по строкам, а ClearRowHeight для строки 500001 расщепляет набор на две непересекающиеся части вокруг дыры
XlsxAssignRowHeightRun копирует части вне очищенного интервала и ничего не добавляет для самого интервала, поэтому наборы остаются непересекающимися по построению, а поиск может остановиться на первом попадании, пока переопределение в строке 500000 остаётся нетронутым

Куда деваются унаследованные стили пустых ячеек?

В один интервальный стилевой оверлей на столбец, а не в объекты ячеек. OdsCommitRow по каждому значению столбца решает, компактная ли это пустая ячейка: строка повторяется больше одного раза, у ячейки нет ни значения, ни формулы, ни форматированного текста. Для компактной пустой он создаёт настоящую ячейку только на первой строке набора, применяет к ней унаследованный стиль и затем регистрирует те же шесть индексов стиля — шрифт, заливку, рамку, числовой формат, выравнивание, защиту — как 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

Что пишет SaveAsODS для листа, опёртого на набор: contentMaxRow останавливается на строке 45, где заканчиваются значения, тогда как maxRow тянется через набор высот и его переопределения; строки выше границы пишутся по одной, а хвост выдаётся как повторённые элементы table-row, стиль строки которых берётся из RowStyleFor, а стили ячеек разрешаются через оверлеи
Каждый повторённый элемент охватывает один однородный отрезок и останавливается на следующем крае набора, записи высоты, крае оверлея или материализованной ячейке, так что лист без проверок сохраняется горсткой элементов, а проверки или экспорт в XLSX платят построчно
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);
    // Переопределение, дыра и набор переживают круговой рейс
    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 КБ для листа, чей набор высот охватывает 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: стиль строки, который говорит «optimal» и при этом задаёт высоту, импортируется с этой высотой

На корпусе 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 для работы с электронными таблицами перечислены поддерживаемые форматы и версии RAD Studio