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

Рейс сводных таблиц ODS в Delphi: область имён XML

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

Что пропустил захват через Pos для official-pivot.ods в HotXLS: поддерево сводной таблицы несёт атрибуты-расширения loext и calcext, а объявления xmlns, которые их привязывают, стоят в корне office:document-content в тридцати пяти привязках оттуда, поэтому вырезанный фрагмент оставил каждый использованный им префикс необъявленным, и парсер, понимающий пространства имён, отверг весь content.xml
Объявление пространства имён действует от своего начального тега до конечного, а вырезание поддерева из документа вырезает его и из этой области, что превращает один атрибут в нечитаемую часть

Как HotXLS переносит привязки xmlns предков на фрагмент?

HotXLS v2.382.1 заменил строковую вырезку проходом по content.xml через собственный потоковый TXMLReader, поддерживая стек привязок пространств имён с меткой глубины, на которой каждая была объявлена, и копируя привязки, всё ещё действующие, на корневой элемент фрагмента в момент достижения цели. Читатель работает с включённым PreserveWhitespaceText, чтобы текстовые узлы возвращались точно так, как записаны, а пересобранные теги используют TXMLReader.RawName и TXMLReader.Attribute[I].RawName — написание префикса из файла, — а не канонические имена, которые читатель обычно отдаёт парсерам частей. Вот ядро цикла:

Как HotXLS v2.382.1 захватывает поддерево data pilot вместе с его областью пространств имён: потоковый проход TXMLReader держит стек привязок xmlns с метками глубины объявления, обходит его от самых внутренних к внешним на цели table:data-pilot-tables, учитывает затенение через множество Seen, пропускает префиксы, объявленные самим элементом, и снимает привязки как на конечных тегах, так и на пустых элементах
Сопоставление цели по каноническому имени у читателя сохраняет работоспособность для производителей, которые пишут префикс таблиц иначе, а поддерево, которое так и не закрылось, возбуждает исключение вместо записи половины фрагмента при сохранении
// 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, ни чего-либо настраиваемого: определение едет вместе с обычным открытием и сохранением

Где захваченное определение сводной таблицы оказывается при сохранении ODS в HotXLS: дети office:spreadsheet идут по фиксированной последовательности ODF — от сгенерированных элементов таблиц через table:content-validations и table:named-expressions, — дословный фрагмент table:data-pilot-tables встаёт перед table:database-ranges, и никакого API нет, потому что определение едет вместе с OpenODS и SaveAsODS
Дословный блок нельзя дописать туда, где писатель в данный момент оказался, а копии привязок предков, которые он несёт, безвредны, потому что Namespaces in XML допускает переобъявление префикса во вложенной области
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