De HotXLS Delphi Excel Component behoudt OpenDocument data-pilottabellen over een ODS open-en-opslag-cyclus door de subboom <table:data-pilot-tables> van content.xml bij het openen woordelijk vast te leggen en hem bij het opslaan terug te spelen, sinds v2.382.0. Sinds v2.382.1 draagt het fragment ook elke XML-namespacebinding die zijn voorouders declareerden, zodat de opgeslagen draaitabeldefinitie welgevormd blijft voor elke consumer, niet alleen voor HotXLS
De bug die beide wijzigingen afdwong, kwam uit een strikte corpusrun. Het voorbeeld official-pivot.ods, geschreven door een LibreOffice 6.1-ontwikkelbuild, bevat één draaitabel met de naam DataPilot1 die Sheet1.A2:E30 leest en zijn resultaat in Sheet1.G6:J18 zet. Open hem met HotXLS, sla hem ongewijzigd op, en tel de elementen <table:data-pilot-table> in de uitvoer: één erin, nul eruit, op Win32 en Win64 allebei. Niets in de test raakte de draaitabel aan. De eerste ronde probes had alleen celconstanten vergeleken en was geslaagd; de structuurcontrole legde het verlies bloot, en dat is een herinnering dat "waarden kloppen" een zwakke definitie van round-trip-trouw is
Waarom verdwijnt een ODS-draaitabel na een opslag via de library?
Een ODS-draaitabel verdwijnt omdat HotXLS geen in-memory model heeft voor OpenDocument data-pilottabellen, en de ODS-schrijver bouwt content.xml volledig uit het model. De schrijver stelt automatische stijlen samen, één <table:table> per werkblad, <table:content-validations>, <table:named-expressions> en <table:database-ranges>, elk gegenereerd uit objecten die de workbook werkelijk bevat. Een draaitabeldefinitie — ODF 1.3 Part 3 §9.6, een container <table:data-pilot-tables> met één <table:data-pilot-table> per draaitabel, met zijn table:source-cell-range, zijn kinderen table:data-pilot-field, zijn table:target-range-address en table:buttons — heeft geen object om in te leven, dus het opnieuw gegenereerde onderdeel laat hem simpelweg weg
Het contrast met XLSX is bewust. HotXLS parseert SpreadsheetML-pivotcaches en draaitabellen naar een echt model dat je vanuit Delphi kunt bouwen, met berekende velden kunt uitbreiden en kunt verversen, dus die overleven een opslag omdat ze worden herschreven en niet gekopieerd. ODS-draaitabellen zijn een veel zeldzamer verzoek, en het modelleren van het ODF data-pilot-vocabulaire alleen voor de round trip zou een hoop code zijn die niemand bewerkt. Het pragmatische antwoord is hetzelfde dat HotXLS al toepast op onbekende extLst-blokken in XLSX: behoud wat je niet modelleert, byte voor byte als het kan, event voor event als het niet kan
Wat deed de eerste op Pos gebaseerde capture fout?
De capture van v2.382.0 sneed de draaitabeldefinitie als een gewone string uit content.xml, en het stuk miste de namespacedeclaraties die het betekenis gaven. De implementatie was zo kort als het klinkt — decodeer het onderdeel naar een WideString, vind de openingstag met Pos, vind de sluittag erna, en kopieer het bereik naar FRawOdsDataPilotTablesXml op de workbook:
// HotXLS v2.382.0 -- één release later vervangen
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); // hele content.xml in het geheugen
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;
De telassertie werd groen, en de fix ging de deur uit. Wat het ving was een tweede, strengere controle die dezelfde dag werd toegevoegd: elk XML-onderdeel van het opgeslagen pakket gaat naar een onafhankelijke namespace-bewuste parser buiten HotXLS, en die parser weigerde de nieuwe content.xml met een fout over een ongebonden prefix. De draaitabel uit LibreOffice draagt producent-extensieattributen — loext:ignore-selected-page="true" op een pagina-veld, calcext:repeat-item-labels="false" op elk niveau — en de uitgesneden string bevatte die attributen wel, maar niet de declaraties xmlns:loext en xmlns:calcext die ze bonden. Die declaraties stonden op de wortel <office:document-content> van het bronbestand, vijfendertig stuks, tweeduizend tekens bij de draaitabel vandaan
W3C Namespaces in XML 1.0 §6.1 definieert de regel die dit een harde fout maakt in plaats van een cosmetische: een namespacedeclaratie is in scope vanaf de begintag van het element waarop hij staat tot de eindtag van dat element, en elke naam met een prefix binnen die scope wordt ertegen opgelost. Knip je een subboom uit het document, dan knip je hem uit de scope. HotXLS schrijft zijn eigen wortel <office:document-content> met elf declaraties — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — dus calcext: werd toevallig opgelost, table: werd toevallig opgelost, en loext: niet. Een namespace-bewuste parser behandelt een ongebonden prefix als een schending van de welgevormdheid, wat betekent dat het hele onderdeel onleesbaar is en niet slechts één attribuut
Hoe draagt HotXLS de xmlns-bindingen van voorouders over op het fragment?
HotXLS v2.382.1 verving het stringknipwerk door een ronde over content.xml via zijn eigen streaming TXMLReader, waarbij een stapel namespacebindingen wordt bijgehouden met de diepte waarop elk gedeclareerd is, en de nog geldende bindingen op het wortelelement van het fragment worden gekopieerd op het moment dat het doel wordt bereikt. De reader loopt met PreserveWhitespaceText aan zodat tekstknopen exact terugkomen zoals ze geschreven zijn, en de herbouwde tags gebruiken TXMLReader.RawName en TXMLReader.Attribute[I].RawName — de prefixspelling uit het bestand — in plaats van de canonieke namen die de reader normaal aan de onderdeelparsers geeft. Hier is de kern van de lus:
// Namespaces: TStringList van 'xmlns:p=uri' met de declarerende diepte in Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // element, tekst, CDATA, commentaar
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); // strip eerst de afsluitende '>' of '/>'
...
// Draag de geldende voorouderbindingen over op de wortel van het fragment.
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // de binnenste binding wint
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // hier al gedeclareerd? sla over
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; // subboom gesloten
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); // verlaat de scope
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Drie details in die lus dragen de correctheid. De stapel van de binnenste binding naar buiten lopen en elke prefix onthouden in Seen implementeert shadowing: als een dichterbij liggende voorouder xmlns:table opnieuw bindt, wint de dichterbij liggende waarde, precies zoals §6.1 zegt dat het moet. Prefixes overslaan die het element zelf al declareert voorkomt dat hetzelfde attribuut twee keer wordt uitgeschreven, wat een andere welgevormdheidsfout zou zijn. En de pop-regel vuurt op eindtags en op lege elementen, omdat <x/> nooit een EndElement-event oplevert — dezelfde valkuil met zelfsluitende tags die de XLSX-extLst-capture ook moest leren. Het doel matchen op Reader.Name in plaats van RawName is een stillere winst: de reader canoniseert de ODF-tablenamespace-URI naar de prefix table, dus een producent die hem t:data-pilot-tables spelt matcht nog steeds, terwijl het uitgeschreven fragment de prefix houdt die de producent gebruikte
De lus weigert ook te gokken. Als het onderdeel eindigt terwijl de capture nog open is — een afgebroken of misvormde content.xml — gooit OdsCaptureDataPilotTablesXml een exception in plaats van een half fragment terug te geven, want een half fragment zou bij het opslaan worden teruggeschreven en van beschadigde invoer beschadigde uitvoer maken met de naam van de library erop
Waar landt het fragment in de opgeslagen content.xml?
HotXLS schrijft het vastgelegde fragment in <office:spreadsheet>, direct na de <table:named-expressions> die het genereert en vóór <table:database-ranges>. Het contentmodel van <office:spreadsheet> in ODF 1.3 Part 3 schrijft een vaste volgorde voor die nakomelingen achteraan voor, dus een woordelijk blok kan niet zomaar worden toegevoegd waar de schrijver toevallig staat; het moet in een specifieke sleuf vallen. Vanuit de aanroeper gezien is er geen API en niets te configureren; de definitie lift mee met een gewone open en 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; // bewerking binnen het bronbereik van de draaitabel
Book.SaveAsODS('official-pivot-out.ods');
// content.xml in de uitvoer draagt nog steeds DataPilot1 met zijn
// bronbereik, velden, doelbereik, knoppen en loext:/calcext:-attributen
finally
Book.Free;
end;
end;
De redundantie is opzettelijk en het weten waard. De wortel van het fragment herhaalt nu xmlns:table en xmlns:calcext, ook al declareert de wortel van het opgeslagen document ze ook; Namespaces in XML staat het opnieuw declareren van een prefix in een geneste scope toe, dus de duplicaten zijn onschadelijk. Voor het LibreOffice-voorbeeld is de overgedragen set alle vijfendertig worteldeclaraties, ongeveer twee kilobyte bovenop de definitie van 8.357 tekens, omdat de capture niet analyseert welke prefixes de subboom werkelijk gebruikt. Een scan op gebruikte prefixes zou dat kunnen beperken, en die komt misschien later; correctheid eerst, compactheid tweede
Een regel voor het uit XML knippen van subbomen voor woordelijke terugspeel
De algemene les is dat een subboom pas op zichzelf staat als je hem dat hebt gemaakt, en namespace-scope is het eerste dat breekt als je dat vergeet. De checklist die HotXLS nu op elke capture van het type "behoud wat we niet modelleren" toepast:
- Loop het document door met een echte reader en houd de bindingen in scope bij. Zoeken met
Posin een string ziet scope helemaal niet, en het matcht bovendien verkeerd op geneste elementen met dezelfde naam, op een overeenkomende string binnen een commentaar ofCDATA-sectie, en op attribuutwaarden die toevallig de tagtekst bevatten - Kopieer de geldende bindingen naar de wortel van het fragment, binnenste eerst, één keer per prefix, en sla over wat de wortel al declareert
- Houd de ruwe prefixspelling in de uitgeschreven tags; match het doel op opgeloste namespace, niet op letterlijke prefix
- Behoud whitespace-tekstknopen, en onthoud dat een leeg element zijn eigen scope sluit zonder eindtag-event
- Valideer het opgeslagen onderdeel met een parser die niet de library onder test is. De library leest zijn eigen uitvoer met plezier opnieuw via hetzelfde toegeeflijke codepad dat hem schreef
Het laatste punt is precies wat HXLS-003 de tweede keer vond. De acceptatiecontrole van v2.382.0 was een reguliere expressie die begintags van data-pilot-table in de opgeslagen content.xml telde, en een reguliere expressie ziet een tag, geen document — hij is blind voor de vraag of de prefixes op die tag gebonden zijn. De strikte corpusrunner die in v2.382.1 is toegevoegd, parst elk XML- en .rels-onderdeel van het opgeslagen pakket met een namespace-bewuste parser en vergelijkt daarna de draaitabelboom — tag, gesorteerde attributen, tekst, kinderen, recursief — met het origineel. Die vergelijking is namespace-uitgebreid, dus een andere prefixspelling zou nog slagen en een ongebonden prefix kan dat niet
Waar de woordelijke garantie ophoudt
Woordelijke terugspeel behoudt een definitie; hij begrijpt er niets van, en de grenzen volgen daaruit. HotXLS biedt geen API om een ODS-draaitabel te lezen, te bewerken of te verversen, dus FRawOdsDataPilotTablesXml is een intern veld en het enige waarneembare gedrag is dat de definitie overleeft. Het fragment wordt opnieuw geserialiseerd uit reader-events, niet als bytes gekopieerd: het zetten van aanhalingstekens en zelfsluitende vormen wordt genormaliseerd, terwijl tekst en whitespace behouden blijven. De vastgelegde XML wordt alleen door de ODS-contentwriter uitgeschreven, dus een workbook die uit .ods is geopend en als .xlsx wordt opgeslagen verliest de draaitabel, en een workbook die uit .xlsx is geopend heeft niets om in een .ods-opslag terug te spelen — de asymmetrieën van de ODS-import- en exportpaden gelden hier net als overal. En omdat de definitie opaque is, kan hij je bewerkingen niet volgen: hernoem Sheet1 of verplaats de brongegevens in HotXLS en de opgeslagen draaitabel wijst nog steeds naar Sheet1.A2:E30, zodat de consumer bij de volgende verversing een kapot bereik meldt. Eén volgordewaarschuwing hoort hier ook: HotXLS schrijft AutoFilter-bereiken als <table:database-ranges> na het draaitabelfragment, en het corpusvoorbeeld bevat geen databaserange, dus een workbook met zowel een filter als een draaitabel kun je beter door een ODF-schemavalidator halen voordat je op de relatieve volgorde van die twee elementen vertrouwt
Test met bestanden van je eigen producent, niet alleen met het corpusvoorbeeld. De namespace-overdracht behandelt elke prefix die een producent op een voorouder declareert, maar een document dat een prefix op het draaitabelelement zelf declareert, of dat een default namespace voor het table-vocabulaire gebruikt, oefent de takken voor overslaan en shadowing uit die het LibreOffice-voorbeeld niet raakt. Beide zijn geïmplementeerd; geen van beide heeft al een voorbeeld in het corpus, en dat onderscheid is precies het soort ding dat een changelog-item al snel vervaagt
De woordelijke data-pilot-capture in v2.382.0 en de namespace-scope-fix in v2.382.1 zitten in de huidige HotXLS Delphi Excel Component, waarvan de productpagina de volledige ODS-, XLSX- en XLS-lees-en-schrijfdekking voor Delphi en C++Builder vermeldt