Technisch artikel

ODS-draaitabellen rondzetten in Delphi: XML-namespace-scope

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

Wat de op Pos gebaseerde capture van official-pivot.ods in HotXLS miste: de draaitabelsubboom draagt loext- en calcext-extensieattributen terwijl de xmlns-declaraties die ze binden op de wortel office:document-content staan, vijfendertig bindingen verder, dus het uitgesneden fragment liet elke prefix die het gebruikte ongebonden en weigerde een namespace-bewuste parser de hele content.xml
Een namespacedeclaratie is in scope van haar begintag tot haar eindtag, en een subboom uit het document knippen knipt hem uit die scope, wat van één attribuut een onleesbaar onderdeel maakt

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:

Hoe HotXLS v2.382.1 de data-pilotsubboom met zijn namespacescope vastlegt: een streaming TXMLReader-ronde houdt een stapel xmlns-bindingen bij met de declarerende diepte, loopt die bij het doel table:data-pilot-tables van binnen naar buiten af, respecteert shadowing via een Seen-set, slaat prefixes over die het element zelf declareert en haalt bindingen van de stapel bij eindtags en bij lege elementen
Het doel matchen op de canonieke readernaam houdt producenten die de table-prefix anders spellen werkend, en een subboom die nooit sluit gooit een fout in plaats van een half fragment bij het opslaan terug te schrijven
// 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:

Waar de vastgelegde draaitabeldefinitie landt in een HotXLS ODS-opslag: de kinderen van office:spreadsheet volgen de vaste ODF-volgorde van de gegenereerde table-elementen via table:content-validations en table:named-expressions, het woordelijke fragment table:data-pilot-tables valt vóór table:database-ranges in de sleuf, en er is geen API omdat de definitie meelift met OpenODS en SaveAsODS
Een woordelijk blok kan niet worden toegevoegd waar de schrijver toevallig staat, en de kopieën van de voorouderbindingen die het draagt zijn onschadelijk omdat Namespaces in XML een prefix in een geneste scope opnieuw mag declareren
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 Pos in een string ziet scope helemaal niet, en het matcht bovendien verkeerd op geneste elementen met dezelfde naam, op een overeenkomende string binnen een commentaar of CDATA-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