HotXLS Delphi Excel Component зберігає зведені таблиці OpenDocument крізь цикл відкриття й збереження ODS, захоплюючи піддерево <table:data-pilot-tables> з content.xml дослівно під час відкриття і відтворюючи його при збереженні — від v2.382.0. А від v2.382.1 фрагмент несе ще й усі прив'язки XML-namespace, які оголосили його предки, тож збережене визначення зведення лишається коректним для будь-якого споживача, а не лише для HotXLS
Баг, який змусив зробити обидві зміни, виплив зі строгого прогону корпусу. Зразок official-pivot.ods, записаний development-збіркою LibreOffice 6.1, містить одне зведення з іменем DataPilot1, яке читає Sheet1.A2:E30 і кладе результат у Sheet1.G6:J18. Відкрийте його в HotXLS, збережіть без змін, порахуйте елементи <table:data-pilot-table> у виводі: один на вході, нуль на виході, однаково на Win32 і Win64. Ніщо в тесті зведення не торкалося. Перший раунд перевірок порівнював лише константи клітинок і проходив; втрату викрило саме структурне твердження, і це нагадування, що «значення збігаються» — слабке визначення точності round-trip
Чому зведена таблиця 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 лише заради round-trip означало б купу коду, який ніхто не редагує. Прагматична відповідь та сама, яку HotXLS уже застосовує до невідомих блоків extLst у XLSX: зберігай те, чого не моделюєш — байт за байтом, якщо можеш, подія за подією, якщо не можеш
Що перше захоплення на основі Pos зробило неправильно?
Захоплення у v2.382.0 вирізало визначення зведення з content.xml як звичайний рядок, і в цьому вирізі бракувало оголошень namespace, які й робили його осмисленим. Реалізація була така коротка, як звучить — декодувати частину у 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-частина збереженого пакета згодовується незалежному namespace-aware парсеру поза 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 визначає правило, яке робить це жорсткою відмовою, а не косметичною: оголошення namespace діє від початкового тегу елемента, на якому воно стоїть, до кінцевого тегу цього елемента, і кожне ім'я з префіксом усередині цієї області розв'язується проти нього. Виріжте піддерево з документа — і ви виріжете його з області дії. HotXLS пише власний корінь <office:document-content> з одинадцятьма оголошеннями — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo, — тож calcext: випадково розв'язався, table: випадково розв'язався, а loext: — ні. Namespace-aware парсер трактує незв'язаний префікс як порушення коректності, а це означає, що вся частина нечитабельна, а не просто один атрибут
Як HotXLS переносить прив'язки xmlns предків на фрагмент?
HotXLS v2.382.1 замінив рядковий виріз проходом по content.xml через власний потоковий TXMLReader, тримаючи стек прив'язок namespace із позначкою глибини, на якій кожну оголосили, і копіюючи прив'язки, що ще діють, на кореневий елемент фрагмента тієї ж миті, коли досягнуто цілі. Читач працює з увімкненим 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 namespace таблиць 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 перенесений набір — це всі тридцять п'ять кореневих оголошень, близько двох кілобайтів поверх визначення на 8,357 символів, бо захоплення не аналізує, які префікси піддерево справді використовує. Сканування використаних префіксів це зрізало б, і воно, можливо, з'явиться пізніше; спершу коректність, компактність потім
Правило для вирізання піддерев з XML під дослівне відтворення
Загальний урок такий: піддерево стає самодостатнім лише тоді, коли ви його таким зробили, а область namespace — перше, що ламається, коли про неї забути. Чекліст, який HotXLS тепер застосовує до будь-якого захоплення за принципом «зберігай те, чого не моделюєш»:
- Обходьте документ справжнім читачем і відслідковуйте прив'язки в області дії. Пошук рядком через
Posвзагалі не бачить області дії, а ще він помиляється на вкладених елементах із тим самим іменем, на збігу всередині коментаря чи секціїCDATAі на значеннях атрибутів, які випадково містять текст тега - Копіюйте чинні прив'язки на корінь фрагмента, від найглибшої, по одному разу на префікс, пропускаючи те, що корінь уже оголошує
- Зберігайте сире написання префікса у виданих тегах; порівнюйте ціль за розв'язаним namespace, а не за літеральним префіксом
- Зберігайте текстові вузли з пробілами й пам'ятайте, що порожній елемент закриває власну область дії без події кінцевого тега
- Валідуйте збережену частину парсером, який не є бібліотекою, що тестується. Бібліотека радо перечитає власний вивід тим самим поблажливим кодом, який його й написав
Останній пункт — це те, що власне й упіймало HXLS-003 удруге. Приймальна перевірка v2.382.0 була регулярним виразом, який рахував початкові теги data-pilot-table у збереженому content.xml, а регулярний вираз бачить тег, а не документ — він сліпий до того, чи зв'язані префікси на цьому тезі. Строгий прогін корпусу, доданий у v2.382.1, розбирає кожну XML-частину й частину .rels збереженого пакета namespace-aware парсером, а потім порівнює дерево зведення — тег, відсортовані атрибути, текст, дочірні елементи, рекурсивно — з оригіналом. Це порівняння розгорнуте за namespace, тож інше написання префікса все одно пройшло б, а незв'язаний префікс — ні
Де закінчується гарантія дослівності
Дослівне відтворення зберігає визначення; воно його не розуміє, і межі випливають саме з цього. HotXLS не виставляє жодного API для читання, редагування чи оновлення зведення ODS, тож FRawOdsDataPilotTablesXml — внутрішнє поле, і єдина спостережувана поведінка — те, що визначення виживає. Фрагмент пересеріалізується з подій читача, а не копіюється як байти: лапки атрибутів і самозакривні форми нормалізуються, а текст і пробіли зберігаються. Захоплений XML видає лише записувач вмісту ODS, тож книга, відкрита з .ods і збережена як .xlsx, втрачає зведення, а книзі, відкритій з .xlsx, нічого відтворювати у збереженні .ods — асиметрії шляхів імпорту та експорту ODS діють тут, як і всюди. І оскільки визначення непрозоре, воно не може йти за вашими правками: перейменуйте Sheet1 або пересуньте вихідні дані в HotXLS — і збережене зведення все ще вказуватиме на Sheet1.A2:E30, лишаючи споживача повідомляти про зламаний діапазон при наступному оновленні. Ще одне застереження щодо порядку належить сюди ж: HotXLS видає діапазони AutoFilter як <table:database-ranges> після фрагмента зведення, а зразок у корпусі не несе жодного database range, тож книгу, яка має і фільтр, і зведення, варто прогнати через валідатор схеми ODF, перш ніж покладатися на відносний порядок цих двох елементів
Тестуйте на файлах власного виробника, а не лише на зразку з корпусу. Перенесення namespace обробляє будь-який префікс, оголошений виробником на предку, але документ, який оголошує префікс на самому елементі зведення або використовує типовий namespace для словника таблиць, задіює гілки пропуску й затінення, яких зразок LibreOffice не торкається. Обидві реалізовані; жодна поки не має зразка в корпусі, і саме цю різницю записи в changelog зазвичай і розмивають
Дослівне захоплення data pilot у v2.382.0 і виправлення області namespace у v2.382.1 постачаються в поточному HotXLS Delphi Excel Component, чия сторінка продукту перелічує повне покриття читання й запису ODS, XLSX та XLS для Delphi і C++Builder