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

Безубыточный round-trip файлов XLSX в Delphi: Theme, extLst, calcChain

Библиотека HotXLS для Delphi и C++Builder разработана для безубыточных (lossless) циклов обработки XLSX: открытие книги, изменение одной ячейки, сохранение — и при этом сохраняются тема пользователя, внешние блоки расширений extLst и цепочка вычислений. Это обеспечивается тремя механизмами: точным кэшированием xl/theme/theme1.xml, повторной сериализации неизвестных блоков <ext> на основе событий и созданием новой, валидной спецификации xl/calcChain.xml при каждом сохранении книги с формулами

Сценарий, требующий всех этих механизмов, встречается удручающе часто. Служба выставления счетов загружает шаблон, разработанный клиентом в Excel (фирменные цвета, спарклайны в колонке KPI, правило условного форматирования, добавленное в новой версии Excel), записывает сумму счета в ячейку B3 и сохраняет файл. Когда клиент открывает результат, фирменные цвета сбрасываются до стандартного синего стиля Office, спарклайны исчезают, а Excel предлагает «восстановить» файл. Ничто в коде не затрагивало эти элементы, виновником была библиотека, которая просто сохранила файл

Почему файлы Excel теряют форматирование после редактирования библиотекой?

Файлы Excel теряют форматирование после изменений библиотекой, потому что большинство библиотек не редактируют файл напрямую, а пересобирают его с нуля. Пакет .xlsx — это архив ZIP с XML-частями: xl/workbook.xml, по одному файлу xl/worksheets/sheetN.xml на лист, xl/styles.xml, xl/theme/theme1.xml, xl/calcChain.xml и другими. Типичная библиотека разбирает эти части в объектную модель при открытии и полностью регенерирует каждую из них при сохранении. Любая функция, которую модель не поддерживает (например, тема, которую библиотека не разбирала, или блок расширения новой версии Excel), не сохраняется в памяти, и в пересозданном файле она просто исчезает

Стандарт ECMA-376 предусматривал эту проблему. Спецификация SpreadsheetML определяет элемент extLst (ECMA-376 Part 1, «Область хранения будущих данных функций», §18.2.10 для элемента уровня книги) как выделенную точку расширения: более новые генераторы размещают там новые возможности, упаковывая каждую в элемент <ext> с атрибутом uri, идентифицирующим эту функцию. Старые же программы чтения должны сохранять то, что они не поддерживают. Спарклайны, срезы и новые типы условного форматирования передаются именно так. Библиотека, отбрасывающая неизвестные блоки <ext>, не просто работает с потерями — она нарушает контракт обратной совместимости, на котором основан формат. Вопрос к любой оцениваемой вами библиотеке прост: если я изменю одну ячейку, что еще изменится в файле?

Как HotXLS сохраняет пользовательскую тему побайтово?

HotXLS сохраняет тему книги, кэшируя исходные байты xl/theme/theme1.xml при открытии и записывая их обратно без изменений при сохранении. Раздел темы (ECMA-376 Part 1, §14.2.7) относится к DrawingML, а не к SpreadsheetML (цветовые, шрифтовые схемы, схемы форматирования), и табличному движку нет необходимости глубоко его разбирать. Ранние версии HotXLS генерировали стандартную тему Office при каждом сохранении, что приводило к сбросу фирменных цветов. С версии v2.89.46 тема открытого пакета сохраняется в исходном виде и записывается без изменений, а встроенная тема Office создается только для книг, создаваемых с нуля. Использование исходных байтов — это максимальная гарантия точности: никакого разбора, никакой повторной сериализации, никаких шансов на расхождения

Точное копирование имеет приоритет над программным доступом к свойствам темы. Класс TXLSXWorkbook предоставляет свойства ThemeMajorFont и ThemeMinorFont для выбора шрифтов заголовков и основного текста новых книг. Однако если при открытии файла была зафиксирована исходная тема, эти сеттеры не влияют на сохраняемый файл — приоритетом является сохранение исходной структуры. Если вам действительно нужно изменить тему существующей книги, это указывает на необходимость редактирования шаблона в самом Excel, а не через программный API. В повседневных же задачах никакой API для этого не нужен:

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('branded-invoice.xlsx');
    Book.Sheets[0].Cells[3, 2].Value := 42750.00;  // то самое единственное изменение
    Book.SaveAs('branded-invoice-out.xlsx');
    // файл theme1.xml на выходе побайтово идентичен входному
  finally
    Book.Free;
  end;
end;

Что происходит с неизвестными блоками extLst при сохранении?

Важной деталью реализации является то, что фиксация блоков представляет собой повторную сериализацию на уровне событий, а не просто копирование байтов. Потоковый XML-ридер HotXLS не предоставляет смещений источника, поэтому неизвестное поддерево перестраивается на основе событий Element, Text и EndElement по мере их считывания. Этот подход таит классическую ловушку: самозакрывающийся элемент (например, <a/>) вызывает только событие Element с флагом пустого тега и никогда не отправляет EndElement, поэтому любой счетчик глубины, уменьшающийся только на EndElement, никогда не распознает закрытие поддерева. С учетом этого восстановленный фрагмент семантически эквивалентен оригиналу — кавычки атрибутов и самозакрывающиеся теги нормализуются, поэтому фрагмент не идентичен побайтово, но Excel считывает именно смысл, а не байты. Безопасность повторной записи гарантируют два свойства собственного вывода Excel: он объявляет необходимые атрибуты xmlns на самом элементе <ext> или внутри него, делая каждый фрагмент самодостаточным. Благодаря этой же самодостаточности дублирование листа внутри книги или между книгами переносит сторонние блоки с помощью простого присваивания списка строк

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  i: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('from-newer-excel.xlsx');
    Sheet := Book.Sheets[0];
    WriteLn(Format('захвачено сторонних блоков расширений: %d',
      [Sheet.RawWorksheetExts.Count]));
    for i := 0 to Sheet.RawWorksheetExts.Count - 1 do
      WriteLn(Copy(Sheet.RawWorksheetExts[i], 1, 100)); // просмотр каждого uri
  finally
    Book.Free;
  end;
end;

Важной деталью реализации является то, что фиксация блоков представляет собой повторную сериализацию на уровне событий, а не просто копирование байтов. Потоковый XML-ридер HotXLS не предоставляет смещений источника, поэтому неизвестное поддерево перестраивается на основе событий Element, Text и EndElement по мере их считывания. Этот подход таит классическую ловушку: самозакрывающийся элемент (например, <a/>) вызывает только событие Element с флагом пустого тега и никогда не отправляет EndElement, поэтому любой счетчик глубины, уменьшающийся только на EndElement, никогда не распознает закрытие поддерева. С учетом этого восстановленный фрагмент семантически эквивалентен оригиналу — кавычки атрибутов и самозакрывающиеся теги нормализуются, поэтому фрагмент не идентичен побайтово, но Excel считывает именно смысл, а не байты. Безопасность повторной записи гарантируют два свойства собственного вывода Excel: он объявляет необходимые атрибуты xmlns на самом элементе <ext> или внутри него, делая каждый фрагмент самодостаточным. Благодаря этой же самодостаточности дублирование листа внутри книги или между книгами переносит сторонние блоки с помощью простого присваивания списка строк

Запись calcChain.xml для корректной обработки формул в Excel

HotXLS записывает xl/calcChain.xml (Calculation Chain, ECMA-376 Part 1, §12.3.1) при каждом сохранении книги с формулами, выбирая один из двух порядков. Если граф зависимостей формул уже построен и актуален (вы вызвали Recalculate после последнего изменения), цепочка выводится в полном топологическом порядке (зависимости перед зависимыми ячейками), а участники циклических ссылок добавляются в конец. В противном случае ячейки перечисляются в порядке документа. Оба варианта верны: примечания по реализации Microsoft для этого формата [MS-XLSX] рассматривают цепочку вычислений как подсказку, которую Excel проверяет и переупорядочивает при загрузке. Поэтому любое полное перечисление легитимно, и HotXLS намеренно не принуждает перестраивать граф внутри SaveAs, так как создание связей зависимостей имеет квадратичную сложность от числа ячеек, что недопустимо при сохранении книги с миллионом ячеек

Book.Open('model.xlsx');
Book.Sheets[0].Cells[10, 4].Formula := '=SUM(D2:D9)';
// При сохранении сейчас calcChain.xml перечислит ячейки в порядке документа.
// После Recalculate создается граф зависимостей, поэтому то же сохранение
// выведет данные в полном топологическом порядке:
Book.Recalculate;
Book.SaveAs('model-out.xlsx');

Зачем заботиться о части, которую Excel считает рекомендательной? Потому что её отсутствие является важным сигналом. Некоторые инструменты (эвристика восстановления данных, сторонние просмотрщики, утилиты сравнения) ожидают наличия цепочки вычислений в книге с формулами, и библиотека, которая удаляет эту часть при сохранении, создает файлы, отличающиеся от структуры Excel. Запись валидной цепочки сохраняет структуру вывода в рамках стандартов экосистемы, что является ключевой и незаметной задачей round-trip инженерии

Где заканчиваются возможности безубыточного round-trip

Объективность здесь важнее маркетинговых лозунгов, поэтому ограничения заслуживают отдельного упоминания. HotXLS не копирует весь пакет побайтово: XML-код листов, стили, общие строки и структура книги восстанавливаются на основе разобранной модели. Поэтому вывод семантически точен, но не идентичен бинарно — даже локальные заголовки ZIP содержат новые DOS-метки времени. Блоки <ext> возвращаются нормализованными, как описано выше. Программное переопределение шрифтов темы игнорируется при наличии оригинальной темы. Сеть сохранения имеет определенные границы: функции, которые HotXLS поддерживает нативно (например, спарклайны разбираются и записываются заново, а не просто копируются), плюс содержимое внешних extLst, плюс кэшированные без изменений разделы. Части, которые не моделируются и не находятся в точках расширения (например, кастомная часть сторонней надстройки), выпадают из описанных механизмов, поэтому проверяйте свои шаблоны на практике

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

Механизмы безубыточного round-trip, описанные здесь (сохранение оригинальной темы с версии v2.89.46, фиксация сторонних extLst и запись calcChain.xml с версии v2.131.0), поставляются в составе актуального компонента HotXLS Delphi Excel Component, страница которого содержит описание всех функций чтения-записи XLSX для Delphi и C++Builder