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

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

Электронная таблица с четырнадцатью столбцами и 45 строками данных, сохранённая как ODS, обычно кончается одним лишним элементом: <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>. Эта единственная строка описывает все строки с 46-й до низа сетки. Развернуть её буквально — как эту строку, выписанную 1 048 530 раз, — и маленький content.xml превращается в миллион записей строк, миллион записей высот и, если столбцы несут стиль ячейки по умолчанию, почти пятнадцать миллионов стилизованных пустых ячеек

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

Почему одну повторённую строку дорого импортировать

Цена приходит от разворачивания, а разворачивание — очевидный способ написать импортёр. Стиль ro1 задаёт style:row-height="0.452cm", то есть 12,81 пункта. Финишер строк, применяющий его вызовом SetRowHeight(RowIndex + i, RowHeight) на каждую повторённую строку, пишет 1 048 530 записей в какую бы ни было построчную таблицу высот в объектной модели. Если эта таблица — ключевой строковый список, где каждая запись сперва ищет существующий ключ, импорт квадратичен по счётчику повторов, а счётчик в миллион — ровно тот вход, где квадратичность перестаёт быть теоретической

Пустые ячейки усугубляют. Каждый <table:table-column> в таком файле обычно называет table:default-cell-style-name, и каждая пустая ячейка в повторённой строке его наследует. Стилизованная пустышка для большинства объектных моделей всё ещё ячейка. Материализуйте по одной на столбец на повторённую строку — и счёт за память затмевает таблицу высот

У сохранения зеркальная проблема. Когда модель говорит, что строка 1 048 575 имеет высоту, писатель, идущий от строки 1 до последней строки с чем-либо на борту, выдаст миллион элементов <table:table-row>. Дешёвый импорт с последующим дорогим сохранением лишь переезжает ценой на другую кнопку

Как HotXLS хранит высоту повторённой строки

HotXLS Delphi Excel Component хранит высоту повторённой строки ODS набором: запись TXLSXRowHeightRun с FirstRow, LastRow и одной Height. Когда TXLSXWorkbook.OpenODS заканчивает строку со счётчиком повторов 1, он зовёт SetRowHeight, как любой другой код. Для любого большего счётчика он записывает весь отрезок одним набором, так что хвост из примера становится строками с 46 по 1 048 575 при 12,81 пт. Счётчик повторов, выходящий за конец сетки, обрезается по строке 1 048 576, а не отвергается

Одно правило импортёра удивляет тех, кто сравнивает результат с исходным файлом. Табличные приложения, пишущие ODS, склонны давать каждому стилю строки style:row-height, включая строки, которые никто никогда не менял, и помечать их style:use-optimal-row-height="true". Для строки, не хранящей ни одной ячейки, автоматическая высота — просто высота по умолчанию, так что HotXLS не записывает для неё высоту. Строки, что-то хранящие, сохраняют высоту, даже если она помечена оптимальной, потому что строка с переносом текста, выросшая под содержимое, иначе сжалась бы обратно при повторном открытии книги как XLSX. Этот компромисс правильный: потеря реальной высоты даёт видимое изменение вёрстки, а хранение избыточной стоит одного набора

Чтение высот обратно идёт через обычное свойство RowHeight. Оно сперва проверяет построчный список и падает к наборам, только когда у строки нет явной записи; HasRowHeight следует тому же порядку. Наборы не пересекаются, так что откат останавливается на первом наборе, содержащем строку

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Spans: TXLSXAxisSpans;
  I: Integer;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    // Лист, чей последний элемент строки повторяется 1 048 530 раз
    Workbook.OpenODS('tail-repeat.ods');
    Sheet := Workbook.Sheets[1];  // Sheets нумеруется с единицы
    // Хранимые метаданные строк: сначала записи на одну строку, затем наборы
    Spans := Sheet.GetStoredAxisSpans(True);
    for I := 0 to High(Spans) do
      Writeln(Spans[I].FirstIndex, '..', Spans[I].LastIndex);
    // Оба чтения приходят из одного набора
    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;

Две правки в этом примере ведут себя по-разному нарочно. Sheet.RowHeight[500000] := 36 не трогает набор и добавляет одну запись в построчный список, который затеняет набор, потому что опрашивается первым. ClearRowHeight так работать не может, поскольку очищенная строка должна сообщать об отсутствии высоты, даже если набор её по-прежнему накрывает. Поэтому он удаляет любую построчную запись, а затем пересобирает список наборов: каждый набор вне очищенной строки остаётся как есть, накрывший её набор расщепляется на часть до и часть после, а для самой строки 500 001 ничего не хранится. GetStoredAxisSpans(True) теперь перечисляет 46..500000 и 500002..1048575 как два отдельных набора рядом с однострочной записью для 500 000. ClearRowHeights опустошает построчный список и наборы одним вызовом

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

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

Унаследованные стили пустых ячеек становятся стилевыми оверлеями: прямоугольниками на листе, несущими стиль, без объектов ячеек позади. Читая строку, импортёр идёт по повторённым ячейкам промежутками соседних столбцов, разрешающимися в один и тот же стиль. Промежуток, чей стиль выглядит в точности как Default, не хранит ничего. Промежуток с реальным форматированием, скажем центрированным или жирным столбцовым дефолтом, становится одной строкой-значением, покрывающей все его столбцы

Когда это строковое значение фиксируется и строка повторяется, HotXLS материализует единственную ячейку в левом верхнем углу промежутка, применяет к ней унаследованный стиль и регистрирует шесть индексов стиля (шрифт, заливка, граница, числовой формат, выравнивание, защита) через Sheet.StyleOverlays.Add на весь прямоугольник: каждый столбец промежутка, от первой повторённой строки до последней. Для остальных 1 048 529 строк больше ничего не создаётся. Sheet.Cells.Count остаётся близким к числу ячеек, действительно держащих данные

Ячейка внутри прямоугольника подхватывает оверлей в момент, когда ваш код её касается. После открытия листа, чей хвост отформатирован жирным столбцовым дефолтом, Sheet.Cells[700000, 1].FontIndex разрешается в жирный шрифт, хотя ячейки на этой координате строкой раньше не существовало. Файлы XLSX с отформатированным, но пустым столбцом идут через ту же механику, и статья о построчном хранении ячеек блоками и интервальных стилевых оверлеях разбирает, как несколько оверлеев наслаиваются и разрешаются. Импортёр ODS отличается только источником оверлеев: он выводит их из счётчиков повторов самостоятельно

Как SaveAsODS записывает счётчик повторов обратно

TXLSXWorkbook.SaveAsODS пишет каждый отрезок одинаковых строк одним <table:table-row> с table:number-rows-repeated и заканчивает отрезок только там, где в строках что-то меняется. Таблица тянется до нижней строки, несущей хоть что-то: значение, пустую ячейку только со стилем, однострочную высоту, конец набора высот, нижний край оверлея, скрытую или структурную запись, область проверки данных или условного форматирования

От строки 1 писатель смотрит вперёд до следующей границы. Кандидаты — следующая материализованная ячейка, начало или конец набора высот, любая однострочная запись (высота, флаг скрытости, уровень структуры, свёрнутое состояние, ручной разрыв страницы), края оверлеев, объединённые диапазоны, гиперссылки, комментарии, картинки и края каждого прямоугольника проверок данных и условного форматирования. Всё до этой границы — один элемент. Внутри элемента та же логика идёт по столбцам, так что пустой отрезок становится одним <table:table-cell> с table:number-columns-repeated и именем стиля, в который разрешается оверлей

Стили строк разделяются. Высота строки форматируется как текст, 12.81pt для примера, и этот текст вместе с флагом скрытости и флагом разрыва страницы образует ключ автоматического стиля строки. Каждая строка с теми же тремя значениями указывает на один и тот же стиль ro<N>, держащий единственное свойство style:row-height. Строка без высоты, видимая и без разрыва, не получает стиля строки вовсе

Что пишет SaveAsODS для листа, опёртого на наборы: строки с 1 по 45 держат значения и пишутся по одному элементу на строку, хвост до строки 1048575 выдаётся повторёнными элементами table-row, расщепляющимися на краях наборов, построчных переопределений и краёв оверлеев, строки с одинаковой высотой делят один автоматический стиль строки, а стили ячеек разрешаются через оверлеи
Каждый повторённый элемент охватывает один однородный отрезок и останавливается на следующем крае набора, записи высоты, крае оверлея, области правил или материализованной ячейке, так что пустой хвост сохраняется горсткой элементов, а экспорт в XLSX пишет по одному элементу строки на каждую покрытую строку
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);
    // Хвост пишется несколькими повторёнными элементами строк
    Workbook.SaveAsODS(Saved);
    Writeln('ODS stream: ', Saved.Size, ' bytes');
    Saved.Position := 0;
    Reopened.OpenODS(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;

Для листа выше хвост расщепляется на четыре куска: строки с 46 по 499 999 на высоте набора, строка 500 000 на 36 пт, строка 500 001 без стиля строки и строки с 500 002 по 1 048 575 снова на высоте набора. Любой столбец, чей дефолтный стиль оставил оверлей, может добавить расщепление там, где оверлей кончается. 45 строк данных над хвостом пишутся по одному элементу на строку, потому что каждая из них держит ячейки

С XLSX другая история. У SpreadsheetML нет атрибута повтора, и элемент <row> описывает ровно одну строку, поэтому сохранение листа, опёртого на наборы, в .xlsx перечисляет строки, которые набор покрывает, и пишет ht и customHeight="1" на каждой из них. Модель остаётся компактной в памяти; размер файла решает целевой формат. Если книга приезжает как ODS и должна уйти как ODS, она никогда не платит эту построчную цену

Правки, перенумеровывающие строки

Второе представление высот строк корректно, только если каждая операция, двигающая строки, двигает и его. TXLSXWorksheet.InsertRows и DeleteRows сдвигают наборы вместе с построчными списками: часть набора до точки правки остаётся на месте, строки внутри удаляемого окна выбрасываются, а остальное двигается на число вставленных или удалённых. Набор, перекрывающий вставку, становится двумя наборами с промежутком неопределённых строк между ними, а набор, перекрывающий удаление, становится короче

procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
  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
  Writeln(Sheet.HasRowHeight(500000));   // FALSE: вставленные строки высоты не имеют
  // Удаляем их снова: переопределение и дыра возвращаются на старые номера строк
  Sheet.DeleteRows(500000, 2);
  Writeln(Sheet.RowHeight[500000]:0:2);  // 36.00
  Writeln(Sheet.HasRowHeight(500001));   // FALSE
  Writeln(Sheet.UsedRange.Row2);         // включает конец сдвинутого набора
end;

Круговой рейс не оставляет хвост ровно там, где он был. Вставка толкает хвостовой набор с 500 002..1 048 575 на 500 004..1 048 577, а сетка кончается строкой 1 048 576, так что часть за этой строкой отрезается. Удаление затем двигает оставшееся вверх на две, и набор кончается строкой 1 048 574: у строки 1 048 575 больше нет хранимой высоты. Если последние строки сетки вам важны, задайте их высоту заново после пары вставка-удаление. Строки, вставленные в середину набора, его высоту тоже не подхватывают: они открывают промежуток неопределённых строк и не наследуют высоту набора — вот почему HasRowHeight(500000) напечатал FALSE выше

Остальные операции, двигающие строки, следуют той же схеме. TileRangeAxisMetadata очищает наборы по всему размножаемому промежутку и затем регистрирует каждый набор источника по одному разу на копию со своим смещением. TXLSXWorksheet.CopyFrom и TXLSXSheets.AddCopy дают новому листу собственную копию списка наборов, так что ClearRowHeights на клоне оставляет высоты оригинала на месте. На стороне чтения UsedRange тянется до первой и последней строки каждого набора, а обход строк XLSX-писателя проходит сквозь наборы, так что строки только с высотой всё равно пишутся

Скрытые строки и уровни структуры хранятся построчно

Высоты строк и унаследованные стили пустых ячеек — вот две вещи, схлопывающиеся в наборы и оверлеи. Флаги скрытости, уровни структуры и свёрнутое состояние записываются по одному на строку. Повторённая строка, скрытая или сидящая внутри <table:table-row-group>, даёт по одной записи на повторённую строку для каждого из этих атрибутов, и при сохранении каждая такая запись — граница сама по себе, так что скрытый отрезок пишется одним <table:table-row> на строку

Для шаблонов под вашим контролем держите скрытую и сгруппированную часть листа настолько тесной, насколько требует данные. Скрыть двадцать строк под отчётом бесплатно. Скрыть всё со строки 46 до низа сетки — миллион записей на загрузке и миллион элементов строк на сохранении, и единственная видимая разница в том, что никто не может прокрутить дальше отчёта

Сторона формата уровня книги, включая то, какие возможности ODS отображаются на какие части объектной модели, — в разборе открытия и сохранения файлов ODS, а другие рычаги для больших файлов собраны в материале про производительность больших книг. Сам элемент строки с его атрибутами повтора и стиля определён в главе о таблицах ODF 1.3 Part 3

Повтор на миллион строк — деталь формата, которую нативный читатель обязан хорошо моделировать, ведь офисного набора на фоне, способного это поглотить, нет. Страница Delphi-компонента электронных таблиц HotXLS перечисляет возможности XLS, XLSX и ODS, которые он читает и пишет, вместе с поддерживаемыми версиями Delphi и C++Builder