Technisch artikel

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

Een ODS-workbook met één draaitabel gaat door een library, er wordt één broncel bewerkt, en het opgeslagen bestand opent met alle celwaarden intact en de draaitabel verdwenen: content.xml bevatte bij het openen één <table:data-pilot-table> en bij het opslaan nul meer. De voor de hand liggende reparatie is de draaitabel-XML als string kopiëren. Dat herstelt het elementaantal, en daarna weigert een namespace-bewuste parser het opgeslagen onderdeel ronduit met een fout over een ongebonden prefix. Beide storingen hebben één wortel: een stuk XML reist alleen veilig als de namespacedeclaraties waarvan hij afhangt met hem meereizen

Waar een ODS-draaitabeldefinitie in content.xml woont

Een OpenDocument-draaitabel, die het formaat een data pilot noemt, is een definitie die in content.xml na de werkbladen wordt bewaard, en een schrijver die dat onderdeel uit zijn eigen objectmodel herbouwt, houdt hem alleen over als het model ergens te plaatsen heeft. ODF 1.3 Part 3 §9.6 beschrijft de container: <table:data-pilot-tables> bevat één <table:data-pilot-table> per draaitabel, elk met een table:source-cell-range, een set table:data-pilot-field-kinderen, een table:target-range-address en een lijst table:buttons. De resultaatcellen die de draaitabel toont, zitten in het werkblad als gewone cellen. Een schrijver die alleen cellen, stijlen, validaties, named expressions en database ranges opnieuw genereert, levert dus een prima geldige spreadsheet op met dezelfde cijfers op het scherm en geen enkele draaitabel erachter

HotXLS Delphi Excel Component leest bij OpenODS, nog vóór er één cel is geparseerd, de hele subboom <table:data-pilot-tables> uit content.xml en probeert de verzameling vervolgens naar zijn pivotmodel te vertalen. Een definitie in de kale vorm die het model representeert, met een lokale bronrange, een rijveld, aggregaatmaten en opgeslagen resultaten die kloppen met de brongegevens, wordt een TXLSPivotTable in de lijst PivotTables van het werkblad, hetzelfde object dat u van een XLSX-draaitabel krijgt en dat u met berekende velden kunt uitbreiden en vanuit Delphi kunt verversen. Alles wat buiten die vorm valt, houdt de verzameling als vastgelegde XML, en SaveAsODS speelt die terug. Eén attribuut in een namespace die de vertaler niet kent, is genoeg: draaitabellen die LibreOffice opslaat kunnen loext:- en calcext:-attributen meedragen, en een loext:-attribuut stuurt de definitie het capture-pad op

De fallback is alles of niets per workbook. Valt één draaitabel in een verzameling van drie buiten de vertaalbare vorm, dan blijven alle drie als vastgelegde XML staan. Ze opsplitsen zou content.xml met twee bronnen van waarheid voor één container achterlaten, de helft herbouwd uit objecten en de helft teruggespeeld uit tekst, en elke tegenstrijdigheid tussen beide helften zou in het bestand van de gebruiker belanden. De verzameling heel houden is de veiligere default, en dat is dezelfde politiek die HotXLS toepast op onbekende extLst-blokken in XLSX: wat het model niet kan dragen, blijft staan zoals het geschreven was

Waarom breekt een uitgesneden draaitabeldefinitie het opgeslagen bestand?

Een draaitabeldefinitie die met een stringzoekactie uit content.xml wordt geknipt, verliest de namespacedeclaraties die zijn prefixes betekenis geven, en Namespaces in XML behandelt een prefix zonder declaratie als een welgevormdheidsfout. De stringaanpak is kort: decodeer het onderdeel naar een WideString, vind <table:data-pilot-tables met Pos, vind daarna de bijbehorende sluittag, en kopieer alles daartussen. De tekst die eruit komt oogt compleet. Ze bevat loext:ignore-selected-page="true" op een paginaveld en calcext:repeat-item-labels="false" op elk niveau, maar de declaraties xmlns:loext en xmlns:calcext die die prefixes binden, staan op de wortel van het document, <office:document-content>, duizenden tekens eerder

Namespaces in XML 1.0 §6.1 houdt een declaratie in scope van de begintag die hem draagt tot de bijbehorende eindtag, en elke naam met een prefix binnen die scope lost ertegen op. Knip een subboom uit het document en u knipt hem uit die scope. Wordt het fragment in een nieuw document geplakt, dan lost elke prefix alleen op als de nieuwe wortel hem toevallig declareert. HotXLS schrijft zijn eigen <office:document-content> met de namespaces die zijn schrijver gebruikt, waaronder office, table, text, style, fo, draw, calcext, tableooo en of. In de uitgesneden kopie lost table: op, lost calcext: op, en loext: niet. Eén ongebonden prefix is genoeg voor een namespace-bewuste parser om het hele onderdeel te weigeren, dus de draaitabel degradeert niet gracieus; ook de bladgegevens eromheen worden onleesbaar

Wat een op Pos gebaseerde capture van een LibreOffice-draaitabel mist: de draaitabelsubboom draagt loext- en calcext-extensieattributen terwijl de xmlns-declaraties die ze binden op de wortel office:document-content staan, dus het uitgesneden fragment laat die prefixes ongebonden en een namespace-bewuste parser weigert 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

Stringzoeken heeft nog andere blinde vlekken waar een capture via een reader nooit mee te maken krijgt. Pos matcht de tagtekst binnen een commentaar, binnen een CDATA-sectie, of binnen een attribuutwaarde die haar aanhaalt. Hij kan geneste elementen met dezelfde naam niet uit elkaar houden. En hij matcht de letterlijke prefix table:, terwijl ODF een producent toestaat de tablenamespace aan elke prefix te binden die hij kiest

Hoe HotXLS de namespacescope op het fragment overdraagt

HotXLS legt de draaitabelsubboom vast met een streaming XML-reader die elke namespacedeclaratie bijhoudt samen met de diepte waarop hij verscheen, en kopieert op het moment dat het doelelement aankomt de nog geldende declaraties op de openingstag van het fragment. De stapel groeit bij elke begintag die xmlns of xmlns:prefix declareert en krimpt zodra het element dat een binding declareerde sluit. Lege elementen hebben dezelfde behandeling nodig: <x xmlns:p="..."/> levert nooit een apart eindtag-event op, dus de pop moet ook op de lege begintag afgaan, anders lekt een binding naar zijn siblings

Bij het doel loopt de capture de stapel van de binnenste binding naar buiten en schrijft elke prefix één keer. Van binnen naar buiten lopen is precies wat shadowing implementeert. Bindt <office:spreadsheet> een prefix die de wortel al declareerde, dan is de dichterbij liggende waarde degene die bij de draaitabel geldt, precies zoals §6.1 vereist, en een set al geschreven prefixes houdt de buitenste waarde tegen. Een prefix die het draaitabelelement op zichzelf declareert, wordt overgeslagen, want xmlns:table twee keer op één tag schrijven is een andere welgevormdheidsfout

Hoe HotXLS de data-pilotsubboom met zijn namespacescope vastlegt: een streaming XML-reader-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 set al geschreven prefixes, slaat prefixes over die het element zelf declareert en haalt bindingen zowel bij eindtags als bij lege elementen van de stapel
Het doel op namespace matchen in plaats van op letterlijke prefix houdt producenten werkend die de table-prefix anders spellen, en een subboom die nooit sluit gooit een fout in plaats van bij het opslaan een half fragment terug te schrijven

Het doel wordt op namespace gematcht, dus een producent die <t:data-pilot-tables> schrijft met t gebonden aan de ODF-table-URI wordt nog steeds herkend, terwijl de vastgelegde tags de spelling uit het bestand houden. Whitespace-tekstknopen komen exact terug zoals ze geschreven zijn. Aanhalingstekens van attributen en de keuze tussen <x/> en <x></x> worden genormaliseerd, want het fragment wordt opnieuw geserialiseerd uit reader-events in plaats van byte voor byte gekopieerd. Eindigt content.xml terwijl de draaitabelsubboom nog open staat, zoals in een afgebroken bestand, dan faalt het openen met een exception in plaats van half een definitie vast te houden die bij de volgende opslag teruggeschreven zou worden

De overgedragen set is met opzet ruim. De capture kopieert elke binding in scope, of de subboom hem nu gebruikt of niet, dus de wortel van het fragment herhaalt xmlns:table en xmlns:calcext ook al declareert de wortel van het opgeslagen document ze ook. Een prefix in een geneste scope opnieuw declareren is legaal onder Namespaces in XML, en een bestand van een producent die tientallen namespaces op zijn wortel declareert, komt een paar kilobyte duurder uit. De lijst snoeien zou betekenen dat de subboom gescand wordt op elke prefix in elementnamen, attribuutnamen en attribuutwaarden die QNames bevatten, en één enkele misser daar haalt de ongebonden-prefix-fout meteen terug. Een paar redundante declaraties kosten veel minder dan dat risico

Een workbook met een draaitabel openen, bewerken en opslaan

Vanuit de aanroeper is er niets aan te zetten: de draaitabeldefinitie lift mee met een gewone OpenODS en SaveAsODS. Werkbladen worden vanaf 1 genummerd, en beide methodes geven 1 terug bij succes:

uses
  System.SysUtils, lxHandleX;

procedure EditPivotSource(const InFile, OutFile: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS(InFile) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InFile]);
    // E2 ligt binnen een bronrange van de draaitabel, Sheet1.A2:E30
    Book.Sheets[1].Cells[2, 5].Value := 1250.0;
    if Book.SaveAsODS(OutFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [OutFile]);
  finally
    Book.Free;
  end;
end;

In de opgeslagen content.xml zit de draaitabelverzameling binnen <office:spreadsheet> direct na de <table:named-expressions> die de schrijver genereert en vóór <table:database-ranges>. Een workbook die zowel een AutoFilter draagt, dat HotXLS als database range wegschrijft, als een draaitabel, zet de twee elementen naast elkaar, en als uw pipeline een strenge ODF-schemavalidator draait, is dat paar de plek om als eerste te bekijken

Waar de vastgelegde draaitabeldefinitie landt in een HotXLS ODS-opslag: table:content-validations en de gegenereerde tabellen komen eerst, daarna table:named-expressions, het woordelijke fragment table:data-pilot-tables en table:database-ranges, zonder API omdat de definitie meelift met OpenODS en SaveAsODS
Het woordelijke blok wordt op één vast punt binnen office:spreadsheet weggeschreven, 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

De bewerking in dat voorbeeld verandert een bronwaarde en laat de resultaatcellen van de draaitabel zoals ze waren. Een vastgelegde definitie wordt teruggespeeld zoals geschreven: HotXLS berekent haar niet opnieuw, en de spreadsheetapplicatie die het bestand opent, herberekent de draaitabel bij zijn volgende verversing. Hetzelfde geldt voor haar adressen. Hernoem Sheet1 of verplaats het bronblok, en de opgeslagen definitie wijst nog steeds naar Sheet1.A2:E30, wat de consumerende applicatie bij het verversen als kapot bereik meldt. Houd bladnamen en het bronblok stabiel in elke workbook waarvan de draaitabellen zo reizen

Welk pad volgde een gegeven draaitabel?

De snelste controle is de lijst PivotTables van het werkblad: een vertaalde definitie staat daar als TXLSPivotTable, een vastgelegde niet. Het conversierapport beantwoordt de andere helft van de vraag, wat er met de draaitabel in een ander uitvoerformaat zou gebeuren. GetConversionReport bekijkt de workbook zonder haar op te slaan of te herberekenen, en de aanroeper is eigenaar van het teruggegeven rapport:

uses
  System.SysUtils, lxHandleX, lxDiagnostics;

function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
  I, Modeled: Integer;
  Report: TXLSConversionReport;
begin
  Modeled := 0;
  for I := 1 to Book.Sheets.Count do          // Sheets is 1-based
    Inc(Modeled, Book.Sheets[I].PivotTables.Count);

  Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
  try
    for I := 0 to Report.Count - 1 do
      if (Report[I].Feature = 'OpenDocumentPivotTables') and
         (Report[I].Fidelity = xlsConversionDropped) then
        Exit('Captured ODF pivot XML: keep this workbook on .ods');
  finally
    Report.Free;
  end;

  if Modeled > 0 then
    Result := Format('%d pivot table(s) in the pivot model', [Modeled])
  else
    Result := 'No pivot definitions';
end;

Het uitvoerpad telt mee, want vastgelegde XML is ODF-vocabulaire en alleen de ODS-schrijver schrijft die. Opgeslagen als .xlsx heeft de vastgelegde verzameling geen SpreadsheetML-tegenhanger, en het rapport zet haar onder OpenDocumentPivotTables met xlsConversionDropped. De binaire .xlsb-schrijver weigert een workbook die haar nog bevat. Een vertaalde draaitabel is een gewoon modelobject en volgt in plaats daarvan de regels van het doelformaat. De verschillen tussen de ODS-import- en exportpaden gelden hier net als overal, en voor bestanden met draaitabellen is de veilige route .ods erin, .ods eruit

Uit de alles-of-niets-fallback volgt nog een regel. Een workbook speelt ofwel een vastgelegde verzameling terug, ofwel schrijft zijn draaitabellen uit het model; hij mengt de twee nooit in één content.xml. Een draaitabel met AddPivotTable toevoegen aan een workbook die al vastgelegde draaitabel-XML bevat, laat de ODS-opslag met een fout stoppen in plaats van gegenereerde XML naast teruggespeelde XML te plakken

Het opgeslagen onderdeel controleren met een tweede parser

Een elementtelling bewijst niets over namespaces. Een reguliere expressie die begintags van data-pilot-table vindt in de opgeslagen content.xml ziet tekst, en de uitgesneden kopie uit de tweede sectie slaagt voor die test. Een namespace-bewuste parser die niets te maken heeft met de library die het bestand schreef, is een veel strengere rechter, en Delphi stuurt er standaard één mee. De functie hieronder is een controle om aan uw eigen testsuite toe te voegen; TZipFile en TXMLDocument komen uit de Delphi-RTL, niet uit HotXLS:

uses
  System.SysUtils, System.Classes, System.Zip,
  Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;

// Eigen testhulp: True als content.xml uit een .ods-pakket
// laadt in een namespace-bewuste DOM-parser
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
  Zip: TZipFile;
  Bytes: TBytes;
  Stream: TBytesStream;
  Doc: IXMLDocument;
begin
  Error := '';
  Zip := TZipFile.Create;
  try
    Zip.Open(OdsFile, zmRead);
    Zip.Read('content.xml', Bytes);
  finally
    Zip.Free;
  end;
  Stream := TBytesStream.Create(Bytes);
  try
    Doc := TXMLDocument.Create(nil);
    try
      Doc.LoadFromStream(Stream);     // een ongebonden prefix faalt hier
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

Draai die controle op elke .ods die uw code opslaat, en ga voor bestanden met draaitabellen een stap verder door na het parsen de draaitabelsubboom van invoer en uitvoer te vergelijken: tag, gesorteerde attributen, tekst en kinderen, recursief, met namen uitgevouwen naar namespace-URI plus lokale naam. Die vergelijking laat een anders gespelde prefix door en vangt een ongebonden, en dat is precies de grens tussen onschadelijk en kapot. Voer haar bestanden van elke producent die uw gebruikers echt draaien. Een document dat de tablenamespace aan een andere prefix bindt, een prefix op het draaitabelelement zelf declareert, of er één in <office:spreadsheet> opnieuw bindt, oefent de regels voor overslaan en shadowing uit op een manier die één LibreOffice-bestand niet doet

De ODS-lezer en -schrijver, het pivotmodel voor zowel XLSX als ODS en het conversierapport van hierboven maken allemaal deel uit van de HotXLS Delphi Excel Component; de productpagina dekt de volledige XLSX-, XLS- en ODS-lees-en-schrijfondersteuning voor Delphi en C++Builder