HotXLS Delphi Excel Component сохраняет сводные таблицы OpenDocument за цикл открытия и сохранения ODS: начиная с v2.382.0 он захватывает поддерево <table:data-pilot-tables> из content.xml дословно при открытии и воспроизводит его при сохранении. Начиная с v2.382.1 фрагмент несёт ещё и все привязки пространств имён XML, объявленные его предками, так что сохранённое определение сводной таблицы остаётся корректным для любого потребителя, а не только для HotXLS
Баг, который вынудил обе правки, вылез из строгого прогона по корпусу. Пример official-pivot.ods, записанный development-сборкой LibreOffice 6.1, содержит одну сводную таблицу с именем DataPilot1, которая читает Sheet1.A2:E30 и кладёт результат в Sheet1.G6:J18. Откройте его в HotXLS, сохраните без изменений, посчитайте элементы <table:data-pilot-table> в выводе: на входе один, на выходе ноль, и одинаково под Win32 и Win64. Ничто в тесте сводную таблицу не трогало. Первая серия проб сравнивала только константы ячеек и проходила; потерю вскрыла именно структурная проверка, а это напоминание, что «значения совпадают» — слабое определение точности кругового рейса
Почему сводная таблица ODS исчезает после сохранения библиотекой?
Сводная таблица ODS исчезает потому, что у HotXLS нет модели в памяти для сводных таблиц OpenDocument, а писатель ODS строит content.xml целиком из модели. Писатель собирает автоматические стили, по одному <table:table> на рабочий лист, <table:content-validations>, <table:named-expressions> и <table:database-ranges>, и всё это генерируется из объектов, которые книга действительно держит. Определению сводной таблицы — ODF 1.3 Part 3 §9.6, контейнер <table:data-pilot-tables> с одним <table:data-pilot-table> на сводную таблицу, несущий свой table:source-cell-range, свои дочерние table:data-pilot-field, свой table:target-range-address и table:buttons, — жить не в чем, поэтому перегенерированная часть его просто опускает
Контраст с XLSX намеренный. HotXLS разбирает кеши и сводные таблицы SpreadsheetML в настоящую модель, которую можно строить, расширять вычисляемыми полями и обновлять из Delphi, поэтому они переживают сохранение: их перезаписывают, а не копируют. Сводные таблицы ODS — куда более редкий запрос, и моделировать словарь data pilot из ODF только ради кругового рейса означало бы написать много кода, который никто не редактирует. Прагматичный ответ — тот же, что HotXLS уже применяет к неизвестным блокам extLst в XLSX: храни то, что не моделируешь, байт в байт, если можешь, и событие за событием, если нет
Что было не так в первом захвате через Pos?
Захват в v2.382.0 вырезал определение сводной таблицы из content.xml как обычную строку, и в вырезанном куске не хватало объявлений пространств имён, которые делали его осмысленным. Реализация была ровно такой короткой, как звучит: декодировать часть в WideString, найти открывающий тег через Pos, найти после него закрывающий, скопировать промежуток в FRawOdsDataPilotTablesXml у книги
// HotXLS v2.382.0 -- заменено через один релиз
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
OpenTag: WideString = '<table:data-pilot-tables';
CloseTag: WideString = '</table:data-pilot-tables>';
var
Text: WideString;
StartPos, ClosePos: Integer;
begin
Result := '';
Text := LoadPartAsWideString(Stream); // весь content.xml в памяти
StartPos := Pos(OpenTag, Text);
if StartPos = 0 then Exit;
ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
if ClosePos = 0 then Exit;
Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;
Проверка на количество стала зелёной, и фикс вышел. Поймала его вторая, более строгая проверка, добавленная в тот же день: каждая XML-часть сохранённого пакета скармливается независимому парсеру, понимающему пространства имён, вне HotXLS, и этот парсер отверг новый content.xml с ошибкой необъявленного префикса. Сводная таблица из LibreOffice несёт атрибуты-расширения производителя — loext:ignore-selected-page="true" на поле страницы, calcext:repeat-item-labels="false" на каждом уровне, — и в вырезанной строке эти атрибуты были, а объявлений xmlns:loext и xmlns:calcext, которые их привязывают, не было. Эти объявления стояли в корне <office:document-content> исходного файла, все тридцать пять, в двух тысячах символов от сводной таблицы
W3C Namespaces in XML 1.0 §6.1 определяет правило, из-за которого это жёсткий отказ, а не косметический: объявление пространства имён действует от начального тега элемента, на котором оно стоит, до конечного тега этого элемента, и каждое имя с префиксом внутри этой области разрешается по нему. Вырезаете поддерево из документа — вырезаете его и из области. HotXLS пишет свой корень <office:document-content> с одиннадцатью объявлениями — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo, — так что calcext: случайно разрешался, table: случайно разрешался, а loext: — нет. Парсер, понимающий пространства имён, считает необъявленный префикс нарушением корректности, а значит нечитаемой становится вся часть, а не один атрибут
Как HotXLS переносит привязки xmlns предков на фрагмент?
HotXLS v2.382.1 заменил строковую вырезку проходом по content.xml через собственный потоковый TXMLReader, поддерживая стек привязок пространств имён с меткой глубины, на которой каждая была объявлена, и копируя привязки, всё ещё действующие, на корневой элемент фрагмента в момент достижения цели. Читатель работает с включённым PreserveWhitespaceText, чтобы текстовые узлы возвращались точно так, как записаны, а пересобранные теги используют TXMLReader.RawName и TXMLReader.Attribute[I].RawName — написание префикса из файла, — а не канонические имена, которые читатель обычно отдаёт парсерам частей. Вот ядро цикла:
// Namespaces: TStringList из 'xmlns:p=uri' с глубиной объявления в Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // элемент, текст, CDATA, комментарий
if Reader.NodeType = xmlntElement then
begin
for I := 0 to Reader.AttributeCount - 1 do
begin
AttrName := Reader.Attribute[I].RawName;
if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
TObject(NativeInt(Depth)));
end;
if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
begin
Opening := XlsxRawXmlReaderOpenTag(Reader); // сначала срезать завершающий '>' или '/>'
...
// Переносим действующие привязки предков на корень фрагмента.
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // побеждает самая внутренняя привязка
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // уже объявлено здесь? пропускаем
Opening := Opening + ' ' + AttrName + '="' +
XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
end;
...
CaptureDepth := Depth;
end;
if not Reader.IsEmptyElement then Inc(Depth);
end
else if Reader.NodeType = xmlntEndElement then
begin
Dec(Depth);
if Depth = CaptureDepth then Exit; // поддерево закрыто
end;
if (Reader.NodeType = xmlntEndElement) or
((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
while (Namespaces.Count > 0) and
(NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
Namespaces.Delete(Namespaces.Count - 1); // покидаем область
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Корректность держат три детали этого цикла. Обход стека от самой внутренней привязки наружу с запоминанием каждого префикса в Seen реализует затенение: если ближний предок переобъявляет xmlns:table, побеждает ближнее значение — ровно так, как требует §6.1. Пропуск префиксов, которые элемент объявляет сам, избавляет от выдачи одного и того же атрибута дважды, а это была бы уже другая ошибка корректности. И правило снятия срабатывает на конечных тегах и на пустых элементах, потому что <x/> никогда не порождает событие EndElement, — та же ловушка самозакрытия, которую пришлось выучить захвату extLst в XLSX. Совпадение с целью по Reader.Name, а не по RawName, — победа потише: читатель канонизирует URI пространства имён таблиц ODF к префиксу table, так что производитель, пишущий t:data-pilot-tables, всё равно совпадёт, а выданный фрагмент сохранит тот префикс, который использовал производитель
Цикл ещё и отказывается угадывать. Если часть заканчивается, пока захват всё ещё открыт, — обрезанный или битый content.xml, — OdsCaptureDataPilotTablesXml возбуждает исключение, а не возвращает половину фрагмента, потому что половина фрагмента была бы записана обратно при сохранении и превратила бы повреждённый вход в повреждённый выход с именем библиотеки на нём
Куда попадает фрагмент в сохранённом content.xml?
HotXLS пишет захваченный фрагмент в <office:spreadsheet> сразу после сгенерированного им <table:named-expressions> и перед <table:database-ranges>. Модель содержимого <office:spreadsheet> из ODF 1.3 Part 3 предписывает фиксированную последовательность для этих хвостовых детей, так что дословный блок нельзя просто дописать туда, где писатель в данный момент оказался; его нужно положить в конкретный слот. Со стороны вызывающего здесь нет ни API, ни чего-либо настраиваемого: определение едет вместе с обычным открытием и сохранением
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.OpenODS('official-pivot.ods') <> 1 then
raise Exception.Create('open failed');
Book.Sheets[0].Cells[2, 5].Value := 1250.0; // правка внутри исходного диапазона сводной таблицы
Book.SaveAsODS('official-pivot-out.ods');
// content.xml в выводе по-прежнему несёт DataPilot1 с его
// исходным диапазоном, полями, целевым диапазоном, кнопками и атрибутами loext:/calcext:
finally
Book.Free;
end;
end;
Избыточность намеренная, и о ней стоит знать. Корень фрагмента теперь повторяет xmlns:table и xmlns:calcext, хотя сохранённый корень документа объявляет их и сам; Namespaces in XML разрешает переобъявлять префикс во вложенной области, так что дубликаты безвредны. Для примера из LibreOffice перенесённый набор — все тридцать пять объявлений корня, около двух килобайт поверх определения в 8357 символов, потому что захват не анализирует, какие префиксы поддерево реально использует. Сканирование использованных префиксов это урезало бы, и, возможно, оно появится позже; сначала корректность, потом компактность
Правило вырезания поддеревьев из XML для дословного воспроизведения
Общий урок таков: поддерево становится самодостаточным только после того, как вы его таким сделали, и область пространств имён — первое, что ломается, когда об этом забываешь. Чек-лист, который HotXLS теперь применяет к любому захвату по принципу «храни то, что не моделируешь»:
- Идите по документу настоящим читателем и отслеживайте действующие привязки. Поиск строкой через
Posобласти не видит вообще, и он же ошибается на вложенных элементах с тем же именем, на совпадающей строке внутри комментария или секцииCDATAи на значениях атрибутов, которые случайно содержат текст тега - Копируйте действующие привязки на корень фрагмента, от самой внутренней наружу, по разу на префикс, пропуская то, что корень объявляет сам
- Сохраняйте исходное написание префиксов в выдаваемых тегах; сопоставляйте цель по разрешённому пространству имён, а не по литеральному префиксу
- Сохраняйте текстовые узлы из пробелов и помните, что пустой элемент закрывает собственную область без события конечного тега
- Проверяйте сохранённую часть парсером, который не является тестируемой библиотекой. Библиотека с удовольствием перечитает собственный вывод тем же снисходительным путём кода, которым его записала
Последний пункт — как раз тот, что нашёл HXLS-003 во второй раз. Приёмочная проверка в v2.382.0 была регулярным выражением, считавшим открывающие теги data-pilot-table в сохранённом content.xml, а регулярное выражение видит тег, а не документ: ему всё равно, привязаны ли префиксы на этом теге. Строгий раннер по корпусу, добавленный в v2.382.1, разбирает каждую часть XML и .rels сохранённого пакета парсером, понимающим пространства имён, и затем сравнивает дерево сводной таблицы — тег, отсортированные атрибуты, текст, дочерние узлы, рекурсивно — с оригиналом. Сравнение идёт с раскрытыми пространствами имён, поэтому переписанный префикс всё равно прошёл бы, а необъявленный — не может
Где заканчивается гарантия дословности
Дословное воспроизведение сохраняет определение; оно его не понимает, и границы следуют из этого. HotXLS не выставляет API для чтения, правки или обновления сводной таблицы ODS, поэтому FRawOdsDataPilotTablesXml — внутреннее поле, а наблюдаемо только то, что определение выживает. Фрагмент сериализуется заново из событий читателя, а не копируется байтами: кавычки атрибутов и самозакрывающиеся формы нормализуются, а текст и пробелы сохраняются. Захваченный XML выдаёт только писатель содержимого ODS, поэтому книга, открытая из .ods и сохранённая как .xlsx, теряет сводную таблицу, а книге, открытой из .xlsx, нечего воспроизводить в сохранение .ods — асимметрии путей импорта и экспорта ODS действуют здесь как и везде. И поскольку определение непрозрачно, оно не может следовать за вашими правками: переименуйте Sheet1 или перенесите исходные данные в HotXLS, и сохранённая сводная таблица всё ещё указывает на Sheet1.A2:E30, оставляя потребителю сообщать о сломанном диапазоне при следующем обновлении. Сюда же относится одна оговорка про порядок: HotXLS выдаёт диапазоны AutoFilter как <table:database-ranges> после фрагмента сводной таблицы, а в примере корпуса диапазона базы данных нет, так что книгу, где есть и фильтр, и сводная таблица, стоит прогнать через валидатор схем ODF, прежде чем полагаться на взаимный порядок этих двух элементов
Тестируйте на файлах своего производителя, а не только на примере из корпуса. Перенос пространств имён обрабатывает любой префикс, объявленный производителем на предке, но документ, объявляющий префикс на самом элементе сводной таблицы или использующий пространство имён по умолчанию для словаря таблиц, задействует ветви пропуска и затенения, которых пример из LibreOffice не трогает. Обе реализованы; ни у одной пока нет примера в корпусе, и это ровно та разница, которую запись в changelog обычно размывает
Дословный захват data pilot в v2.382.0 и фикс области пространств имён в v2.382.1 поставляются в текущем HotXLS Delphi Excel Component, на странице продукта которого перечислено полное покрытие чтения и записи ODS, XLSX и XLS для Delphi и C++Builder