HotXLS Delphi Excel Component bevarer OpenDocument data pilot-tabeller gennem en ODS open-og-save-cyklus ved at fange <table:data-pilot-tables>-subtræet af content.xml ordret ved open-tid og afspille det ved save, siden v2.382.0. Siden v2.382.1 bærer fragmentet også hver XML-namespace-binding, dets forfædre har deklareret, så den gemte pivot-definition forbliver velformet for enhver konsument, ikke kun for HotXLS
Bugen, der tvang begge ændringer frem, kom ud af en striks corpus-kørsel. Eksemplet official-pivot.ods, skrevet af en LibreOffice 6.1-udviklingsbuild, indeholder én pivot ved navn DataPilot1, som læser Sheet1.A2:E30 og lander sit resultat i Sheet1.G6:J18. Åbn den med HotXLS, gem den uændret, tæl <table:data-pilot-table>-elementerne i outputtet: Én ind, nul ud, på Win32 og Win64 lige så. Intet i testen rørte pivoten. Den første runde af sonder havde kun sammenlignet cellekonstanter og bestået; den strukturelle assertion var det, der eksponerede tabet, hvilket er en påmindelse om, at "værdier matcher" er en svag definition af round-trip-fidelity
Hvorfor forsvinder en ODS-pivottabel efter en biblioteks-gemning?
En ODS-pivottabel forsvinder, fordi HotXLS ikke har nogen in-memory-model for OpenDocument data pilot-tabeller, og ODS-writeren bygger content.xml udelukkende fra modellen. Writeren samler automatic styles, én <table:table> pr. regneark, <table:content-validations>, <table:named-expressions> og <table:database-ranges>, hver genereret fra objekter, arbejdsbogen faktisk holder. En pivot-definition — ODF 1.3 Part 3 §9.6, en <table:data-pilot-tables>-container med én <table:data-pilot-table> pr. pivot, der bærer dens table:source-cell-range, dens table:data-pilot-field-børn, dens table:target-range-address og table:buttons — har intet objekt at bo i, så den regenererede part udelader den simpelthen
Kontrasten til XLSX er bevidst. HotXLS parser SpreadsheetML pivot caches og pivottabeller ind i en rigtig model, du kan bygge, udvide med beregnede felter og opdatere fra Delphi, så de overlever en gemning, fordi de skrives om, ikke kopieres. ODS-pivotter er et langt sjældnere ønske, og at modellere ODF-datapilot-ordforrådet alene for round-trippens skyld ville være en masse kode, ingen redigerer. Det pragmatiske svar er det samme, HotXLS allerede anvender på ukendte extLst-blokke i XLSX: Bevar det, du ikke modellerer, byte for byte, hvis du kan, event for event, hvis du ikke kan
Hvad fik den første Pos-baserede capture forkert?
Capturen i v2.382.0 skar pivot-definitionen ud af content.xml som en almindelig streng, og snittet manglede de namespace-deklarationer, der gjorde det meningsfuldt. Implementeringen var så kort, som den lyder — dekod parten til en WideString, find åbningstagget med Pos, find lukketagget efter det, kopiér spændet ind i FRawOdsDataPilotTablesXml på arbejdsbogen:
// HotXLS v2.382.0 -- afløst én udgivelse senere
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 i hukommelsen
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;
Tæl-assertionen blev grøn, og fixet shippede. Det, der fangede det, var et andet, strengere tjek tilføjet samme dag: Hver XML-part af den gemte pakke føres til en namespace-bevidst parser uden for HotXLS, og den parser afviste den nye content.xml med en unbound-prefix-fejl. Pivoten fra LibreOffice bærer producer-udvidelsesattributter — loext:ignore-selected-page="true" på et sidefelt, calcext:repeat-item-labels="false" på hvert niveau — og snitstrengen indeholdt de attributter, men ikke de xmlns:loext- og xmlns:calcext-deklarationer, der bandt dem. Deklarationerne sad på kildefilens <office:document-content>-rod, femogtredive af dem, to tusind tegn væk fra pivoten
W3C Namespaces in XML 1.0 §6.1 definerer den regel, der gør dette til en hård fejl frem for en kosmetisk en: En namespace-deklaration er i scope fra start-tagget på det element, den optræder på, til det elements lukketag, og hvert prefixed navn inde i det scope opløses mod den. Skærer du et subtræ ud af dokumentet, skærer du det ud af scopet. HotXLS skriver sin egen <office:document-content>-rod med elleve deklarationer — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — så calcext: tilfældigvis opløstes, table: tilfældigvis opløstes, og loext: gjorde ikke. En namespace-bevidst parser behandler et unbound prefix som en overtrædelse af velformethed, hvilket betyder, at hele parten er ulæselig, ikke blot én attribut
Hvordan bærer HotXLS xmlns-bindinger fra forfædre over på fragmentet?
HotXLS v2.382.1 erstattede strengsnittet med et pass hen over content.xml gennem sin egen streaming-TXMLReader, der vedligeholder en stak af namespace-bindinger mærket med den dybde, hver er deklareret på, og kopierer bindingerne, der stadig er i kraft, over på fragmentets rodelement i det øjeblik, targetet nås. Readeren kører med PreserveWhitespaceText slået til, så tekstnoder kommer tilbage præcis som skrevet, og de genopbyggede tags bruger TXMLReader.RawName og TXMLReader.Attribute[I].RawName — prefix-stavemåden fra filen — frem for de kanoniske navne, readeren normalt giver part-parserne. Her er kernen i løkken:
// Namespaces: TStringList af 'xmlns:p=uri' med deklareringsdybden i Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // element, tekst, 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); // strip det afsluttende '>' eller '/>' først
...
// Bær de effektive forfædre-bindinger over på fragmentets rod.
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // inderste binding vinder
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // allerede deklareret her? spring 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; // subtræ lukket
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); // forlad scopet
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Tre detaljer i den løkke bærer korrektheden. At gennemløbe stakken fra den inderste binding og ud og huske hvert prefix i Seen implementerer shadowing: Hvis en nærmere forfædre rebinder xmlns:table, vinder den nærmere værdi, præcis som §6.1 siger, det skal. At springe prefixer over, som elementet selv allerede deklarerer, undgår at emitte samme attribut to gange, hvilket ville være en anden velformethedsfejl. Og pop-reglen udløses ved lukketags og ved tomme elementer, fordi <x/> aldrig producerer en EndElement-event — den samme self-closing-fælde, som XLSX extLst-capturen måtte lære. At matche targetet med Reader.Name frem for RawName er en mere stille gevinst: Readeren kanoniserer ODF-table-namespace-URI'en til table-prefixet, så en producent, der staver det t:data-pilot-tables, stadig matcher, mens det emitterede fragment beholder det prefix, producenten brugte
Løkken nægter også at gætte. Slutter parten, mens capturen stadig er åben — en trunkeret eller misdannet content.xml — raiser OdsCaptureDataPilotTablesXml i stedet for at returnere et halvt fragment, fordi et halvt fragment ville blive skrevet tilbage ved save og forvandle et beskadiget input til et beskadiget output med bibliotekets navn på
Hvor lander fragmentet i den gemte content.xml?
HotXLS skriver det fangede fragment ind i <office:spreadsheet> umiddelbart efter de <table:named-expressions>, det genererer, og foran <table:database-ranges>. ODF 1.3 Part 3s indholdsmodel for <office:spreadsheet> foreskriver en fast rækkefølge for de afsluttende børn, så en ordret blok kan ikke bare appenderes, hvor writeren tilfældigvis er; den skal droppes i en bestemt slot. Set fra callerens side er der ingen API og intet at konfigurere; definitionen følger med en almindelig open og 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; // rediger inde i pivotens kilde-range
Book.SaveAsODS('official-pivot-out.ods');
// content.xml i outputtet bærer stadig DataPilot1 med sin
// kilde-range, felter, target-range, buttons og loext:/calcext:-attributter
finally
Book.Free;
end;
end;
Redundansen er bevidst og det værd at vide. Fragmentets rod gentager nu xmlns:table og xmlns:calcext, selv om dokumentroden i det gemte dokument også deklarerer dem; Namespaces in XML tillader at redeklarere et prefix i et indlejret scope, så dubletterne er harmløse. For LibreOffice-eksemplet er det medførte sæt alle femogtredive roddeklarationer, omkring to kilobytes oveni definitionen på 8.357 tegn, fordi capturen ikke analyserer, hvilke prefixer subtræet faktisk bruger. En brugt-prefix-scan ville trimme det, og den kan komme senere; korrekthed først, kompakthed så
En regel for at skære subtræer ud af XML til ordret replay
Den generelle lektie er, at et subtræ først er self-contained, når du har gjort det sådan, og namespace-scope er det første, der knækker, når du glemmer. Tjeklisten, HotXLS nu anvender på enhver "bevar det, vi ikke modellerer"-capture:
- Gennemløb dokumentet med en rigtig reader og spor bindingerne i scope. Strengsøgning med
Poskan slet ikke se scope, og den matcher også forkert ved indlejrede elementer med samme navn, ved en matchende streng inde i en kommentar ellerCDATA-sektion og ved attributværdier, der tilfældigvis indeholder tagteksten - Kopiér de effektive bindinger over på fragmentets rod, inderst først, én gang pr. prefix, og spring det over, som roden allerede deklarerer
- Behold den rå prefix-stavemåde i de emitterede tags; match targetet efter opløst namespace, ikke efter bogstaveligt prefix
- Bevar whitespace-tekstnoder, og husk, at et tomt element lukker sit eget scope uden en end-tag-event
- Validér den gemte part med en parser, der ikke er biblioteket under test. Biblioteket genlæser gerne sit eget output gennem samme efterladne kodevej, der skrev det
Det sidste punkt er det, der faktisk fandt HXLS-003 anden gang. Accepttjekket i v2.382.0 var et regulært udtryk, der talte data-pilot-table-starttags i den gemte content.xml, og et regulært udtryk ser et tag, ikke et dokument — det er blindt for, om prefixerne på det tag er bundne. Den strikse corpus-runner tilføjet i v2.382.1 parser hver XML- og .rels-part af den gemte pakke med en namespace-bevidst parser og sammenligner derefter pivot-træet — tag, sorterede attributter, tekst, børn, rekursivt — med originalen. Den sammenligning er namespace-ekspanderet, så en prefix-genstavning ville stadig bestå, og et unbound prefix kan ikke
Hvor den ordrette garanti slutter
Ordret replay bevarer en definition; det forstår den ikke, og grænserne følger deraf. HotXLS eksponerer ingen API til at læse, redigere eller opdatere en ODS-pivot, så FRawOdsDataPilotTablesXml er et internt felt, og den eneste observerbare adfærd er, at definitionen overlever. Fragmentet re-serialiseres fra reader-events, ikke kopieres som bytes: Attributcitatering og self-closing-former normaliseres, mens tekst og whitespace bevares. Den fangede XML emiteres kun af ODS-content-writeren, så en arbejdsbog åbnet fra .ods og gemt som .xlsx mister pivoten, og en arbejdsbog åbnet fra .xlsx har intet at afspille ind i en .ods-gemning — asymmetrierne i ODS import- og eksportvejene gælder her som alle steder. Og fordi definitionen er opak, kan den ikke følge dine redigeringer: Omdøb Sheet1 eller flyt kildedataene i HotXLS, og den gemte pivot peger stadig på Sheet1.A2:E30, hvilket efterlader konsumenten med at rapportere et ødelagt range, næste gang den opdaterer. Én orden-advarsel hører også hjemme her: HotXLS emitter AutoFilter-ranges som <table:database-ranges> efter pivot-fragmentet, og corpus-eksemplet bærer ingen database-range, så en arbejdsbog med både et filter og en pivot bør køres gennem en ODF-skemavalidator, før du stoler på den relative rækkefølge af de to elementer
Test med din egen producents filer, ikke kun corpus-eksemplet. Namespace-carry-over'en håndterer ethvert prefix, en producent deklarerer på en forfædre, men et dokument, der deklarerer et prefix på pivot-elementet selv, eller som bruger et default-namespace til table-ordforrådet, udsætter de skip- og shadowing-grene, som LibreOffice-eksemplet ikke gør. Begge er implementeret; ingen af dem har et eksempel i korpuset endnu, og den skelnen er præcis den slags, som en changelog-post plejer at udviske
Data pilot-capturen ordret i v2.382.0 og namespace-scope-fixet i v2.382.1 ships i den nuværende HotXLS Delphi Excel Component, hvis productside lister den fulde ODS-, XLSX- og XLS-læse-skrive-dækning til Delphi og C++Builder