Das HotXLS Delphi Excel Component bewahrt OpenDocument-Daten-Pilot-Tabellen über einen ODS-Öffnen-und-Speichern-Zyklus, indem es seit v2.382.0 den <table:data-pilot-tables>-Teilbaum von content.xml beim Öffnen wortgetreu einfängt und beim Speichern wiedergibt. Seit v2.382.1 trägt das Fragment zusätzlich jede XML-Namespace-Bindung, die seine Vorfahren deklariert haben, sodass die gespeicherte Pivot-Definition für jeden Consumer wohlgeformt bleibt, nicht nur für HotXLS
Der Bug, der beide Änderungen erzwang, kam aus einem strengen Korpus-Lauf. Das Beispiel official-pivot.ods, geschrieben von einem LibreOffice-6.1-Entwicklerbuild, hält eine Pivot namens DataPilot1, die Sheet1.A2:E30 liest und ihr Ergebnis in Sheet1.G6:J18 ablegt. Mit HotXLS öffnen, unverändert speichern, die <table:data-pilot-table>-Elemente in der Ausgabe zählen: eins rein, null raus, auf Win32 wie Win64. Nichts im Test hatte die Pivot angefasst. Die erste Runde der Sonden hatte nur Zellkonstanten verglichen und war durchgelaufen; die strukturelle Assertion war es, die den Verlust offenlegte – eine Erinnerung daran, dass „Werte stimmen überein“ eine schwache Definition von Round-Trip-Treue ist
Warum verschwindet eine ODS-Pivot-Tabelle nach dem Speichern durch die Bibliothek?
Eine ODS-Pivot-Tabelle verschwindet, weil HotXLS kein In-Memory-Modell für OpenDocument-Daten-Pilot-Tabellen hat und der ODS-Writer content.xml komplett aus dem Modell baut. Der Writer setzt automatische Styles zusammen, ein <table:table> pro Worksheet, <table:content-validations>, <table:named-expressions> und <table:database-ranges>, jeweils generiert aus Objekten, die die Mappe tatsächlich hält. Eine Pivot-Definition – ODF 1.3 Part 3 §9.6, ein <table:data-pilot-tables>-Container mit einem <table:data-pilot-table> pro Pivot, mit ihrem table:source-cell-range, ihren table:data-pilot-field-Kindern, ihrer table:target-range-address und table:buttons – hat kein Objekt, in dem sie wohnen könnte, also lässt das neu erzeugte Part sie schlicht weg
Der Kontrast zu XLSX ist Absicht. HotXLS parst SpreadsheetML-Pivot-Caches und -Pivot-Tabellen in ein echtes Modell, das Sie in Delphi aufbauen, mit berechneten Feldern erweitern und auffrischen können, also überleben die ein Speichern, weil sie neu geschrieben, nicht kopiert werden. ODS-Pivots sind ein viel seltenerer Wunsch, und das ODF-Daten-Pilot-Vokabular nur des Round Trips wegen zu modellieren wäre eine Menge Code, den niemand anfasst. Die pragmatische Antwort ist dieselbe, die HotXLS bereits auf unbekannte extLst-Blöcke in XLSX anwendet: Bewahren Sie auf, was Sie nicht modellieren – Byte für Byte, wenn es geht, Ereignis für Ereignis, wenn nicht
Was hat die erste Pos-basierte Erfassung falsch gemacht?
Die Erfassung in v2.382.0 schnitt die Pivot-Definition als schlichten String aus content.xml heraus, und dem Schnitt fehlten die Namespace-Deklarationen, die ihn erst sinnvoll machten. Die Implementierung war so kurz, wie sie klingt – das Part in einen WideString dekodieren, das öffnende Tag mit Pos finden, das schließende Tag dahinter finden, den Span in FRawOdsDataPilotTablesXml auf der Mappe ablegen:
// HotXLS v2.382.0 -- eine Version später abgelöst
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); // ganzes content.xml im Speicher
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;
Die Zähl-Assertion ging auf grün, und der Fix wurde ausgeliefert. Erwischt hat ihn eine zweite, strengere Prüfung, die am selben Tag dazukam: Jedes XML-Part des gespeicherten Pakets wird außerhalb von HotXLS an einen unabhängigen namespace-bewussten Parser verfüttert, und dieser Parser wies das neue content.xml mit einem Unbound-Prefix-Fehler zurück. Die Pivot aus LibreOffice trägt Producer-Erweiterungsattribute – loext:ignore-selected-page="true" an einem Seitenfeld, calcext:repeat-item-labels="false" auf jeder Ebene – und der geschnittene String enthielt diese Attribute, aber nicht die xmlns:loext- und xmlns:calcext-Deklarationen, die sie banden. Diese Deklarationen saßen auf dem <office:document-content>-Root der Quelldatei, fünfunddreißig an der Zahl, zweitausend Zeichen von der Pivot entfernt
W3C Namespaces in XML 1.0 §6.1 definiert die Regel, die daraus ein hartes Scheitern macht und keinen kosmetischen Schönheitsfehler: Eine Namespace-Deklaration gilt vom Start-Tag des Elements, auf dem sie steht, bis zu dessen End-Tag, und jeder präfixierte Name innerhalb dieses Scopes löst sich gegen sie auf. Schneiden Sie einen Teilbaum aus dem Dokument heraus, schneiden Sie ihn aus dem Scope heraus. HotXLS schreibt sein eigenes <office:document-content>-Root mit elf Deklarationen – office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo – also löste sich calcext: zufällig auf, table: ebenfalls, und loext: nicht. Ein namespace-bewusster Parser wertet einen ungebundenen Präfix als Wohlgeformtheitsverletzung, was bedeutet, dass das ganze Part unlesbar ist, nicht bloß ein Attribut
Wie bringt HotXLS die xmlns-Bindungen der Vorfahren aufs Fragment?
HotXLS v2.382.1 ersetzte den String-Schnitt durch einen Lauf über content.xml mit dem eigenen Streaming-TXMLReader, hielt einen Stack von Namespace-Bindungen, markiert mit der Tiefe, in der jede deklariert wurde, und kopierte die noch gültigen Bindungen im Moment des Ziel-Treffers aufs Root-Element des Fragments. Der Reader läuft mit aktiviertem PreserveWhitespaceText, sodass Textknoten exakt wie geschrieben zurückkommen, und die wieder aufgebauten Tags benutzen TXMLReader.RawName und TXMLReader.Attribute[I].RawName – die Präfix-Schreibweise aus der Datei – statt der kanonischen Namen, die der Reader normalerweise an die Part-Parser übergibt. Hier der Kern der Schleife:
// Namespaces: TStringList aus 'xmlns:p=uri' mit der deklarienden Tiefe in Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // Element, Text, CDATA, Kommentar
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); // zuerst das abschließende '>' oder '/>' abschneiden
...
// Die wirksamen Vorfahren-Bindungen aufs Fragment-Root übertragen.
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // das innerste Binding gewinnt
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // hier bereits deklariert? überspringen
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; // Teilbaum geschlossen
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 verlassen
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Drei Details in dieser Schleife tragen die Korrektheit. Den Stack vom innersten Binding nach außen zu durchlaufen und jeden Präfix in Seen zu merken implementiert Shadowing: Bindet ein näherer Vorfahre xmlns:table neu, gewinnt der nähere Wert, genau wie §6.1 es verlangt. Präfixe zu überspringen, die das Element selbst schon deklariert, vermeidet, dasselbe Attribut doppelt auszugeben – das wäre ein anderer Wohlgeformtheitsfehler. Und die Pop-Regel greift bei End-Tags und bei leeren Elementen, denn <x/> erzeugt nie ein EndElement-Ereignis – dieselbe Self-Closing-Falle, die auch die XLSX-extLst-Erfassung lernen musste. Das Ziel über Reader.Name statt RawName zu matchen ist der leisere Gewinn: Der Reader kanonisiert die ODF-Table-Namespace-URI zum table-Präfix, sodass ein Producer, der es als t:data-pilot-tables schreibt, weiterhin passt, während das ausgegebene Fragment die Präfix-Schreibweise des Producers behält
Die Schleife rät auch nicht. Endet das Part, während die Erfassung noch offen ist – ein abgeschnittenes oder defektes content.xml –, wirft OdsCaptureDataPilotTablesXml eine Exception, statt ein halbes Fragment zurückzugeben, denn ein halbes Fragment würde beim Speichern zurückgeschrieben und ein beschädigtes Eingabedokument in eine beschädigte Ausgabe verwandeln, und das mit dem Namen der Bibliothek darauf
Wo landet das Fragment im gespeicherten content.xml?
HotXLS schreibt das eingefangene Fragment in <office:spreadsheet>, unmittelbar nach den selbst erzeugten <table:named-expressions> und vor <table:database-ranges>. Das Content-Modell von <office:spreadsheet> nach ODF 1.3 Part 3 schreibt für diese nachfolgenden Kinder eine feste Reihenfolge vor, also kann ein wortgetreuer Block nicht einfach dort angehängt werden, wo der Writer gerade steht; er muss in einen konkreten Slot fallen. Aus Aufrufersicht gibt es keine API und nichts zu konfigurieren; die Definition reist mit einem gewöhnlichen Öffnen und Speichern mit:
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; // Bearbeitung im Pivot-Quellbereich
Book.SaveAsODS('official-pivot-out.ods');
// content.xml in der Ausgabe trägt DataPilot1 weiterhin mit seinem
// Quellbereich, seinen Feldern, Zielbereich, Buttons und loext:/calcext:-Attributen
finally
Book.Free;
end;
end;
Die Redundanz ist Absicht und wissenswert. Das Fragment-Root wiederholt jetzt xmlns:table und xmlns:calcext, obwohl das Root des gespeicherten Dokuments sie ebenfalls deklariert; Namespaces in XML erlaubt das Neudeklarieren eines Präfix in einem verschachtelten Scope, also sind die Duplikate harmlos. Beim LibreOffice-Beispiel ist der mitgeführte Satz die Gesamtheit aller fünfunddreißig Root-Deklarationen, rund zwei Kilobyte zusätzlich zu der 8.357 Zeichen langen Definition, weil die Erfassung nicht analysiert, welche Präfixe der Teilbaum tatsächlich benutzt. Ein Used-Prefix-Scan würde das kürzen, und er mag später kommen; Korrektheit zuerst, Kompaktheit danach
Eine Regel fürs Herausschneiden von Teilbäumen aus XML zur wortgetreuen Wiedergabe
Die allgemeine Lehre: Ein Teilbaum ist erst dann in sich geschlossen, wenn Sie ihn dazu gemacht haben, und der Namespace-Scope ist das erste, was bricht, wenn Sie es vergessen. Die Checkliste, die HotXLS jetzt auf jede „Bewahre, was wir nicht modellieren“-Erfassung anwendet:
- Laufen Sie das Dokument mit einem echten Reader ab und verfolgen Sie die Bindungen im Scope. Stringsuche mit
Possieht den Scope gar nicht, und sie greift auch daneben bei verschachtelten Elementen desselben Namens, bei einem passenden String innerhalb eines Kommentars oder einerCDATA-Sektion und bei Attributwerten, die zufällig den Tag-Text enthalten - Kopieren Sie die wirksamen Bindungen aufs Fragment-Root, vom innersten zuerst, einmal pro Präfix, und überspringen Sie, was das Root bereits deklariert
- Behalten Sie die rohe Präfix-Schreibweise in den ausgegebenen Tags; matchen Sie das Ziel über den aufgelösten Namespace, nicht über den wörtlichen Präfix
- Bewahren Sie Whitespace-Textknoten, und denken Sie daran, dass ein leeres Element seinen eigenen Scope schließt, ohne ein End-Tag-Ereignis
- Validieren Sie das gespeicherte Part mit einem Parser, der nicht die getestete Bibliothek ist. Die Bibliothek liest ihre eigene Ausgabe gern durch denselben nachsichtigen Codepfad wieder ein, der sie geschrieben hat
Der letzte Punkt ist der, der HXLS-003 beim zweiten Mal tatsächlich fand. Die Abnahme-Prüfung in v2.382.0 war ein regulärer Ausdruck, der data-pilot-table-Start-Tags im gespeicherten content.xml zählte, und ein regulärer Ausdruck sieht ein Tag, kein Dokument – er ist blind dafür, ob die Präfixe auf diesem Tag gebunden sind. Der strenge Korpus-Läufer aus v2.382.1 parst jedes XML- und .rels-Part des gespeicherten Pakets mit einem namespace-bewussten Parser und vergleicht dann den Pivot-Baum – Tag, sortierte Attribute, Text, Kinder, rekursiv – mit dem Original. Dieser Vergleich ist namespace-aufgelöst, eine umgeschriebene Präfix-Schreibweise würde also weiterhin durchgehen, ein ungebundener Präfix nicht
Wo die wortgetreue Garantie endet
Wortgetreue Wiedergabe bewahrt eine Definition; sie versteht sie nicht, und die Grenzen folgen daraus. HotXLS stellt keine API bereit, um eine ODS-Pivot zu lesen, zu bearbeiten oder aufzufrischen, also ist FRawOdsDataPilotTablesXml ein internes Feld, und das einzige beobachtbare Verhalten ist, dass die Definition überlebt. Das Fragment wird aus Reader-Ereignissen neu serialisiert, nicht als Bytes kopiert: Attribut-Quoting und Self-Closing-Formen werden normalisiert, während Text und Whitespace bleiben. Das eingefangene XML gibt nur der ODS-Content-Writer aus, also verliert eine Mappe, die aus .ods geöffnet und als .xlsx gespeichert wird, die Pivot, und eine aus .xlsx geöffnete Mappe hat nichts, was sie in einen .ods-Save hinein wiedergeben könnte – die Asymmetrien der ODS-Import- und -Export-Pfade gelten hier wie überall. Und weil die Definition opak ist, kann sie Ihren Änderungen nicht folgen: Benennen Sie Sheet1 um oder verschieben Sie die Quelldaten in HotXLS, zeigt die gespeicherte Pivot weiter auf Sheet1.A2:E30, und der Consumer meldet beim nächsten Auffrischen einen kaputten Bereich. Eine Ordnungs-Anmerkung gehört ebenfalls hierher: HotXLS gibt AutoFilter-Bereiche als <table:database-ranges> nach dem Pivot-Fragment aus, und das Korpus-Beispiel trägt keinen Datenbankbereich, also sollte eine Mappe mit beidem, Filter und Pivot, durch einen ODF-Schema-Validator laufen, bevor Sie sich auf die relative Reihenfolge dieser beiden Elemente verlassen
Testen Sie mit den Dateien Ihres eigenen Producers, nicht nur mit dem Korpus-Beispiel. Der Namespace-Übertrag meistert jeden Präfix, den ein Producer auf einem Vorfahren deklariert, aber ein Dokument, das einen Präfix auf dem Pivot-Element selbst deklariert oder einen Default-Namespace für das Table-Vokabular benutzt, fordert die Skip- und Shadowing-Zweige, die das LibreOffice-Beispiel nicht berührt. Beide sind implementiert; keines davon hat bisher ein Beispiel im Korpus, und genau diese Unterscheidung ist die Art von Ding, die ein Changelog-Eintrag gern verwischt
Die wortgetreue Daten-Pilot-Erfassung aus v2.382.0 und der Namespace-Scope-Fix aus v2.382.1 stecken im aktuellen HotXLS Delphi Excel Component, dessen Produktseite die vollständige ODS-, XLSX- und XLS-Lese-Schreib-Abdeckung für Delphi und C++Builder auflistet