Articol tehnic

Round-trip de pivot ODS în Delphi: domeniul de nume XML

HotXLS Delphi Excel Component păstrează tabelele data pilot din OpenDocument printr-un ciclu de deschidere și salvare ODS, capturând identic subarborele <table:data-pilot-tables> din content.xml la deschidere și redându-l la salvare, începând cu v2.382.0. Din v2.382.1 fragmentul duce cu el și fiecare legătură de namespace XML declarată de strămoșii lui, așa că definiția de pivot salvată rămâne bine formată pentru orice consumator, nu doar pentru HotXLS

Bug-ul care a forțat ambele modificări a ieșit dintr-o rulare strictă pe corpus. Eșantionul official-pivot.ods, scris de un build de dezvoltare LibreOffice 6.1, conține un singur pivot numit DataPilot1, care citește Sheet1.A2:E30 și își pune rezultatul în Sheet1.G6:J18. Deschideți-l cu HotXLS, salvați-l neschimbat, numărați elementele <table:data-pilot-table> din rezultat: unul la intrare, zero la ieșire, la fel pe Win32 și Win64. Nimic din test nu atingea pivotul. Prima rundă de sondaje comparase doar constante de celulă și trecuse; aserțiunea structurală este cea care a expus pierderea, ceea ce amintește că „valorile se potrivesc” este o definiție slabă a fidelității de round-trip

De ce dispare un tabel pivot ODS după o salvare din librărie?

Un tabel pivot ODS dispare pentru că HotXLS nu are un model în memorie pentru tabelele data pilot din OpenDocument, iar scriitorul ODS construiește content.xml în întregime din model. Scriitorul asamblează stiluri automate, un <table:table> per foaie de calcul, <table:content-validations>, <table:named-expressions> și <table:database-ranges>, fiecare generat din obiecte pe care registrul le deține efectiv. O definiție de pivot — ODF 1.3 Part 3 §9.6, un container <table:data-pilot-tables> cu un <table:data-pilot-table> per pivot, purtând table:source-cell-range, copiii lui table:data-pilot-field, table:target-range-address și table:buttons — nu are niciun obiect în care să trăiască, așa că partea regenerată pur și simplu o omite

Contrastul cu XLSX este deliberat. HotXLS parsează cache-urile și tabelele pivot SpreadsheetML într-un model real pe care îl puteți construi, extinde cu câmpuri calculate și reîmprospăta din Delphi, așa că acelea supraviețuiesc unei salvări pentru că sunt rescrise, nu copiate. Pivoturile ODS sunt o cerere mult mai rară, iar modelarea vocabularului data pilot din ODF doar de dragul round-trip-ului ar însemna mult cod pe care nimeni nu îl editează. Răspunsul pragmatic este același pe care HotXLS îl aplică deja la blocurile extLst necunoscute din XLSX: păstrează ce nu modelezi, octet cu octet dacă se poate, eveniment cu eveniment dacă nu

Ce a greșit prima captură bazată pe Pos?

Captura din v2.382.0 decupa definiția de pivot din content.xml ca pe un simplu șir, iar decupajul pierdea declarațiile de namespace care o făceau inteligibilă. Implementarea era atât de scurtă pe cât sună — decodează partea într-un WideString, găsește tag-ul de deschidere cu Pos, găsește tag-ul de închidere după el, copiază intervalul în FRawOdsDataPilotTablesXml de pe registru:

// HotXLS v2.382.0 -- înlocuită o versiune mai târziu
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);   // tot content.xml în memorie
  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;

Aserțiunea de numărare a trecut pe verde, iar reparația a fost livrată. Ce a prins-o a fost o a doua verificare, mai strictă, adăugată în aceeași zi: fiecare parte XML a pachetului salvat este dată unui parser independent care cunoaște namespace-uri, din afara HotXLS, iar acel parser a respins noul content.xml cu o eroare de prefix nelegat. Pivotul din LibreOffice poartă atribute de extensie ale producătorului — loext:ignore-selected-page="true" pe un câmp de pagină, calcext:repeat-item-labels="false" pe fiecare nivel — iar șirul decupat conținea acele atribute, dar nu și declarațiile xmlns:loext și xmlns:calcext care le legau. Acele declarații stăteau pe rădăcina <office:document-content> a fișierului sursă, treizeci și cinci la număr, la două mii de caractere distanță de pivot

W3C Namespaces in XML 1.0 §6.1 definește regula care face din asta o eroare gravă, nu una cosmetică: o declarație de namespace este în domeniu de la tag-ul de început al elementului pe care apare până la tag-ul lui de sfârșit, iar fiecare nume cu prefix din acel domeniu se rezolvă prin ea. Decupați un subarbore din document și îl decupați și din domeniu. HotXLS își scrie propria rădăcină <office:document-content> cu unsprezece declarații — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — așa că calcext: se nimerea să se rezolve, table: se nimerea să se rezolve, iar loext: nu. Un parser care cunoaște namespace-uri tratează un prefix nelegat ca pe o încălcare a bunei formări, ceea ce înseamnă că toată partea este ilizibilă, nu doar un atribut

Ce a ratat captura bazată pe Pos din official-pivot.ods în HotXLS: subarborele de pivot poartă atribute de extensie loext și calcext, în timp ce declarațiile xmlns care le leagă stau pe rădăcina office:document-content, la treizeci și cinci de legături distanță, așa că fragmentul decupat lăsa fiecare prefix folosit nelegat, iar un parser care cunoaște namespace-uri respingea tot content.xml
O declarație de namespace este în domeniu de la tag-ul ei de început până la cel de sfârșit, iar decuparea unui subarbore din document îl scoate și din acel domeniu, ceea ce transformă un singur atribut într-o parte ilizibilă

Cum duce HotXLS legăturile xmlns ale strămoșilor pe fragment?

HotXLS v2.382.1 a înlocuit decupajul pe șir cu o trecere peste content.xml prin propriul TXMLReader în flux, întreținând o stivă de legături de namespace etichetate cu adâncimea la care a fost declarată fiecare și copiind legăturile încă în vigoare pe elementul rădăcină al fragmentului în momentul în care ținta este atinsă. Reader-ul rulează cu PreserveWhitespaceText activat, ca nodurile de text să vină exact cum au fost scrise, iar tag-urile reconstruite folosesc TXMLReader.RawName și TXMLReader.Attribute[I].RawName — grafia prefixului din fișier — în locul numelor canonice pe care reader-ul le predă de obicei parserelor de părți. Iată miezul buclei:

Cum capturează HotXLS v2.382.1 subarborele data pilot împreună cu domeniul lui de namespace: o trecere în flux cu TXMLReader ține o stivă de legături xmlns etichetate cu adâncimea de declarare, o parcurge de la cea mai interioară spre exterior la ținta table:data-pilot-tables, respectă umbrirea printr-un set Seen, sare peste prefixele pe care elementul le declară singur și scoate legăturile de pe stivă la tag-urile de sfârșit și la elementele goale deopotrivă
Potrivirea țintei după numele canonic al reader-ului îi menține funcționali pe producătorii care scriu prefixul table altfel, iar un subarbore care nu se închide niciodată ridică excepție în loc să scrie un fragment pe jumătate la salvare
// Namespaces: TStringList cu 'xmlns:p=uri' și adâncimea declarării în Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // element, text, CDATA, comentariu
  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);   // elimină mai întâi '>' sau '/>' de la final
      ...
      // Duce legăturile efective ale strămoșilor pe rădăcina fragmentului.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // legătura cea mai interioară câștigă
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // deja declarat aici? sari peste
          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;                           // subarbore închis
  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);                   // ieși din domeniu
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Trei detalii din acea buclă susțin corectitudinea. Parcurgerea stivei de la legătura cea mai interioară spre exterior și reținerea fiecărui prefix în Seen implementează umbrirea: dacă un strămoș mai apropiat reia xmlns:table, valoarea mai apropiată câștigă, exact cum spune §6.1 că trebuie. Sărirea peste prefixele pe care elementul le declară deja evită emiterea aceluiași atribut de două ori, ceea ce ar fi o altă eroare de bună formare. Iar regula de scoatere de pe stivă se declanșează la tag-urile de sfârșit și la elementele goale, pentru că <x/> nu produce niciodată un eveniment EndElement — aceeași capcană a auto-închiderii pe care a trebuit să o învețe și captura de extLst din XLSX. Potrivirea țintei după Reader.Name, nu după RawName, este un câștig mai discret: reader-ul canonicalizează URI-ul de namespace table din ODF la prefixul table, așa că un producător care îl scrie t:data-pilot-tables se potrivește în continuare, în timp ce fragmentul emis păstrează prefixul pe care l-a folosit producătorul

Bucla refuză de asemenea să ghicească. Dacă partea se termină în timp ce captura este încă deschisă — un content.xml trunchiat sau rău format — OdsCaptureDataPilotTablesXml ridică excepție în loc să întoarcă un fragment pe jumătate, pentru că un fragment pe jumătate ar fi scris înapoi la salvare și ar transforma o intrare deteriorată într-o ieșire deteriorată care poartă numele librăriei

Unde ajunge fragmentul în content.xml salvat?

HotXLS scrie fragmentul capturat în <office:spreadsheet> imediat după <table:named-expressions> pe care îl generează și înaintea lui <table:database-ranges>. Modelul de conținut al lui <office:spreadsheet> din ODF 1.3 Part 3 prescrie o secvență fixă pentru acei copii de la final, așa că un bloc identic nu poate fi pur și simplu adăugat oriunde s-ar afla scriitorul în acel moment; trebuie plasat într-un loc anume. Din partea apelantului nu există niciun API și nimic de configurat; definiția călătorește împreună cu o deschidere și o salvare obișnuită:

Unde ajunge definiția de pivot capturată într-o salvare ODS din HotXLS: copiii lui office:spreadsheet urmează secvența fixă ODF de la elementele table generate prin table:content-validations și table:named-expressions, fragmentul identic table:data-pilot-tables se plasează înaintea lui table:database-ranges, iar nu există API pentru că definiția călătorește cu OpenODS și SaveAsODS
Un bloc identic nu poate fi adăugat oriunde s-ar afla scriitorul, iar copiile legăturilor de strămoși pe care le duce sunt inofensive pentru că Namespaces in XML permite redeclararea unui prefix într-un domeniu imbricat
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;   // editare în interiorul intervalului sursă al pivotului
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml din ieșire duce în continuare DataPilot1 cu
    // intervalul sursă, câmpurile, intervalul țintă, butoanele și atributele loext:/calcext:
  finally
    Book.Free;
  end;
end;

Redundanța este intenționată și merită știută. Rădăcina fragmentului repetă acum xmlns:table și xmlns:calcext, chiar dacă rădăcina documentului salvat le declară și ea; Namespaces in XML permite redeclararea unui prefix într-un domeniu imbricat, așa că duplicatele sunt inofensive. Pentru eșantionul LibreOffice, setul dus cu el cuprinde toate cele treizeci și cinci de declarații de rădăcină, cam două kiloocteți peste definiția de 8,357 caractere, pentru că captura nu analizează ce prefixe folosește efectiv subarborele. O scanare a prefixelor folosite ar reduce asta și poate va veni mai târziu; întâi corectitudinea, apoi compactitatea

O regulă pentru decuparea subarborilor din XML în vederea redării identice

Lecția generală este că un subarbore este autonom doar după ce l-ați făcut autonom, iar domeniul de namespace este primul lucru care se rupe când uitați asta. Lista de verificare pe care HotXLS o aplică acum oricărei capturi de tip „păstrează ce nu modelăm”:

  • Parcurgeți documentul cu un reader real și urmăriți legăturile aflate în domeniu. Căutarea în șir cu Pos nu poate vedea deloc domeniul și se potrivește greșit și pe elemente imbricate cu același nume, și pe un șir potrivit din interiorul unui comentariu sau al unei secțiuni CDATA, și pe valori de atribut care se întâmplă să conțină textul tag-ului
  • Copiați legăturile efective pe rădăcina fragmentului, de la cea mai interioară spre exterior, o dată per prefix, sărind peste ce declară deja rădăcina
  • Păstrați grafia brută a prefixului în tag-urile emise; potriviți ținta după namespace-ul rezolvat, nu după prefixul literal
  • Păstrați nodurile de text cu spații albe și țineți minte că un element gol își închide singur domeniul, fără un eveniment de tag de sfârșit
  • Validați partea salvată cu un parser care nu este librăria testată. Librăria își va reciti bucuroasă propria ieșire prin același drum de cod îngăduitor care a scris-o

Ultimul punct este cel care a găsit efectiv HXLS-003 a doua oară. Verificarea de acceptanță din v2.382.0 era o expresie regulată care număra tag-urile de început data-pilot-table din content.xml salvat, iar o expresie regulată vede un tag, nu un document — este oarbă la întrebarea dacă prefixele de pe acel tag sunt legate. Runner-ul strict de corpus adăugat în v2.382.1 parsează fiecare parte XML și .rels a pachetului salvat cu un parser care cunoaște namespace-uri și apoi compară arborele de pivot — tag, atribute sortate, text, copii, recursiv — cu originalul. Comparația este extinsă pe namespace-uri, așa că o rescriere a prefixului ar trece în continuare, iar un prefix nelegat nu poate

Unde se termină garanția de identitate

Redarea identică păstrează o definiție; nu o înțelege, iar limitele decurg de aici. HotXLS nu expune niciun API pentru a citi, edita sau reîmprospăta un pivot ODS, așa că FRawOdsDataPilotTablesXml este un câmp intern și singurul comportament observabil este că definiția supraviețuiește. Fragmentul este reserializat din evenimentele reader-ului, nu copiat ca octeți: ghilimelele atributelor și formele auto-închise sunt normalizate, în timp ce textul și spațiile albe sunt păstrate. XML-ul capturat este emis doar de scriitorul de conținut ODS, așa că un registru deschis din .ods și salvat ca .xlsx pierde pivotul, iar un registru deschis din .xlsx nu are ce reda într-o salvare .ods — asimetriile căilor de import și export ODS se aplică aici ca peste tot. Și, pentru că definiția este opacă, nu poate urma editările voastre: redenumiți Sheet1 sau mutați datele sursă în HotXLS, iar pivotul salvat indică în continuare Sheet1.A2:E30, lăsând consumatorul să raporteze un interval rupt la următoarea reîmprospătare. O atenționare de ordonare își are locul aici: HotXLS emite intervalele AutoFilter ca <table:database-ranges> după fragmentul de pivot, iar eșantionul din corpus nu conține niciun interval de bază de date, așa că un registru care are și filtru, și pivot ar trebui trecut printr-un validator de schemă ODF înainte să vă bazați pe ordinea relativă a acelor două elemente

Testați cu fișierele propriului producător, nu doar cu eșantionul din corpus. Transportul de namespace-uri acoperă orice prefix pe care un producător îl declară pe un strămoș, dar un document care declară un prefix chiar pe elementul de pivot, sau care folosește un namespace implicit pentru vocabularul de tabele, exercită ramurile de sărire și de umbrire pe care eșantionul LibreOffice nu le atinge. Ambele sunt implementate; niciuna nu are încă un eșantion în corpus, iar această distincție este exact genul de lucru pe care o intrare de changelog tinde să îl estompeze

Captura identică de data pilot din v2.382.0 și reparația domeniului de namespace din v2.382.1 sunt livrate în HotXLS Delphi Excel Component actual, a cărui pagină de produs listează întreaga acoperire de citire și scriere ODS, XLSX și XLS pentru Delphi și C++Builder