Articolo tecnico

Round trip delle pivot ODS in Delphi: scope dei namespace

Il componente HotXLS per Excel in Delphi conserva le tabelle data pilot di OpenDocument attraverso un ciclo di apertura e salvataggio ODS, catturando alla lettera il sottoalbero <table:data-pilot-tables> di content.xml al momento dell'apertura e riproducendolo al salvataggio, dalla v2.382.0. Dalla v2.382.1 il frammento porta anche ogni binding di namespace XML dichiarato dai suoi antenati, così la definizione di pivot salvata resta ben formata per qualsiasi consumer, non solo per HotXLS

Il bug che ha reso necessarie entrambe le modifiche è uscito da un'esecuzione severa del corpus. Il campione official-pivot.ods, scritto da una build di sviluppo di LibreOffice 6.1, contiene una pivot chiamata DataPilot1 che legge Sheet1.A2:E30 e deposita il risultato in Sheet1.G6:J18. Aprilo con HotXLS, salvalo senza modifiche, conta gli elementi <table:data-pilot-table> nell'output: uno in ingresso, zero in uscita, sia su Win32 sia su Win64. Niente nel test aveva toccato la pivot. Il primo giro di sonde aveva confrontato solo le costanti delle celle ed era passato; è stata l'asserzione strutturale a svelare la perdita, il che ricorda che "i valori corrispondono" è una definizione debole di fedeltà nel round trip

Perché una pivot ODS sparisce dopo un salvataggio della libreria?

Una pivot ODS sparisce perché HotXLS non ha alcun modello in memoria per le tabelle data pilot di OpenDocument, e il writer ODS costruisce content.xml interamente dal modello. Il writer assembla stili automatici, un <table:table> per foglio di lavoro, <table:content-validations>, <table:named-expressions> e <table:database-ranges>, ognuno generato da oggetti che il workbook contiene davvero. Una definizione di pivot — ODF 1.3 Part 3 §9.6, un contenitore <table:data-pilot-tables> con un <table:data-pilot-table> per pivot, che porta il suo table:source-cell-range, i suoi figli table:data-pilot-field, il suo table:target-range-address e table:buttons — non ha un oggetto in cui vivere, quindi la parte rigenerata semplicemente la omette

Il contrasto con XLSX è voluto. HotXLS analizza le pivot cache e le tabelle pivot SpreadsheetML in un vero modello che puoi costruire, estendere con campi calcolati e aggiornare da Delphi, quindi quelle sopravvivono al salvataggio perché vengono riscritte, non copiate. Le pivot ODS sono una richiesta molto più rara, e modellare il vocabolario data pilot di ODF al solo scopo del round trip sarebbe un sacco di codice che nessuno modifica. La risposta pragmatica è la stessa che HotXLS applica già ai blocchi extLst sconosciuti in XLSX: conserva ciò che non modelli, byte per byte se puoi, evento per evento se non puoi

Che cosa sbagliava la prima cattura basata su Pos?

La cattura della v2.382.0 ritagliava la definizione di pivot da content.xml come semplice stringa, e al ritaglio mancavano le dichiarazioni di namespace che lo rendevano significativo. L'implementazione era corta come sembra: decodifica la parte in una WideString, trova il tag di apertura con Pos, trova dopo di esso il tag di chiusura, copia l'intervallo in FRawOdsDataPilotTablesXml sul workbook:

// HotXLS v2.382.0 -- superata una release dopo
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);   // tutto content.xml in memoria
  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;

L'asserzione sul conteggio è passata, e la correzione è stata rilasciata. A intercettarla è stato un secondo controllo più severo, aggiunto lo stesso giorno: ogni parte XML del pacchetto salvato viene data in pasto a un parser indipendente che conosce i namespace, esterno a HotXLS, e quel parser ha rifiutato il nuovo content.xml con un errore di prefisso non legato. La pivot di LibreOffice porta attributi di estensione del produttore — loext:ignore-selected-page="true" su un campo page, calcext:repeat-item-labels="false" su ogni livello — e la stringa ritagliata conteneva quegli attributi ma non le dichiarazioni xmlns:loext e xmlns:calcext che li legavano. Quelle dichiarazioni stavano sulla radice <office:document-content> del file sorgente, trentacinque in tutto, a duemila caratteri di distanza dalla pivot

W3C Namespaces in XML 1.0 §6.1 definisce la regola che rende questo un fallimento netto e non un dettaglio estetico: una dichiarazione di namespace è in scope dal tag di apertura dell'elemento su cui compare fino al tag di chiusura di quell'elemento, e ogni nome con prefisso dentro quello scope si risolve rispetto ad essa. Taglia un sottoalbero dal documento e lo tagli anche dallo scope. HotXLS scrive una propria radice <office:document-content> con undici dichiarazioni — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — quindi calcext: si risolveva per caso, table: si risolveva per caso, e loext: no. Un parser che conosce i namespace tratta un prefisso non legato come una violazione di well-formedness, il che significa che l'intera parte è illeggibile, non solo un attributo

Che cosa perdeva la cattura basata su Pos di official-pivot.ods in HotXLS: il sottoalbero della pivot porta attributi di estensione loext e calcext mentre le dichiarazioni xmlns che li legano stanno sulla radice office:document-content a trentacinque binding di distanza, quindi il frammento ritagliato lasciava ogni prefisso che usava non legato e un parser namespace-aware rifiutava l'intero content.xml
Una dichiarazione di namespace è in scope dal suo tag di apertura al suo tag di chiusura, e tagliare un sottoalbero dal documento lo taglia da quello scope, il che trasforma un attributo in una parte illeggibile

Come fa HotXLS a portare i binding xmlns degli antenati sul frammento?

HotXLS v2.382.1 ha sostituito il ritaglio di stringa con un passaggio su content.xml tramite il proprio TXMLReader in streaming, mantenendo uno stack di binding di namespace etichettati con la profondità a cui ciascuno è stato dichiarato, e copiando i binding ancora in vigore sull'elemento radice del frammento nel momento in cui si raggiunge il target. Il reader gira con PreserveWhitespaceText abilitato, così i nodi di testo tornano esattamente come sono stati scritti, e i tag ricostruiti usano TXMLReader.RawName e TXMLReader.Attribute[I].RawName — la grafia del prefisso presa dal file — invece dei nomi canonici che il reader consegna normalmente ai parser delle parti. Ecco il cuore del ciclo:

Come HotXLS v2.382.1 cattura il sottoalbero data pilot con il suo scope di namespace: un passaggio in streaming con TXMLReader tiene uno stack di binding xmlns etichettati con la profondità di dichiarazione, lo percorre dall'interno verso l'esterno sul target table:data-pilot-tables, rispetta lo shadowing tramite un insieme Seen, salta i prefissi che l'elemento dichiara da sé e fa pop dei binding sia sui tag di fine sia sugli elementi vuoti
Abbinare il target tramite il nome canonico del reader tiene funzionanti i produttori che riscrivono il prefisso table, e un sottoalbero che non si chiude mai solleva un'eccezione invece di riscrivere mezzo frammento al salvataggio
// Namespaces: TStringList di 'xmlns:p=uri' con la profondità di dichiarazione in Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // elemento, testo, CDATA, commento
  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);   // togli prima il '>' o '/>' finale
      ...
      // Porta i binding effettivi degli antenati sulla radice del frammento.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // vince il binding più interno
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // già dichiarato qui? salta
          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;                           // sottoalbero chiuso
  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);                   // esci dallo scope
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Tre dettagli in quel ciclo reggono la correttezza. Percorrere lo stack dal binding più interno verso l'esterno e ricordare ogni prefisso in Seen implementa lo shadowing: se un antenato più vicino rilega xmlns:table, vince il valore più vicino, esattamente come §6.1 prescrive. Saltare i prefissi che l'elemento dichiara già da sé evita di emettere due volte lo stesso attributo, che sarebbe un errore di well-formedness diverso. E la regola di pop scatta sui tag di fine e sugli elementi vuoti, perché <x/> non produce mai un evento EndElement: la stessa trappola del self-closing che la cattura di extLst in XLSX ha dovuto imparare. Abbinare il target tramite Reader.Name invece di RawName è una vittoria più silenziosa: il reader canonicalizza l'URI del namespace table di ODF sul prefisso table, quindi un produttore che lo scrive t:data-pilot-tables viene comunque riconosciuto, mentre il frammento emesso conserva il prefisso che il produttore ha usato

Il ciclo si rifiuta anche di tirare a indovinare. Se la parte finisce mentre la cattura è ancora aperta — un content.xml troncato o malformato — OdsCaptureDataPilotTablesXml solleva un'eccezione invece di restituire mezzo frammento, perché mezzo frammento verrebbe riscritto al salvataggio e trasformerebbe un input danneggiato in un output danneggiato con sopra il nome della libreria

Dove finisce il frammento nel content.xml salvato?

HotXLS scrive il frammento catturato dentro <office:spreadsheet> subito dopo il <table:named-expressions> che genera e prima di <table:database-ranges>. Il modello di contenuto di ODF 1.3 Part 3 per <office:spreadsheet> prescrive una sequenza fissa per quei figli finali, quindi un blocco alla lettera non può semplicemente essere aggiunto ovunque capiti al writer; va inserito in uno slot specifico. Dal lato del chiamante non c'è alcuna API e nulla da configurare; la definizione viaggia insieme a una normale apertura e a un normale salvataggio:

Dove finisce la definizione di pivot catturata in un salvataggio ODS di HotXLS: i figli di office:spreadsheet seguono la sequenza fissa di ODF dagli elementi table generati passando per table:content-validations e table:named-expressions, il frammento table:data-pilot-tables alla lettera si inserisce prima di table:database-ranges, e non esiste alcuna API perché la definizione viaggia insieme a OpenODS e SaveAsODS
Un blocco alla lettera non può essere aggiunto ovunque capiti al writer, e le copie dei binding degli antenati che porta sono innocue perché Namespaces in XML permette di ridichiarare un prefisso in uno scope annidato
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;   // modifica dentro l'intervallo sorgente della pivot
    Book.SaveAsODS('official-pivot-out.ods');
    // il content.xml nell'output porta ancora DataPilot1 con il suo
    // intervallo sorgente, i campi, l'intervallo target, i pulsanti e gli attributi loext:/calcext:
  finally
    Book.Free;
  end;
end;

La ridondanza è intenzionale e vale la pena conoscerla. La radice del frammento ora ripete xmlns:table e xmlns:calcext anche se la radice del documento salvato li dichiara a sua volta; Namespaces in XML permette di ridichiarare un prefisso in uno scope annidato, quindi i duplicati sono innocui. Per il campione di LibreOffice l'insieme trasportato è quello di tutte e trentacinque le dichiarazioni radice, circa due kilobyte in più rispetto alla definizione da 8,357 caratteri, perché la cattura non analizza quali prefissi il sottoalbero usi davvero. Una scansione dei prefissi usati ridurrebbe il tutto, e potrebbe arrivare più avanti; prima la correttezza, poi la compattezza

Una regola per ritagliare sottoalberi XML da riprodurre alla lettera

La lezione generale è che un sottoalbero è autosufficiente solo dopo che lo hai reso tale, e lo scope dei namespace è la prima cosa che si rompe quando te ne dimentichi. La checklist che HotXLS applica ormai a qualsiasi cattura di tipo "conserva ciò che non modelliamo":

  • Percorri il documento con un vero reader e tieni traccia dei binding in scope. La ricerca di stringhe con Pos non vede affatto lo scope, e sbaglia anche su elementi annidati con lo stesso nome, su una stringa che combacia dentro un commento o una sezione CDATA e su valori di attributo che contengono per caso il testo del tag
  • Copia i binding effettivi sulla radice del frammento, dal più interno in poi, una volta per prefisso, saltando quelli che la radice dichiara già
  • Conserva la grafia grezza del prefisso nei tag emessi; abbina il target tramite il namespace risolto, non tramite il prefisso letterale
  • Preserva i nodi di testo di spaziatura, e ricorda che un elemento vuoto chiude il proprio scope senza un evento di tag di fine
  • Valida la parte salvata con un parser che non sia la libreria sotto test. La libreria rileggerà volentieri il proprio output attraverso lo stesso percorso di codice permissivo che lo ha scritto

L'ultimo punto è quello che ha davvero trovato HXLS-003 la seconda volta. Il controllo di accettazione della v2.382.0 era un'espressione regolare che contava i tag di apertura data-pilot-table nel content.xml salvato, e un'espressione regolare vede un tag, non un documento: è cieca di fronte a se i prefissi su quel tag siano legati o no. Il runner severo del corpus aggiunto nella v2.382.1 analizza ogni parte XML e .rels del pacchetto salvato con un parser che conosce i namespace e poi confronta l'albero della pivot — tag, attributi ordinati, testo, figli, ricorsivamente — con l'originale. Quel confronto è espanso sui namespace, quindi una riscrittura del prefisso passerebbe comunque, mentre un prefisso non legato non può passare

Dove finisce la garanzia della riproduzione alla lettera

La riproduzione alla lettera conserva una definizione; non la capisce, e i confini derivano da questo. HotXLS non espone alcuna API per leggere, modificare o aggiornare una pivot ODS, quindi FRawOdsDataPilotTablesXml è un campo interno e l'unico comportamento osservabile è che la definizione sopravvive. Il frammento viene riserializzato dagli eventi del reader, non copiato come byte: la quotatura degli attributi e le forme self-closing vengono normalizzate, mentre testo e spaziature restano. L'XML catturato viene emesso solo dal writer del contenuto ODS, quindi un workbook aperto da .ods e salvato come .xlsx perde la pivot, e un workbook aperto da .xlsx non ha nulla da riprodurre in un salvataggio .ods: le asimmetrie dei percorsi di importazione ed esportazione ODS valgono qui come ovunque. E poiché la definizione è opaca, non può seguire le tue modifiche: rinomina Sheet1 o sposta i dati sorgente in HotXLS e la pivot salvata punterà ancora a Sheet1.A2:E30, lasciando al consumer il compito di segnalare un intervallo rotto al prossimo aggiornamento. C'è anche un caveat di ordinamento da segnalare qui: HotXLS emette gli intervalli AutoFilter come <table:database-ranges> dopo il frammento della pivot, e il campione del corpus non porta alcun database range, quindi un workbook che abbia sia un filtro sia una pivot andrebbe passato per un validatore di schema ODF prima di fare affidamento sull'ordine relativo di quei due elementi

Prova con i file del tuo produttore, non solo con il campione del corpus. Il trasporto dei namespace gestisce qualsiasi prefisso che un produttore dichiari su un antenato, ma un documento che dichiara un prefisso sull'elemento pivot stesso, o che usa un namespace di default per il vocabolario table, esercita i rami di salto e di shadowing che il campione di LibreOffice non tocca. Entrambi sono implementati; nessuno dei due ha ancora un campione nel corpus, e questa distinzione è esattamente il tipo di cosa che una voce di changelog tende a confondere

La cattura alla lettera delle data pilot nella v2.382.0 e la correzione dello scope dei namespace nella v2.382.1 sono incluse nell'attuale componente Excel HotXLS per Delphi, la cui pagina di prodotto elenca la copertura completa di lettura e scrittura ODS, XLSX e XLS per Delphi e C++Builder