Teknisk artikel

ODS pivot table round-trip i Delphi: XML namespace scope

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

Hvad den Pos-baserede capture af official-pivot.ods manglede i HotXLS: Pivot-subtræet bærer loext- og calcext-udvidelsesattributter, mens xmlns-deklarationerne, der binder dem, sidder på office:document-content-roden femogtredive bindinger væk, så snitfragmentet efterlod hvert prefix, det brugte, unbound, og en namespace-bevidst parser afviste hele content.xml
En namespace-deklaration er i scope fra sit start-tag til sit lukketag, og at skære et subtræ ud af dokumentet skærer det ud af det scope, hvilket forvandler én attribut til en ulæselig part

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:

Sådan fanger HotXLS v2.382.1 data pilot-subtræet med dets namespace-scope: Et streaming-TXMLReader-pass holder en stak af xmlns-bindinger mærket med deklareringsdybde, gennemløber den inderst først ved table:data-pilot-tables-targetet, respekterer shadowing gennem et Seen-sæt, springer prefixer over, som elementet selv deklarerer, og popper bindinger ved både lukketags og tomme elementer
At matche targetet med det kanoniske reader-navn holder producenter, der genstaver table-prefixet, kørende, og et subtræ, der aldrig lukker, raiser i stedet for at skrive et halvt fragment tilbage ved save
// 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:

Hvor den fangede pivot-definition lander i en HotXLS ODS-gemning: office:spreadsheet-børn følger den faste ODF-rækkefølge fra de genererede table-elementer gennem table:content-validations og table:named-expressions, det ordrette table:data-pilot-tables-fragment lander foran table:database-ranges, og der findes ingen API, fordi definitionen følger med OpenODS og SaveAsODS
En ordret blok kan ikke appenderes, hvor writeren tilfældigvis er, og kopierne af forfædre-bindingerne, den bærer, er harmløse, fordi Namespaces in XML tillader at redeklarere et prefix i et indlejret scope
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 Pos kan slet ikke se scope, og den matcher også forkert ved indlejrede elementer med samme navn, ved en matchende streng inde i en kommentar eller CDATA-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