Tehnični članak

Round-trip pivot tabele ODS: imenski prostori v HotXLS

HotXLS Delphi Excel Component ohranja pivot tabele OpenDocument skozi cikel odpiranja in shranjevanja ODS tako, da ob odpiranju dobesedno zajame poddrevo <table:data-pilot-tables> v content.xml in ga ob shranjevanju predvaja — od različice 2.382.0. Od različice 2.382.1 fragment nosi tudi vsako vezavo imenskega prostora XML, ki so jo razglasili njegovi predniki, zato shranjena definicija pivot ostane dobro oblikovana za vsakega porabnika in ne le za HotXLS

Hrošč, ki je vsilil obe spremembi, je prišel iz strogega zagona korpusa. Vzorec official-pivot.ods, ki ga je zapisala razvojna različica LibreOffice 6.1, vsebuje en pivot z imenom DataPilot1, ki bere Sheet1.A2:E30 in svoj rezultat odloži v Sheet1.G6:J18. Odprite ga s HotXLS, shranite nespremenjenega, preštejte elemente <table:data-pilot-table> v izhodu: en noter, nič ven, enako na Win32 in Win64. Noben preskus se pivota ni dotaknil. Prvi krog sond je primerjal le konstante celic in šel skozi; strukturna trditev je tista, ki je izgubo razkrila, kar je opomnik, da je "vrednosti se ujemajo" šibka definicija zvestobe round-tripa

Zakaj pivot tabela ODS po shranjevanju iz knjižnice izgine?

Pivot tabela ODS izgine, ker HotXLS za pivot tabele OpenDocument nima modela v pomnilniku, pisec ODS pa content.xml zgradi v celoti iz modela. Pisec sestavi samodejne stile, eno <table:table> na delovni list, <table:content-validations>, <table:named-expressions> in <table:database-ranges> — vsakega iz objektov, ki jih delovni zvezek res vsebuje. Definicija pivot — ODF 1.3 3. del §9.6, vsebnik <table:data-pilot-tables> z enim <table:data-pilot-table> na pivot, ki nosi svoj table:source-cell-range, svoje otroke table:data-pilot-field, svoj table:target-range-address in table:buttons — nima objekta, v katerem bi živela, zato jo znova zgrajeni del preprosto izpusti

Nasprotje z XLSX je namerno. HotXLS razčleni predpomnilnike in pivot tabele SpreadsheetML v pravi model, ki ga lahko zgradite, razširite z izračunanimi polji in osvežite iz Delphija, zato ti shranjevanje preživijo, ker se prepišejo in ne kopirajo. Pivoti ODS so veliko redkejša zahteva in modeliranje besednjaka data pilot ODF samo zaradi round-tripa bi bilo veliko kode, ki je nihče ne ureja. Pragmatični odgovor je isti, kot ga HotXLS že uporablja za neznane bloke extLst v XLSX: kar ne modelirate, obdržite — bajt za bajtom, če gre, dogodek za dogodkom, če ne gre

Kaj je prvi zajem na osnovi Pos naredil narobe?

Zajem v različici 2.382.0 je definicijo pivot izrezal iz content.xml kot navaden niz, izrezku pa so manjkale razglasitve imenskih prostorov, ki so mu dajale pomen. Izvedba je bila tako kratka, kot se sliši — dekodiraj del v WideString, najdi začetno oznako s Pos, za njo najdi končno oznako, kopiraj obseg v FRawOdsDataPilotTablesXml na delovnem zvezku:

// HotXLS v2.382.0 -- nadomeščeno eno različico pozneje
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);   // celoten content.xml v pomnilniku
  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;

Trditev o štetju je postala zelena in popravek je izšel. Ujel ga je drugi, strožji pregled, dodan isti dan: vsak del XML v shranjenem paketu se pošlje neodvisnemu razčlenjevalniku, ki pozna imenske prostore in deluje zunaj HotXLS, ta razčlenjevalnik pa je novi content.xml zavrnil z napako nevezane predpone. Pivot iz LibreOffice nosi atribute razširitev proizvajalca — loext:ignore-selected-page="true" na polju strani, calcext:repeat-item-labels="false" na vsaki ravni — in izrezani niz je vseboval te atribute, ne pa razglasitev xmlns:loext in xmlns:calcext, ki sta jih vezali. Ti razglasitvi sta sedeli na korenu <office:document-content> izvorne datoteke, petintrideset jih je bilo, dva tisoč znakov stran od pivota

W3C Namespaces in XML 1.0 §6.1 določa pravilo, ki iz tega naredi trdo napako in ne kozmetične: razglasitev imenskega prostora je v obsegu od začetne oznake elementa, na katerem se pojavi, do končne oznake tega elementa, vsako ime s predpono znotraj tega obsega pa se razreši proti njej. Izrežite poddrevo iz dokumenta in izrezali ste ga tudi iz obsega. HotXLS zapiše svoj lasten koren <office:document-content> z enajstimi razglasitvami — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — zato se je calcext: slučajno razrešil, table: se je slučajno razrešil, loext: pa ne. Razčlenjevalnik, ki pozna imenske prostore, nevezano predpono obravnava kot kršitev dobre oblikovanosti, kar pomeni, da je cel del neberljiv in ne le en atribut

Kaj je zajem vzorca official-pivot.ods na osnovi Pos v HotXLS spregledal: poddrevo pivot nosi atribute razširitev loext in calcext, razglasitve xmlns, ki ju vežejo, pa sedijo na korenu office:document-content petintrideset vezav stran, zato je izrezani fragment pustil vsako predpono, ki jo uporablja, nevezano in je razčlenjevalnik, ki pozna imenske prostore, zavrnil celoten content.xml
Razglasitev imenskega prostora je v obsegu od svoje začetne do svoje končne oznake, izrez poddrevesa iz dokumenta pa ga izreže iz tega obsega, kar iz enega atributa naredi neberljiv del

Kako HotXLS prenese vezave xmlns prednikov na fragment?

HotXLS 2.382.1 je nizovni izrez nadomestil s prehodom čez content.xml skozi svoj pretočni TXMLReader, pri čemer vzdržuje sklad vezav imenskih prostorov, označenih z globino, na kateri je bila vsaka razglašena, in v trenutku, ko doseže cilj, kopira vezave, ki so še v veljavi, na korenski element fragmenta. Bralnik teče z omogočenim PreserveWhitespaceText, da se besedilna vozlišča vrnejo natanko tako, kot so bila zapisana, znova zgrajene oznake pa uporabljajo TXMLReader.RawName in TXMLReader.Attribute[I].RawName — črkovanje predpone iz datoteke — in ne kanoničnih imen, ki jih bralnik običajno izroči razčlenjevalnikom delov. Tu je jedro zanke:

Kako HotXLS 2.382.1 zajame poddrevo data pilot skupaj z njegovim obsegom imenskih prostorov: pretočni prehod TXMLReader vzdržuje sklad vezav xmlns, označenih z globino razglasitve, pri cilju table:data-pilot-tables ga prehodi od najgloblje navzven, spoštuje senčenje prek množice Seen, preskoči predpone, ki jih element razglasi sam, in odlaga vezave ob končnih oznakah in ob praznih elementih enako
Ujemanje cilja po kanoničnem imenu bralnika ohrani delujoče tiste proizvajalce, ki predpono table zapišejo drugače, poddrevo, ki se nikoli ne zapre, pa sproži izjemo, namesto da bi ob shranjevanju zapisalo polovičen fragment
// Namespaces: TStringList z 'xmlns:p=uri', globina razglasitve v Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // element, besedilo, CDATA, komentar
  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);   // najprej odstrani končni '>' ali '/>'
      ...
      // Prenesi veljavne vezave prednikov na koren fragmenta.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // najgloblja vezava zmaga
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // že razglašeno tukaj? preskoči
          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;                           // poddrevo zaprto
  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);                   // zapusti obseg
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Tri podrobnosti v tej zanki nosijo pravilnost. Hoja po skladu od najgloblje vezave navzven z zapomnitvijo vsake predpone v Seen izvede senčenje: če bližnji prednik znova razglasi xmlns:table, obvelja bližnja vrednost, natanko tako, kot zahteva §6.1. Preskok predpon, ki jih element razglasi sam, se izogne dvojnemu oddajanju istega atributa, kar bi bila druga kršitev dobre oblikovanosti. Pravilo odlaganja pa se sproži ob končnih oznakah in ob praznih elementih, ker <x/> nikoli ne proizvede dogodka EndElement — ista past samozapirajočih elementov, ki se je je moral naučiti zajem extLst v XLSX. Ujemanje cilja po Reader.Name in ne po RawName je tišja pridobitev: bralnik kanonizira URI imenskega prostora tabele ODF v predpono table, zato se proizvajalec, ki jo zapiše kot t:data-pilot-tables, še vedno ujame, oddani fragment pa obdrži predpono, kakršno je uporabil proizvajalec

Zanka tudi noče ugibati. Če se del konča, medtem ko je zajem še odprt — odrezan ali pokvarjen content.xml — OdsCaptureDataPilotTablesXml sproži izjemo, namesto da bi vrnil polovičen fragment, ker bi se polovičen fragment ob shranjevanju zapisal nazaj in poškodovan vnos spremenil v poškodovan izhod s podpisom knjižnice

Kam fragment pristane v shranjenem content.xml?

HotXLS shranjeni fragment zapiše v <office:spreadsheet> takoj za <table:named-expressions>, ki jih ustvari, in pred <table:database-ranges>. Vsebinski model <office:spreadsheet> iz ODF 1.3 3. dela predpisuje fiksno zaporedje teh zadnjih otrok, zato dobesednega bloka ni mogoče preprosto pripeti, kjer se pisec trenutno znajde; vstaviti ga je treba na točno določeno mesto. S strani klicatelja ni nobenega API-ja in ničesar za nastaviti; definicija se pelje skupaj z običajnim odpiranjem in shranjevanjem:

Kam v shranjevanju ODS iz HotXLS pristane zajeta definicija pivot: otroci office:spreadsheet sledijo fiksnemu zaporedju ODF od generiranih elementov tabele prek table:content-validations in table:named-expressions, dobesedni fragment table:data-pilot-tables se vstavi pred table:database-ranges, API-ja pa ni, ker se definicija pelje skupaj z OpenODS in SaveAsODS
Dobesednega bloka ni mogoče pripeti, kjer se pisec trenutno znajde, kopije vezav prednikov, ki jih nosi, pa so neškodljive, ker Namespaces in XML dovoljuje ponovno razglasitev predpone v gnezdenem obsegu
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;   // urejanje znotraj izvornega obsega pivota
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml v izhodu še vedno nosi DataPilot1 s svojim
    // izvornim obsegom, polji, ciljnim obsegom, gumbi in atributi loext:/calcext:
  finally
    Book.Free;
  end;
end;

Podvajanje je namerno in vredno vedenja. Koren fragmenta zdaj ponovi xmlns:table in xmlns:calcext, čeprav ju razglasi tudi koren shranjenega dokumenta; Namespaces in XML dovoljuje ponovno razglasitev predpone v gnezdenem obsegu, zato so dvojniki neškodljivi. Pri vzorcu LibreOffice je preneseni nabor vseh petintrideset korenskih razglasitev, približno dva kilobajta povrhu definicije z 8357 znaki, ker zajem ne analizira, katere predpone poddrevo res uporablja. Pregled uporabljenih predpon bi to skrčil in morda pride pozneje; najprej pravilnost, šele nato strnjenost

Pravilo za rezanje poddreves iz XML za dobesedno predvajanje

Splošna lekcija je, da je poddrevo samozadostno šele, ko ga takega naredite, obseg imenskih prostorov pa je prva stvar, ki se zlomi, ko to pozabite. Kontrolni seznam, ki ga HotXLS zdaj uporablja za vsak zajem v smislu "obdrži, česar ne modeliramo":

  • Dokument prehodite s pravim bralnikom in sledite vezavam v obsegu. Iskanje po nizu s Pos obsega sploh ne vidi, zgreši pa tudi pri gnezdenih elementih z istim imenom, pri ujemajočem se nizu v komentarju ali odseku CDATA in pri vrednostih atributov, ki slučajno vsebujejo besedilo oznake
  • Kopirajte veljavne vezave na koren fragmenta, od najgloblje navzven, enkrat na predpono, s preskokom tistega, kar koren že razglasi
  • V oddanih oznakah obdržite izvirno črkovanje predpone; cilj ujemajte po razrešenem imenskem prostoru in ne po dobesedni predponi
  • Ohranite besedilna vozlišča z belino in ne pozabite, da prazen element zapre svoj obseg brez dogodka končne oznake
  • Shranjeni del preverite z razčlenjevalnikom, ki ni knjižnica, ki jo preskušate. Knjižnica bo svoj izhod z veseljem znova prebrala skozi isto popustljivo pot kode, ki ga je zapisala

Zadnja točka je tista, ki je HXLS-003 dejansko našla drugič. Sprejemni pregled v različici 2.382.0 je bil regularni izraz, ki je štel začetne oznake data-pilot-table v shranjenem content.xml, regularni izraz pa vidi oznako in ne dokumenta — slep je za to, ali so predpone na tej oznaki vezane. Strogi izvajalnik korpusa, dodan v različici 2.382.1, razčleni vsak del XML in .rels v shranjenem paketu s preizkuševalnikom, ki pozna imenske prostore, nato pa drevo pivota — oznaka, urejeni atributi, besedilo, otroci, rekurzivno — primerja z izvirnikom. Ta primerjava je razširjena po imenskih prostorih, zato bi drugačno črkovanje predpone še vedno šlo skozi, nevezana predpona pa nikakor

Kje se jamstvo dobesednosti konča

Dobesedno predvajanje definicijo ohrani; ne razume je, in meje izhajajo iz tega. HotXLS ne izpostavlja nobenega API-ja za branje, urejanje ali osveževanje pivota ODS, zato je FRawOdsDataPilotTablesXml notranje polje in edino opazno vedenje je, da definicija preživi. Fragment se znova serializira iz dogodkov bralnika in se ne kopira kot bajti: črkovanje atributov in samozapirajoče oblike se normalizirajo, besedilo in belina pa se ohranita. Zajeti XML oddaja samo pisec vsebine ODS, zato delovni zvezek, odprt iz .ods in shranjen kot .xlsx, pivot izgubi, delovni zvezek, odprt iz .xlsx, pa nima česa predvajati v shranjevanje .ods — nesimetrije poti uvoza in izvoza ODS veljajo tu kot povsod. In ker je definicija neprozorna, ne more slediti vašim urejanjem: preimenujte Sheet1 ali premaknite izvorne podatke v HotXLS in shranjeni pivot bo še vedno kazal na Sheet1.A2:E30, porabnik pa bo ob naslednji osvežitvi prijavil pokvarjen obseg. Sem sodi tudi eno opozorilo glede vrstnega reda: HotXLS oddaja obsege AutoFilter kot <table:database-ranges> za fragmentom pivota, vzorec korpusa pa ne nosi nobenega obsega podatkovne baze, zato delovni zvezek s filtrom in pivotom poženite skozi preverjevalnik sheme ODF, preden se zanesete na relativni vrstni red teh dveh elementov

Preskušajte z datotekami svojega proizvajalca in ne le z vzorcem korpusa. Prenos imenskih prostorov obdela vsako predpono, ki jo proizvajalec razglasi na predniku, dokument, ki predpono razglasi na samem elementu pivota ali uporabi privzeti imenski prostor za besednjak tabele, pa preizkusi veji preskoka in senčenja, ki jih vzorec LibreOffice ne. Obe sta izvedeni; nobena še nima vzorca v korpusu, in prav ta razlika je tisto, kar vnos v changelogu rad zabriše

Dobesedni zajem pivot tabel iz različice 2.382.0 in popravek obsega imenskih prostorov iz različice 2.382.1 izhajata v trenutni komponenti HotXLS Delphi Excel Component, katere stran izdelka navaja celotno pokritost branja in pisanja ODS, XLSX in XLS za Delphi in C++Builder