Το HotXLS Delphi Excel Component κρατά τα OpenDocument data pilot tables μέσα από έναν κύκλο open-and-save ODS καταγράφοντας το subtree <table:data-pilot-tables> του content.xml verbatim τη στιγμή του open και αναπαράγοντάς το στο save, από το v2.382.0. Από το v2.382.1 το fragment κουβαλάει επιπλέον κάθε binding XML namespace που δήλωσαν οι πρόγονοί του, ώστε η saved ορισμή του pivot να παραμένει well formed για οποιονδήποτε consumer, όχι μόνο για το HotXLS
Το bug που επέβαλε και τις δύο αλλαγές βγήκε από μια αυστηρή εκτέλεση corpus. Το δείγμα official-pivot.ods, γραμμένο από development build του LibreOffice 6.1, κρατά ένα pivot με όνομα DataPilot1 που διαβάζει Sheet1.A2:E30 και προσγειώνει το αποτέλεσμά του στο Sheet1.G6:J18. Άνοιξέ το με HotXLS, σώσε το αναλλοίωτο, μετρησε τα στοιχεία <table:data-pilot-table> στην έξοδο: ένα μέσα, μηδέν έξω, σε Win32 και Win64 το ίδιο. Τίποτα στο test δεν άγγιξε το pivot. Ο πρώτος γύρος probes είχε μόνο συγκρίνει σταθερές κελιών και πέρασε· ο δομικός έλεγχος είναι αυτός που εκθέτει την απώλεια, που είναι υπενθύμιση ότι «τα values ταιριάζουν» είναι αδύναμος ορισμός πιστότητας round-trip
Γιατί ένας ODS pivot table εξαφανίζεται μετά από save της βιβλιοθήκης;
Ένας ODS pivot table εξαφανίζεται επειδή το HotXLS δεν έχει in-memory model για OpenDocument data pilot tables, και ο ODS writer χτίζει το content.xml ολόκληρο από το model. Ο writer συνθέτει automatic styles, ένα <table:table> ανά worksheet, <table:content-validations>, <table:named-expressions>, και <table:database-ranges>, το καθένα παραγόμενο από objects που το workbook πράγματι κρατά. Μια ορισμή pivot — ODF 1.3 Part 3 §9.6, ένα container <table:data-pilot-tables> με ένα <table:data-pilot-table> ανά pivot, που κουβαλάει το table:source-cell-range του, τα παιδιά table:data-pilot-field του, το table:target-range-address και table:buttons του — δεν έχει κανένα object να κατοικήσει, οπότε το ξαναδημιουργημένο part την παραλείπει σκέτα
Η αντίθεση με το XLSX είναι σκόπιμη. Το HotXLS κάνει parse τα pivot caches και pivot tables του SpreadsheetML σε πραγματικό model που μπορείς να χτίσεις, να επεκτείνεις με calculated fields και να κάνεις refresh από Delphi, οπότε εκείνα επιβιώνουν από ένα save επειδή ξαναγράφονται, όχι επειδή αντιγράφονται. Τα ODS pivots είναι πολύ σπανιότερο αίτημα, και το να μοντελοποιήσεις ολόκληρο το λεξιλόγιο ODF data pilot για χάρη μόνο του round-trip θα ήταν πολύς κώδικας που κανείς δεν επεξεργάζεται. Η πρακτική απάντηση είναι η ίδια που το HotXLS εφαρμόζει ήδη σε άγνωστα blocks extLst στο XLSX: κράτα ό,τι δεν μοντελοποιείς, byte-προς-byte αν μπορείς, event-προς-event αν δεν μπορείς
Τι έκανε λάθος η πρώτη καταγραφή με Pos;
Η καταγραφή του v2.382.0 έκοβε την ορισμή του pivot από το content.xml ως σκέτο string, και το κόψιμο έλειπε τις δηλώσεις namespace που το έκαναν νοηματικό. Η υλοποίηση ήταν τόσο κοντή όσο ακούγεται — αποκωδικοποίησε το part σε WideString, βρες το tag ανοίγματος με Pos, βρες το tag κλεισίματος μετά από αυτό, αντέγραψε το διάστημα στο FRawOdsDataPilotTablesXml του workbook:
// 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;
Ο έλεγχος πλήθους πήγε πράσινος, και το fix κυκλοφόρησε. Αυτό που το έπιασε ήταν ένας δεύτερος, αυστηρότερος έλεγχος προστιθέμενος την ίδια μέρα: κάθε XML part του saved package τροφοδοτείται σε ανεξάρτητο namespace-aware parser έξω από το HotXLS, και εκείνος ο parser απέρριψε το νέο content.xml με σφάλμα unbound prefix. Το pivot από το LibreOffice κουβαλάει attributes επεκτάσεων του producer — loext:ignore-selected-page="true" πάνω σε page field, calcext:repeat-item-labels="false" πάνω σε κάθε level — και το κομμένο string περιείχε εκείνα τα attributes αλλά όχι τις δηλώσεις xmlns:loext και xmlns:calcext που τα δένανε. Εκείνες οι δηλώσεις καθόνταν στη ρίζα <office:document-content> του source αρχείου, τριάντα πέντε τον αριθμό, δύο χιλιάδες χαρακτήρες μακριά από το pivot
Το W3C Namespaces in XML 1.0 §6.1 ορίζει τον κανόνα που κάνει αυτό σκληρή αποτυχία και όχι καλλωπιστική: μια δήλωση namespace είναι in scope από το start tag του element πάνω στο οποίο εμφανίζεται μέχρι το end tag εκείνου του element, και κάθε prefixed name μέσα σε εκείνο το scope λύνεται απέναντί της. Κόψεις ένα subtree έξω από το έγγραφο και το κόβεις έξω από το scope. Το HotXLS γράφει τη δική του ρίζα <office:document-content> με έντεκα δηλώσεις — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — οπότε το calcext: τύγχανε να λύνεται, το table: τύγχανε να λύνεται, και το loext: δεν λύνόταν. Ένας namespace-aware parser μεταχειρίζεται unbound prefix ως παραβίαση well-formedness, που σημαίνει ότι όλο το part είναι δυσανάγνωστο, όχι απλώς ένα attribute
Πώς κουβαλάει το HotXLS τις bindings xmlns των προγόνων πάνω στο fragment;
Το HotXLS v2.382.1 αντικατέστησε το κόψιμο string με ένα πέρασμα πάνω στο content.xml μέσω του δικού του streaming TXMLReader, κρατώντας μια στοίβα bindings namespace καρφωμένη με το βάθος στο οποίο καθεμία δηλώθηκε, και αντεγράφοντας τις bindings ακόμα ενεργές πάνω στο root element του fragment τη στιγμή που φτάνει ο στόχος. Ο reader τρέχει με PreserveWhitespaceText ενεργό ώστε οι text nodes να γυρνάνε ακριβώς όπως γράφτηκαν, και τα ξαναχτισμένα tags χρησιμοποιούν TXMLReader.RawName και TXMLReader.Attribute[I].RawName — την ορθογραφία του prefix από το αρχείο — αντί για τα canonical names που ο reader συνήθως δίνει στους part parsers. Εδώ είναι ο πυρήνας του loop:
// Namespaces: TStringList από 'xmlns:p=uri' με το βάθος δήλωσης στα Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // element, text, 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); // αφαίρεσε πρώτα το trailing '>' ή '/>'
...
// Μετέφερε τις ενεργές bindings των προγόνων πάνω στη ρίζα του fragment.
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; // το subtree έκλεισε
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');
Τρεις λεπτομέρειες σε εκείνο το loop κουβαλάνε την ορθότητα. Η διάσχιση της στοίβας από την πιο εσωτερική binding προς τα έξω με την απομνημόνευση κάθε prefix σε Seen υλοποιεί το shadowing: αν ένας πλησιέστερος πρόγονος ξαναδένει το xmlns:table, η πλησιέστερη τιμή κερδίζει, ακριβώς όπως λέει το §6.1 ότι πρέπει. Η παράλειψη prefixes που το element δηλώνει ήδη μόνο του αποφεύγει την έκπεμψη του ίδιου attribute δύο φορές, που θα ήταν διαφορετικό σφάλμα well-formedness. Και ο κανόνας pop πυροδοτείται σε end tags και σε κενά elements, γιατί το <x/> δεν παράγει ποτέ event EndElement — η ίδια παγίδα self-closing που έμαθε με τον δύσκολο τρόπο η καταγραφή extLst του XLSX. Το ταίριασμα του στόχου με Reader.Name αντί για RawName είναι πιο ήσυχο κέρδος: ο reader κανονικοποιεί το namespace URI του ODF table στο prefix table, οπότε ένας producer που το ονοματίζει t:data-pilot-tables εξακολουθεί να ταιριάζει, ενώ το εκπεμπόμενο fragment κρατά όποιο prefix χρησιμοποίησε ο producer
Το loop επίσης αρνείται να μαντέψει. Αν το part τελειώσει όσο η καταγραφή είναι ακόμα ανοιχτή — ένα πεκομένο ή κακοσχηματισμένο content.xml — το OdsCaptureDataPilotTablesXml πετάει exception αντί να γυρίσει μισό fragment, γιατί ένα μισό fragment θα ξαναγραφόταν στο save και θα μετέτρεπε μια κατεστραμμένη είσοδο σε κατεστραμμένη έξοδο με το όνομα της βιβλιοθήκης πάνω του
Πού προσγειώνεται το fragment στο saved content.xml;
Το HotXLS γράφει το καταγεγραμμένο fragment στο <office:spreadsheet> αμέσως μετά τα <table:named-expressions> που παράγει και πριν από τα <table:database-ranges>. Το content model του <office:spreadsheet> στο ODF 1.3 Part 3 προδιαγράφει σταθερή σειρά για εκείνα τα trailing παιδιά, οπότε ένα verbatim block δεν μπορεί απλώς να προσαρτηθεί όπου τυχαίνει να βρίσκεται ο writer· πρέπει να πέσει σε συγκεκριμένο slot. Από την πλευρά του caller δεν υπάρχει API και τίποτα προς ρύθμιση· η ορισμή έρχεται μαζί με ένα συνηθισμένο open και save:
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 range του pivot
Book.SaveAsODS('official-pivot-out.ods');
// Το content.xml στην έξοδο εξακολουθεί να κουβαλάει το DataPilot1 με το
// source range, τα πεδία, το target range, τα buttons και τα attributes loext:/calcext: του
finally
Book.Free;
end;
end;
Η πλεονάζουσα πληροφορία είναι σκόπιμη και αξίζει να τη ξέρεις. Η ρίζα του fragment επαναλαμβάνει πλέον xmlns:table και xmlns:calcext ακόμα κι αν η ρίζα του saved εγγράφου τις δηλώνει κι αυτή· το Namespaces in XML επιτρέπει εκ νέου δήλωση prefix σε φωλιασμένο scope, οπότε τα διπλότυπα είναι αβλαβή. Για το δείγμα LibreOffice το σύνολο που κουβαλάει είναι και οι τριάντα πέντε δηλώσεις ρίζας, περίπου δύο kilobytes πάνω από την ορισμή 8.357 χαρακτήρων, γιατί η καταγραφή δεν αναλύει ποια prefixes χρησιμοποιεί πράγματι το subtree. Ένα σκανάρισμα used-prefix θα το περικόπτε, και μπορεί να έρθει αργότερα· πρώτα η ορθότητα, μετά η συμπαγότητα
Ένας κανόνας για το κόψιμο subtrees από XML για verbatim replay
Το γενικό μάθημα είναι ότι ένα subtree είναι αυτοτελές μόνο αφού το κάνεις εσύ τέτοιο, και το namespace scope είναι το πρώτο πράγμα που σπάει όταν το ξεχάσεις. Η λίστα ελέγχου που το HotXLS εφαρμόζει πλέον σε κάθε καταγραφή «κράτα ό,τι δεν μοντελοποιούμε»:
- Διάσχιση του εγγράφου με πραγματικό reader και παρακολούθησε τις bindings in scope. Η αναζήτηση string με
Posδεν βλέπει καθόλου scope, και ταιριάζει λάθος επιπλέον σε φωλιασμένα elements με το ίδιο όνομα, σε string που ταιριάζει μέσα σε σχόλιο ή sectionCDATA, και σε τιμές attributes που τυχαίνει να περιέχουν το text του tag - Αντέγραψε τις ενεργές bindings πάνω στη ρίζα του fragment, από την πιο εσωτερική πρώτα, μία φορά ανά prefix, παραλείποντας ό,τι δηλώνει ήδη η ρίζα
- Κράτα την raw ορθογραφία του prefix στα εκπεμπόμενα tags· ταίριαξε τον στόχο με λυμένο namespace, όχι με κυριολεκτικό prefix
- Διατήρησε τους text nodes whitespace, και θυμήσου ότι ένα κενό element κλείνει το δικό του scope χωρίς event end-tag
- Επαλήθευσε το saved part με parser που δεν είναι η βιβλιοθήκη υπό test. Η βιβλιοθήκη θα ξαναδιαβάσει ευχαρίστως τη δική της έξοδο μέσα από το ίδιο επιεικές code path που την έγραψε
Το τελευταίο σημείο είναι αυτό που πράγματι βρήκε το HXLS-003 τη δεύτερη φορά. Ο έλεγχος αποδοχής του v2.382.0 ήταν regular expression που μετρούσε start tags data-pilot-table στο saved content.xml, και ένα regular expression βλέπει tag, όχι έγγραφο — είναι τυφλό στο αν τα prefixes πάνω σε εκείνο το tag είναι δεμένα. Ο αυστηρός corpus runner που προστέθηκε στο v2.382.1 κάνει parse κάθε XML και .rels part του saved package με namespace-aware parser και μετά συγκρίνει το δέντρο pivot — tag, ταξινομημένα attributes, text, παιδιά, αναδρομικά — απέναντι στο πρωτότυπο. Εκείνη η σύγκριση είναι namespace-expanded, οπότε μια ανα-ονομασία prefix θα εξακολουθούσε να περνάει και ένα unbound prefix δεν μπορεί
Πού τελειώνει η verbatim εγγύηση
Η verbatim αναπαραγωγή διατηρεί μια ορισμή· δεν την καταλαβαίνει, και τα όρια ακολουθούν από αυτό. Το HotXLS δεν εκθέτει κανένα API για να διαβάσεις, επεξεργαστείς ή κάνεις refresh έναν ODS pivot, οπότε το FRawOdsDataPilotTablesXml είναι εσωτερικό πεδίο και η μόνη παρατηρήσιμη συμπεριφορά είναι ότι η ορισμή επιβιώνει. Το fragment ξανα-σειριοποιείται από reader events, δεν αντιγράφεται ως bytes: το quoting των attributes και οι μορφές self-closing κανονικοποιούνται, ενώ το text και το whitespace κρατιούνται. Το καταγεγραμμένο XML εκπέμπεται μόνο από τον ODS content writer, οπότε ένα workbook που ανοίχτηκε από .ods και σώθηκε ως .xlsx χάνει το pivot, και ένα workbook που ανοίχτηκε από .xlsx δεν έχει τίποτα να αναπαράγει σε save .ods — οι ασυμμετρίες των μονοπατιών ODS import και export ισχύουν εδώ όπως παντού. Και επειδή η ορισμή είναι opaque, δεν μπορεί να ακολουθήσει τις επεξεργασίες σου: μετονόμασε το Sheet1 ή μετακίνησε τα source data στο HotXLS και ο saved pivot εξακολουθεί να δείχνει στο Sheet1.A2:E30, αφήνοντας στον consumer να αναφέρει σπασμένο range την επόμενη φορά που θα κάνει refresh. Και μια επιφύλαξη σειράς ανήκει εδώ: το HotXLS εκπέμπει AutoFilter ranges ως <table:database-ranges> μετά το pivot fragment, και το δείγμα corpus δεν κουβαλάει κανένα database range, οπότε ένα workbook με και filter και pivot πρέπει να περαστεί από ODF schema validator πριν βασιστείς στη σχετική σειρά των δύο στοιχείων
Τεστάρισε με τα αρχεία του δικού σου producer, όχι μόνο με το δείγμα corpus. Η μεταφορά των namespace bindings χειρίζεται κάθε prefix που δηλώνει ένας producer σε πρόγονο, αλλά ένα έγγραφο που δηλώνει prefix πάνω στο ίδιο το pivot element, ή που χρησιμοποιεί default namespace για το λεξιλόγιο table, εξασκεί τα branches skip και shadowing που το δείγμα LibreOffice δεν εξασκεί. Και τα δύο είναι υλοποιημένα· κανένα δεν έχει δείγμα στο corpus ακόμα, και εκείνη η διάκριση είναι ακριβώς το είδος του πράγματος που μια εγγραφή changelog τείνει να θολώνει
Η verbatim καταγραφή data pilot στο v2.382.0 και το fix namespace-scope στο v2.382.1 κυκλοφορούν μέσα στο τρέχον HotXLS Delphi Excel Component, του οποίου η σελίδα προϊόντος απαριθμεί την πλήρη κάλυψη ανάγνωσης-εγγραφής ODS, XLSX και XLS για Delphi και C++Builder