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
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:
// 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:
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
Posnon vede affatto lo scope, e sbaglia anche su elementi annidati con lo stesso nome, su una stringa che combacia dentro un commento o una sezioneCDATAe 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