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

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

Книга ODS с одной сводной таблицей проходит через библиотеку, одна исходная ячейка правится, а сохранённый файл открывается со всеми значениями ячеек, но без сводной: на входе content.xml нёс один <table:data-pilot-table>, на выходе — ноль. Очевидный ремонт — скопировать XML сводной строкой. Число элементов восстанавливается, а затем парсер, понимающий пространства имён, отвергает сохранённую часть целиком с ошибкой unbound prefix. У обеих неудач один корень: кусок XML путешествует безопасно, только когда объявления пространств имён, от которых он зависит, путешествуют вместе с ним

Где в content.xml живёт определение сводной ODS

Сводная таблица OpenDocument, которую формат называет data pilot, — это определение, хранящееся после листов в content.xml, и писатель, пересобирающий эту часть из своей объектной модели, сохраняет её, только если модели есть куда её положить. 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. Ячейки результатов, которые показывает сводная, сидят внутри листа как обычные ячейки. Так что писатель, который заново генерирует только ячейки, стили, проверки данных, именованные выражения и диапазоны баз данных, выдаёт совершенно валидную таблицу с теми же числами на экране и без сводной за ними

HotXLS Delphi Excel Component вычитывает всё поддерево <table:data-pilot-tables> из content.xml во время OpenODS, ещё до разбора первой ячейки, и затем пытается перевести коллекцию в свою модель сводных. Определение в простой форме, которую модель представляет, — с локальным исходным диапазоном, полем строк, агрегатными мерами и сохранёнными результатами, согласующимися с исходными данными, — становится TXLSPivotTable в списке PivotTables листа, тот же объект, что вы получаете из сводной XLSX и можете расширять вычисляемыми полями и обновлять из Delphi. Всё, что в эту форму не укладывается, остаётся в коллекции как захваченный XML, и SaveAsODS записывает его обратно. Достаточно атрибута в неизвестном переводчику пространстве имён: сводные, сохранённые LibreOffice, могут нести атрибуты loext: и calcext:, и атрибут loext: отправляет определение по пути захвата

Откат — всё или ничего на всю книгу. Если одна сводная из трёх в коллекции не влезает в переводимую форму, все три остаются захваченным XML. Разрезание оставило бы в content.xml два источника истины для одного контейнера — половина пересобрана из объектов, половина проиграна из текста, и любое расхождение полов легло бы в файл пользователя. Держать коллекцию целой — более безопасный дефолт, и это та же политика, что у неизвестных блоков extLst в XLSX: что модель не может нести, хранится как написано

Почему разрезанное определение сводной ломает сохранённый файл?

Определение сводной, вырезанное из content.xml строковым поиском, теряет объявления пространств имён, придающие его префиксам смысл, а Namespaces in XML считает префикс без объявления ошибкой корректности. Строковый подход короток: декодировать часть в WideString, найти <table:data-pilot-tables через Pos, найти после неё парный закрывающий тег и скопировать всё между ними. Вышедший текст выглядит полным. В нём есть loext:ignore-selected-page="true" на поле страницы и calcext:repeat-item-labels="false" на каждом уровне, но объявления xmlns:loext и xmlns:calcext, привязывающие эти префиксы, стоят на корне документа <office:document-content> за тысячи символов до

Namespaces in XML 1.0 §6.1 держит объявление в силе от начального тега, который его несёт, до парного конечного, и каждое имя с префиксом внутри разрешается через него. Вырежете поддерево из документа — вырежете его и из этой области. Когда фрагмент вставляют в новый документ, каждый префикс разрешается, только если новый корень случайно его объявил. HotXLS пишет собственный <office:document-content> с пространствами имён, которыми пользуется его писатель, — среди них office, table, text, style, fo, draw, calcext, tableooo и of. В нарезанной копии table: разрешается, calcext: разрешается, а loext: — нет. Одного непривязанного префикса достаточно, чтобы парсер, понимающий пространства имён, отверг всю часть целиком, так что сводная не деградирует мягко; данные листа вокруг неё тоже становятся нечитаемыми

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

У строкового поиска есть и другие слепые пятна, которых захват через читатель никогда не встречает. Pos совпадает с текстом тега внутри комментария, внутри секции CDATA или внутри значения атрибута, где этот текст процитирован. Он не отличает вложенные элементы с тем же именем. И он совпадает с литеральным префиксом table:, тогда как ODF позволяет производителю привязать пространство имён table к любому префиксу по своему вкусу

Как HotXLS переносит область пространств имён на фрагмент

HotXLS захватывает поддерево сводной потоковым XML-читателем, который отслеживает каждое объявление пространства имён вместе с глубиной, где оно появилось, и в момент прихода целевого элемента копирует ещё действующие объявления на открывающий тег фрагмента. Стек растёт на каждом начальном теге, объявляющем xmlns или xmlns:prefix, и сжимается, когда закрывается объявивший привязку элемент. Пустые элементы требуют того же: <x xmlns:p="..."/> никогда не порождает отдельного события конечного тега, так что снимать привязку приходится и на пустом начальном теге, иначе привязка утекает к его соседям

На цели захват идёт по стеку от самой внутренней привязки наружу и пишет каждый префикс один раз. Обход изнутри наружу — это и есть реализация затенения. Если <office:spreadsheet> перепривязывает префикс, уже объявленный корнем, действующим на сводной является ближайшее значение, ровно как требует §6.1, а множество уже записанных префиксов не даёт внешнему значению проследовать за ним. Префикс, который целевой элемент объявляет на самом себе, пропускается: написать xmlns:table дважды на одном теге — уже другая ошибка корректности

Как HotXLS захватывает поддерево data pilot вместе с его областью пространств имён: проход потокового XML-читателя держит стек привязок xmlns с метками глубины объявления, обходит его от самых внутренних к внешним на цели table:data-pilot-tables, учитывает затенение через множество уже записанных префиксов, пропускает префиксы, объявленные самим элементом, и снимает привязки как на конечных тегах, так и на пустых элементах
Сопоставление цели по пространству имён, а не по литеральному префиксу сохраняет работоспособность производителей, пишущих префикс таблиц иначе, а поддерево, которое так и не закрылось, возбуждает исключение вместо записи половины фрагмента при сохранении

Цель сопоставляется по пространству имён, так что производитель, пишущий <t:data-pilot-tables> с префиксом t, привязанным к URI таблиц ODF, всё равно распознаётся, а захваченные теги сохраняют написание из файла. Текстовые узлы из пробелов возвращаются в точности как написаны. Кавычки атрибутов и выбор между <x/> и <x></x> нормализуются, поскольку фрагмент пересобирается из событий читателя, а не копируется побайтово. Если content.xml кончается, пока поддерево сводной ещё открыто, — как в обрезанном файле — открытие падает с исключением вместо того, чтобы держать половину определения, которая была бы записана при следующем сохранении

Переносимый набор нарочно щедрый. Захват копирует каждую привязку в области видимости, использует ли её поддерево или нет, так что корень фрагмента повторяет xmlns:table и xmlns:calcext, хотя корень сохраняемого документа тоже их объявляет. Переобъявить префикс во вложенной области допустимо по Namespaces in XML, а файл от производителя, объявляющего на корне десятки пространств имён, толстеет на пару килобайт. Обрезать список значило бы сканировать поддерево на каждый префикс в именах элементов, именах атрибутов и значениях атрибутов, хранящих QName, и один промах возвращает ошибку unbound prefix в полной красе. Пара избыточных объявлений стоит куда дешевле этого риска

Открытие, правка и сохранение книги со сводной

Со стороны вызова включать нечего: определение сводной едет вместе с обычными OpenODS и SaveAsODS. Листы нумеруются с 1, оба метода возвращают 1 при успехе:

uses
  System.SysUtils, lxHandleX;

procedure EditPivotSource(const InFile, OutFile: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS(InFile) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InFile]);
    // E2 лежит внутри исходного диапазона сводной Sheet1.A2:E30
    Book.Sheets[1].Cells[2, 5].Value := 1250.0;
    if Book.SaveAsODS(OutFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [OutFile]);
  finally
    Book.Free;
  end;
end;

В сохранённом content.xml коллекция сводных сидит внутри <office:spreadsheet> сразу после генерируемой писателем <table:named-expressions> и перед <table:database-ranges>. Книга, несущая и AutoFilter, который HotXLS пишет как диапазон базы данных, и сводную, ставит эти два элемента рядом, и если ваш конвейер гоняет строгий ODF-валидатор схемы, именно эта пара — первое место для осмотра

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

Правка в этом примере меняет исходное значение и оставляет ячейки результатов сводной как были. Захваченное определение проигрывается как написано: HotXLS его не пересчитывает, а табличное приложение, открывшее файл, пересчитает сводную при следующем обновлении. То же касается адресов. Переименуете Sheet1 или сдвинете исходный блок — сохранённое определение всё ещё указывает на Sheet1.A2:E30, и потребляющее приложение при обновлении сообщит о битом диапазоне. Держите имена листов и исходный блок стабильными в любой книге, чьи сводные путешествуют этим путём

Каким путём прошла конкретная сводная?

Быстрейшая проверка — список PivotTables листа: переведённое определение появляется там как TXLSPivotTable, захваченное — нет. Отчёт конверсии отвечает на вторую половину вопроса: что станет со сводной в другом выходном формате. GetConversionReport осматривает книгу, не сохраняя и не пересчитывая её, а владеет возвращённым отчётом вызывающий:

uses
  System.SysUtils, lxHandleX, lxDiagnostics;

function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
  I, Modeled: Integer;
  Report: TXLSConversionReport;
begin
  Modeled := 0;
  for I := 1 to Book.Sheets.Count do          // Sheets нумеруется с единицы
    Inc(Modeled, Book.Sheets[I].PivotTables.Count);

  Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
  try
    for I := 0 to Report.Count - 1 do
      if (Report[I].Feature = 'OpenDocumentPivotTables') and
         (Report[I].Fidelity = xlsConversionDropped) then
        Exit('Captured ODF pivot XML: keep this workbook on .ods');
  finally
    Report.Free;
  end;

  if Modeled > 0 then
    Result := Format('%d pivot table(s) in the pivot model', [Modeled])
  else
    Result := 'No pivot definitions';
end;

Путь вывода важен, потому что захваченный XML — словарь ODF, и эмитирует его только ODS-писатель. При сохранении в .xlsx у захваченной коллекции нет SpreadsheetML-аналога, и отчёт перечисляет её под OpenDocumentPivotTables с xlsConversionDropped. Бинарный писатель .xlsb отказывается от книги, которая всё ещё её держит. Переведённая сводная — обычный объект модели и следует правилам целевого формата. Различия путей импорта и экспорта ODS действуют здесь как всюду, и для файлов со сводными безопасный маршрут — .ods на входе, .ods на выходе

Из отката «всё или ничего» следует ещё одно правило. Книга либо проигрывает захваченную коллекцию, либо пишет сводные из модели; смешать то и другое в одном content.xml она не может. Добавление сводной через AddPivotTable в книгу, уже держащую захваченный XML сводных, останавливает сохранение ODS с ошибкой вместо сращивания сгенерированного XML рядом с проигранным

Проверка сохранённой части вторым парсером

Число элементов ничего не доказывает о пространствах имён. Регулярное выражение, находящее начальные теги data-pilot-table в сохранённом content.xml, видит текст, и нарезанная копия из второго раздела этот тест проходит. Куда более строгий судья — парсер, понимающий пространства имён и не связанный с библиотекой, писавшей файл, а такой в Delphi уже есть. Функция ниже — проверка, которую стоит добавить в собственный тестовый набор; TZipFile и TXMLDocument приходят из Delphi RTL, а не из HotXLS:

uses
  System.SysUtils, System.Classes, System.Zip,
  Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;

// Ваш собственный тестовый помощник: True, когда content.xml в пакете .ods
// грузится в понимающем пространства имён DOM-парсере
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
  Zip: TZipFile;
  Bytes: TBytes;
  Stream: TBytesStream;
  Doc: IXMLDocument;
begin
  Error := '';
  Zip := TZipFile.Create;
  try
    Zip.Open(OdsFile, zmRead);
    Zip.Read('content.xml', Bytes);
  finally
    Zip.Free;
  end;
  Stream := TBytesStream.Create(Bytes);
  try
    Doc := TXMLDocument.Create(nil);
    try
      Doc.LoadFromStream(Stream);     // непривязанный префикс падает здесь
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

Гоняйте её на каждом .ods, который сохраняет ваш код, а для файлов со сводными сделайте шаг дальше и сравните поддеревья сводной входа и выхода после разбора: тег, отсортированные атрибуты, текст и дети, рекурсивно, с именами, развёрнутыми до URI пространства имён плюс локального имени. Такое сравнение пропускает переписанный префикс и ловит непривязанный — ровно та черта между безвредным и сломанным. Кормите ему файлы от всех производителей, которыми реально пользуются ваши пользователи. Документ, привязавший пространство имён таблиц к другому префиксу, объявивший префикс на самом элементе сводной или перепривязавший один в <office:spreadsheet>, нагружает правила пропуска и затенения так, как один файл LibreOffice не нагрузит

Читатель и писатель ODS, модель сводных для XLSX и ODS и отчёт конверсии из примера выше — всё это часть HotXLS Delphi Excel Component; страница продукта описывает полную поддержку чтения и записи XLSX, XLS и ODS для Delphi и C++Builder