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

ODS pivot round-trip в Delphi: scope на XML namespaces

HotXLS Delphi Excel Component запазва OpenDocument data pilot таблиците през един ODS цикъл отваряне-и-запис, като улавя <table:data-pilot-tables> поддървото на content.xml дословно при отваряне и го възпроизвежда при запис — от v2.382.0 нататък. От v2.382.1 фрагментът носи и всяка XML namespace връзка, декларирана от неговите ancestor-и, така че записаната pivot дефиниция остава well formed за всеки consumer, не само за HotXLS

Бъгът, който наложи и двете промени, излезе от строг corpus пробег. Примерът official-pivot.ods, записан от development build на LibreOffice 6.1, държи една pivot с име DataPilot1, която чете Sheet1.A2:E30 и каца резултата си в Sheet1.G6:J18. Отворете я с HotXLS, запишете я непроменена, пребройте елементите <table:data-pilot-table> в изхода: едно навътре, нула навън, еднакво на Win32 и Win64. Нищо в теста не пипна pivot-а. Първият кръг проби беше само сравнявал клетъчни константи и беше минал; структурната assertion беше тази, която изласка загубата — напомняне, че „стойностите съвпадат“ е слаба дефиниция за round-trip faithfulност

Защо ODS pivot таблица изчезва след запис от библиотеката?

ODS pivot таблица изчезва, защото HotXLS няма in-memory модел за OpenDocument data pilot таблици, а ODS writer-ът изгражда content.xml изцяло от модела. Writer-ът сглобява automatic styles, по един <table:table> на worksheet, <table:content-validations>, <table:named-expressions> и <table:database-ranges>, всяко генерирано от обекти, които работната книга действително държи. Pivot дефиниция — ODF 1.3 Part 3 §9.6, контейнер <table:data-pilot-tables> с по един <table:data-pilot-table> на pivot, носещ своя table:source-cell-range, своите table:data-pilot-field деца, своя table:target-range-address и table:buttons — няма обект, в който да живее, така че регенерираната част просто я пропуска

Контрастът с XLSX е умишлен. HotXLS парсва SpreadsheetML pivot cache-овете и pivot таблиците в истински модел, който можете да изградите, разширите с calculated fields и освежите от Delphi, така че те оцеляват при запис, защото се пренаписват, не се копират. ODS pivot-ите са много по-рядка заявка, а моделирането на ODF data pilot речника само заради round-trip би било много код, който никой не редактира. Прагматичният отговор е същият, който HotXLS вече прилага за непознати extLst блокове в XLSX: пазете това, което не моделирате, байт-по-байт, ако можете, event-по-event, ако не можете

Какво обърка първото Pos-базирано улавяне?

Улавянето във v2.382.0 отряза pivot дефиницията от content.xml като обикновен string, и в среза липсваха 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;

Count assertion-ът посини, и поправката излезе. Хвана я втора, по-строга проверка, добавена същия ден: всяка XML част на записания пакет се подава на независим namespace-aware парсер извън HotXLS, и този парсер отхвърли новия content.xml с грешка unbound prefix. Pivot-ът от LibreOffice носи producer extension атрибути — loext:ignore-selected-page="true" на page поле, calcext:repeat-item-labels="false" на всяко ниво — а отрязаният string съдържаше тези атрибути, но не и декларациите xmlns:loext и xmlns:calcext, които ги връзваха. Тези декларации седяха на корена <office:document-content> на source файла — трийсет и пет на брой, на две хиляди знака разстояние от pivot-а

W3C Namespaces in XML 1.0 §6.1 дефинира правилото, което превръща това в тежък провал, а не козметичен: namespace декларация е in scope от началния таг на елемента, на който се появява, до неговия затварящ таг, и всяко име с префикс вътре в този scope се разрешава срещу нея. Отрежете поддърво от документа и сте го отрязали от scope-а. HotXLS записва собствения си корен <office:document-content> с единадесет декларации — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — така че calcext: случайно се разреши, table: случайно се разреши, а loext: — не. Namespace-aware парсер третира необвързан префикс като нарушение на well-formedness, което означава, че цялата част е нечетима, а не само един атрибут

Какво пропусна Pos-базираното улавяне на official-pivot.ods в HotXLS: pivot поддървото носи loext и calcext extension атрибути, докато xmlns декларациите, които ги връзват, седят на корена office:document-content, на трийсет и пет връзки разстояние, така че отрязаният фрагмент остави всеки ползван от него префикс необвързан и namespace-aware парсер отхвърли целия content.xml
Namespace декларация е in scope от началния си таг до затварящия си, а отрязването на поддърво от документа го отрязва и от този scope, което превръща един атрибут в нечетима част

Как HotXLS пренася ancestor xmlns връзки върху фрагмента?

HotXLS v2.382.1 замени string среза с проход по content.xml чрез собствения му streaming TXMLReader, поддържайки стек от namespace връзки, етикетирани с дълбочината, на която всяка е декларирана, и копирайки все още действащите връзки върху коренния елемент на фрагмента в момента, в който целта е достигната. Reader-ът върви с включено PreserveWhitespaceText, така че текстовите възли се връщат точно както са записани, а възстановените тагове ползват TXMLReader.RawName и TXMLReader.Attribute[I].RawName — правописа на префикса от файла — вместо каноничните имена, които reader-ът нормално подава на частните парсери. Ето ядрото на цикъла:

Как HotXLS v2.382.1 улавя data pilot поддървото с неговия namespace scope: streaming проход с TXMLReader пази стек от xmlns връзки, етикетирани с деклариращата дълбочина, обхожда го от най-вътрешната навън на целта table:data-pilot-tables, почита shadowing чрез Seen множество, прескача префикси, които елементът декларира сам, и pop-ва връзки еднакво на end тагове и на празни елементи
Съпоставянето на целта по каноничното reader име поддържа producer-и, които преименуват table префикса, а поддърво, което никога не се затваря, вдига exception, вместо да върне половин фрагмент при запис
// 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);   // обелвай завършващото '>' или '/>' първо
      ...
      // Пренеси ефективните ancestor връзки върху корена на фрагмента.
      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);                   // напусни scope-а
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Три детайла в този цикъл носят коректността. Обхождането на стека от най-вътрешната връзка навън и запомнянето на всеки префикс в Seen имплементира shadowing: ако по-близък ancestor пререгильтерира xmlns:table, по-близката стойност печели — точно както §6.1 изисква. Прескачането на префикси, които елементът вече декларира сам, избягва излъчването на същия атрибут два пъти, което би било друга well-formedness грешка. А pop правилото светва на end тагове и на празни елементи, защото <x/> никога не произвежда събитие EndElement — същият самозатварящ се капан, на който се научи XLSX extLst улавянето. Съпоставянето на целта по Reader.Name, а не по RawName, е по-тиха печалба: reader-ът канонизира ODF table namespace URI-то до префикса table, така че producer, който го изпише t:data-pilot-tables, пак съвпада, докато излъченият фрагмент запазва каквото и да е изписване на префикса producer-ът е ползвал

Цикълът освен това отказва да гадае. Ако частта свърши, докато улавянето е още отворено — отрязан или повреден content.xml — OdsCaptureDataPilotTablesXml вдига exception, вместо да върне половин фрагмент, защото половин фрагмент щеше да бъде записан обратно при запис и щеше да превърне повреден вход в повреден изход с името на библиотеката върху него

Къде каца фрагментът в записания content.xml?

HotXLS записва уловения фрагмент в <office:spreadsheet> веднага след генерирания от него <table:named-expressions> и пред <table:database-ranges>. Content моделът на <office:spreadsheet> в ODF 1.3 Part 3 предписва фиксирана последователност за тези последващи деца, така че дословен блок не може просто да се долепи там, където writer-ът случайно се намира; той трябва да бъде пуснат в конкретен слот. От страната на caller-а няма API и няма какво да се конфигурира; дефиницията пътува заедно с обикновено отваряне и запис:

Къде каца уловената pivot дефиниция при ODS запис на HotXLS: децата на office:spreadsheet следват фиксираната ODF последователност от генерираните table елементи през table:content-validations и table:named-expressions, дословният фрагмент table:data-pilot-tables се вмъква пред table:database-ranges, и не съществува API, защото дефиницията пътува заедно с OpenODS и SaveAsODS
Дословен блок не може да се долепи там, където writer-ът случайно се намира, а копията на ancestor връзките, които той носи, са безвредни, защото Namespaces in XML позволява пререгистриране на префикс във вложен scope
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;   // редакция вътре в source диапазона на pivot-а
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml в изхода все още носи DataPilot1 със своя
    // source диапазон, полета, target диапазон, бутони и loext:/calcext: атрибути
  finally
    Book.Free;
  end;
end;

Излишъкът е умишлен и си заслужава да се знае. Коренът на фрагмента вече повтаря xmlns:table и xmlns:calcext, макар коренът на записания документ да ги декларира също; Namespaces in XML позволява пререгистриране на префикс във вложен scope, така че дубликатите са безвредни. За LibreOffice примера пренесеният набор са всичките трийсет и пет root декларации — около два килобайта върху 8 357-знаковата дефиниция — защото улавянето не анализира кои префикси поддървото действително ползва. Сканиране на ползвани префикси би ги съкратило и може да дойде по-късно; първо коректност, после компактност

Правило за отрязване на поддърва от XML за дословен replay

Общият урок е, че поддърво е самоцялостно само след като сте го направили такова, а namespace scope е първото нещо, което се чупи, когато забравите. Чеклистът, който HotXLS вече прилага за всяко улавяне от типа „пазете това, което не моделираме“:

  • Обхождайте документа с истински reader и следете връзките in scope. String търсене с Pos изобщо не вижда scope, и освен това се засича на вложени елементи със същото име, на съвпадащ string в коментар или CDATA секция и на атрибути стойности, които случайно съдържат текста на тага
  • Копирайте ефективните връзки върху корена на фрагмента, най-вътрешните първо, веднъж на префикс, пропускайки каквото коренът вече декларира
  • Пазете суровото изписване на префикса в излъчените тагове; съпоставяйте целта по разрешен namespace, не по буквален префикс
  • Запазвайте whitespace текстовите възли и помнете, че празен елемент затваря собствения си scope без end-tag събитие
  • Валидирайте записаната част с парсер, който не е библиотеката под тест. Библиотеката ще препрочете собствения си изход с удоволствие през същия снизходителен code path, който го е записал

Последната точка е тази, която действително намери HXLS-003 втория път. Acceptance проверката на v2.382.0 беше регулярен израз, броящ начални тагове data-pilot-table в записания content.xml, а регулярен израз вижда таг, не документ — той е сляп дали префиксите на този таг са обвързани. Строгият corpus runner, добавен във v2.382.1, парсва всяка XML и .rels част на записания пакет с namespace-aware парсер и после сравнява pivot дървото — таг, сортирани атрибути, текст, деца, рекурсивно — с оригинала. Това сравнение е namespace-разширено, така че преименуван префикс пак минава, а необвързан не може

Къде свършва дословната гаранция

Дословният replay запазва дефиниция; той не я разбира, а границите следват от това. HotXLS не излага API за четене, редактиране или освежаване на ODS pivot, така че FRawOdsDataPilotTablesXml е вътрешно поле и единственото наблюдаемо поведение е, че дефиницията оцелява. Фрагментът се ресериализира от reader събития, не се копира като байтове: кавитирането на атрибути и самозатварящите се форми се нормализират, докато текст и whitespace се пазят. Уловеният XML се излъчва само от ODS content writer-а, така че работна книга, отворена от .ods и записана като .xlsx, губи pivot-а, а работна книга, отворена от .xlsx, няма какво да възпроизведе в .ods запис — асиметриите на ODS импорт и export пътищата важат тук както навсякъде. И защото дефиницията е непрозрачна, тя не може да следва вашите редакции: преименувайте Sheet1 или преместете source данните в HotXLS, а записаният pivot все още сочи към Sheet1.A2:E30, оставяйки на consumer-а да докладва счупен диапазон при следващото му refresh. Едно предупреждение за подредба също принадлежи тук: HotXLS излъчва AutoFilter диапазони като <table:database-ranges> след pivot фрагмента, а corpus примерът не носи database диапазон, така че работна книга и с филтър, и с pivot трябва да се прекарате през ODF schema валидатор, преди да разчитате на относителния ред на тези два елемента

Тествайте с файловете на собствения си producer, не само с corpus примера. Namespace пренасянето се справя с всеки префикс, който producer декларира на ancestor, но документ, който декларира префикс върху самия pivot елемент, или който ползва default namespace за table речника, упражнява skip и shadowing клоните, които LibreOffice примерът не упражнява. И двете са имплементирани; нито едното няма пример в corpus-а още, и точно това разграничение е нещото, което changelog запис има тенденцията да замъгля

Дословното data pilot улавяне във v2.382.0 и namespace-scope поправката във v2.382.1 се доставят в текущия HotXLS Delphi Excel Component, чиято продуктова страница изброява пълното ODS, XLSX и XLS read-write покритие за Delphi и C++Builder