Tehnički članak

ODS pivot tablica round trip: opseg XML namespacea

HotXLS Delphi Excel Component čuva OpenDocument data pilot tablice kroz ciklus otvaranja i spremanja ODS-a tako da pri otvaranju snimi podstablo <table:data-pilot-tables> iz content.xml doslovno i reproducira ga pri spremanju, od v2.382.0. Od v2.382.1 fragment nosi i svako XML namespace vezanje koje su deklarirali njegovi preci, pa spremljena definicija pivota ostaje dobro oblikovana za svakog potrošača, ne samo za HotXLS

Bug koji je iznudio obje promjene izašao je iz strogog corpus pokretanja. Uzorak official-pivot.ods, koji je napisao LibreOffice 6.1 development build, drži jedan pivot imenom DataPilot1 koji čita Sheet1.A2:E30 i smješta svoj rezultat u Sheet1.G6:J18. Otvorite ga HotXLS-om, spremite nepromijenjenog, prebrojite elemente <table:data-pilot-table> u izlazu: jedan unutra, nula van, i na Win32 i na Win64 jednako. Ništa u testu nije diralo pivot. Prvi krug probi usporedio je samo konstante ćelija i prošao; strukturna tvrdnja je ono što je razotkrilo gubitak, što je podsjetnik da je "vrijednosti se poklapaju" slaba definicija vjernosti round tripa

Zašto ODS pivot tablica nestane nakon spremanja u biblioteci?

ODS pivot tablica nestaje zato što HotXLS nema in-memory model za OpenDocument data pilot tablice, a ODS pisac gradi content.xml u cijelosti iz modela. Pisac sastavlja automatske stilove, po jedan <table:table> za svaki radni list, <table:content-validations>, <table:named-expressions> i <table:database-ranges>, svaki generiran iz objekata koje radna knjiga doista drži. Definicija pivota — ODF 1.3 Part 3 §9.6, kontejner <table:data-pilot-tables> s po jednim <table:data-pilot-table> za svaki pivot, koji nosi svoj table:source-cell-range, svoju djecu table:data-pilot-field, svoj table:target-range-address i table:buttons — nema objekt u kojem bi živjela, pa je regenerirani dio jednostavno izostavlja

Kontrast s XLSX-om je namjeran. HotXLS parsira SpreadsheetML pivot cachee i pivot tablice u pravi model koji možete graditi, proširivati izračunatim poljima i osvježavati iz Delphija, pa oni preživljavaju spremanje jer se prepisuju, a ne kopiraju. ODS pivoti su puno rjeđi zahtjev, a modeliranje ODF data pilot vokabulara samo zbog round tripa bilo bi mnogo koda koji nitko ne uređuje. Pragmatičan odgovor isti je onaj koji HotXLS već primjenjuje na nepoznate extLst blokove u XLSX-u: sačuvaj ono što ne modeliraš, bajt po bajt ako možeš, događaj po događaj ako ne možeš

Što je prvo hvatanje temeljeno na Pos-u pogriješilo?

Hvatanje u v2.382.0 izrezalo je definiciju pivota iz content.xml kao običan string, a izrezani dio nije sadržavao deklaracije namespacea koje su ga činile smislenim. Implementacija je bila kratka kao što zvuči — dekodiraj dio u WideString, nađi početni tag s Pos, nađi završni tag nakon njega, kopiraj raspon u FRawOdsDataPilotTablesXml na radnoj knjizi:

// HotXLS v2.382.0 -- zamijenjeno jedno izdanje kasnije
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);   // cijeli content.xml u memoriji
  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;

Tvrdnja o broju postala je zelena, i popravak je isporučen. Ono što ga je uhvatilo bila je druga, stroža provjera dodana istoga dana: svaki XML dio spremljenog paketa šalje se nezavisnom namespace-aware parseru izvan HotXLS-a, i taj parser odbio je novi content.xml s greškom nevezanog prefiksa. Pivot iz LibreOfficea nosi atribute proširenja proizvođača — loext:ignore-selected-page="true" na page polju, calcext:repeat-item-labels="false" na svakoj razini — a izrezani string sadržavao je te atribute ali ne i deklaracije xmlns:loext i xmlns:calcext koje su ih vezale. Te deklaracije sjedile su na korijenu <office:document-content> izvorne datoteke, njih trideset pet, dvije tisuće znakova daleko od pivota

W3C Namespaces in XML 1.0 §6.1 definira pravilo koje ovo čini tvrdim padom, a ne kozmetičkim: deklaracija namespacea u opsegu je od početnog taga elementa na kojem se pojavljuje do završnog taga tog elementa, i svako ime s prefiksom unutar tog opsega razrješava se prema njoj. Izrežite podstablo iz dokumenta i izrezali ste ga iz opsega. HotXLS piše vlastiti korijen <office:document-content> s jedanaest deklaracija — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — pa se calcext: slučajno razriješio, table: se slučajno razriješio, a loext: nije. Namespace-aware parser tretira nevezani prefiks kao prekršaj dobro oblikovanosti, što znači da je cijeli dio nečitljiv, a ne samo jedan atribut

Što je hvatanje official-pivot.ods temeljeno na Pos-u propustilo u HotXLS-u: podstablo pivota nosi loext i calcext atribute proširenja, dok xmlns deklaracije koje ih vežu sjede na korijenu office:document-content trideset pet vezanja daleko, pa je izrezani fragment ostavio svaki prefiks koji koristi nevezanim i namespace-aware parser odbio je cijeli content.xml
Deklaracija namespacea u opsegu je od svog početnog do završnog taga, a izrezivanje podstabla iz dokumenta izrezuje ga iz tog opsega, što jedan atribut pretvara u nečitljiv dio

Kako HotXLS prenosi xmlns vezanja predaka na fragment?

HotXLS v2.382.1 zamijenio je izrezivanje stringa prolazom kroz content.xml vlastitim streaming TXMLReader-om, održavajući stack namespace vezanja označenih dubinom na kojoj je svako deklarirano, i kopirajući vezanja koja su još na snazi na korijenski element fragmenta u trenutku kada se stigne do cilja. Reader radi s uključenim PreserveWhitespaceText da tekstualni čvorovi dođu točno onako kako su napisani, a ponovno izgrađeni tagovi koriste TXMLReader.RawName i TXMLReader.Attribute[I].RawName — prefiks onako kako je napisan u datoteci — umjesto kanonskih imena koje reader inače predaje parserima dijelova. Ovo je srž petlje:

Kako HotXLS v2.382.1 snima podstablo data pilota zajedno s njegovim namespace opsegom: streaming prolaz TXMLReader-om drži stack xmlns vezanja označenih dubinom deklariranja, obilazi ga od najnutarnijeg pri cilju table:data-pilot-tables, poštuje zasjenjenje kroz Seen skup, preskače prefikse koje element sam deklarira i uklanja vezanja na završnim tagovima i na praznim elementima jednako
Uparivanje cilja po kanonskom imenu readera čuva rad proizvođačima koji pišu table prefiks drugačije, a podstablo koje se nikad ne zatvori diže iznimku umjesto da pri spremanju zapiše pola fragmenta
// Namespaces: TStringList od 'xmlns:p=uri' s dubinom deklariranja u Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // element, tekst, 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);   // prvo skini završni '>' ili '/>'
      ...
      // Prenesi djelotvorna vezanja predaka na korijen fragmenta.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // najnutarnije vezanje pobjeđuje
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // već deklarirano ovdje? 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;                           // podstablo zatvoreno
  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);                   // napusti opseg
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Tri detalja u toj petlji nose ispravnost. Prolaženje stacka od najnutarnijeg vezanja prema van i pamćenje svakog prefiksa u Seen implementira zasjenjenje: ako bliži predak ponovno veže xmlns:table, bliža vrijednost pobjeđuje, točno kako §6.1 kaže da mora. Preskakanje prefiksa koje element već sam deklarira izbjegava emitiranje istog atributa dvaput, što bi bila druga vrsta prekršaja dobro oblikovanosti. A pravilo uklanjanja aktivira se na završnim tagovima i na praznim elementima, jer <x/> nikad ne proizvede EndElement događaj — ista zamka samozatvarajućeg elementa koju je hvatanje extLst-a u XLSX-u moralo naučiti. Uparivanje cilja po Reader.Name umjesto RawName tiša je pobjeda: reader kanonizira URI ODF table namespacea u prefiks table, pa proizvođač koji ga piše t:data-pilot-tables i dalje odgovara, dok emitirani fragment zadržava prefiks koji je proizvođač koristio

Petlja također odbija pogađati. Ako dio završi dok je hvatanje još otvoreno — odsječen ili neispravno oblikovan content.xml — OdsCaptureDataPilotTablesXml diže iznimku umjesto da vrati pola fragmenta, jer bi pola fragmenta bilo zapisano natrag pri spremanju i pretvorilo bi oštećen ulaz u oštećen izlaz s imenom biblioteke na sebi

Gdje fragment slijeće u spremljeni content.xml?

HotXLS piše snimljeni fragment u <office:spreadsheet> odmah nakon <table:named-expressions> koje generira i prije <table:database-ranges>. Content model <office:spreadsheet> iz ODF 1.3 Part 3 propisuje fiksni slijed za tu završnu djecu, pa doslovan blok ne može jednostavno biti dodan gdje god se pisac zatekne; mora se smjestiti u određeni utor. Sa strane pozivatelja nema API-ja niti bilo čega za konfiguriranje; definicija putuje uz obično otvaranje i spremanje:

Gdje snimljena definicija pivota slijeće pri HotXLS ODS spremanju: djeca office:spreadsheet slijede fiksni ODF slijed od generiranih table elemenata preko table:content-validations i table:named-expressions, doslovni fragment table:data-pilot-tables smješta se prije table:database-ranges, a API ne postoji jer definicija putuje uz OpenODS i SaveAsODS
Doslovan blok ne može biti dodan gdje god se pisac zatekne, a kopije vezanja predaka koje nosi bezopasne su jer Namespaces in XML dopušta ponovno deklariranje prefiksa u ugniježđenom opsegu
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;   // uređivanje unutar izvornog raspona pivota
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml u izlazu još nosi DataPilot1 sa svojim
    // izvornim rasponom, poljima, ciljnim rasponom, gumbima i loext:/calcext: atributima
  finally
    Book.Free;
  end;
end;

Redundantnost je namjerna i vrijedi je znati. Korijen fragmenta sada ponavlja xmlns:table i xmlns:calcext iako ih korijen spremljenog dokumenta također deklarira; Namespaces in XML dopušta ponovno deklariranje prefiksa u ugniježđenom opsegu, pa su duplikati bezopasni. Za LibreOffice uzorak preneseni skup su sve trideset pet root deklaracija, oko dva kilobajta povrh definicije od 8,357 znakova, jer hvatanje ne analizira koje prefikse podstablo doista koristi. Skeniranje iskorištenih prefiksa to bi smanjilo, i možda dođe kasnije; prvo ispravnost, pa kompaktnost

Pravilo za izrezivanje podstabala iz XML-a za doslovnu reprodukciju

Opća lekcija je da je podstablo samostalno tek kad ga takvim napravite, a namespace opseg je prva stvar koja puca kada to zaboravite. Kontrolna lista koju HotXLS sada primjenjuje na svako hvatanje tipa "sačuvaj ono što ne modeliramo":

  • Prođite dokument pravim readerom i pratite vezanja u opsegu. Pretraga stringa s Pos uopće ne vidi opseg, a također promašuje na ugniježđenim elementima s istim imenom, na podudarnom stringu unutar komentara ili CDATA sekcije, i na vrijednostima atributa koje slučajno sadrže tekst taga
  • Kopirajte djelotvorna vezanja na korijen fragmenta, od najnutarnijeg prema van, jednom po prefiksu, preskačući ono što korijen već deklarira
  • Zadržite prefiks onako kako je napisan u emitiranim tagovima; cilj uparujte po razriješenom namespaceu, a ne po doslovnom prefiksu
  • Sačuvajte whitespace tekstualne čvorove i zapamtite da prazan element zatvara vlastiti opseg bez događaja završnog taga
  • Validirajte spremljeni dio parserom koji nije biblioteka koja je testirana. Biblioteka će s veseljem ponovno pročitati vlastiti izlaz kroz istu blagu kodnu putanju koja ga je i napisala

Zadnja je točka ona koja je doista našla HXLS-003 drugi put. Prihvatna provjera u v2.382.0 bila je regularni izraz koji broji početne tagove data-pilot-table u spremljenom content.xml, a regularni izraz vidi tag, ne dokument — slijep je na to jesu li prefiksi na tom tagu vezani. Strogi corpus runner dodan u v2.382.1 parsira svaki XML i .rels dio spremljenog paketa namespace-aware parserom, a zatim uspoređuje stablo pivota — tag, sortirane atribute, tekst, djecu, rekurzivno — s originalom. Ta usporedba je namespace-expanded, pa bi preimenovanje prefiksa i dalje prošlo, a nevezani prefiks ne može

Gdje prestaje garancija doslovnosti

Dosljedna reprodukcija čuva definiciju; ona je ne razumije, i granice iz toga slijede. HotXLS ne izlaže nikakav API za čitanje, uređivanje ili osvježavanje ODS pivota, pa je FRawOdsDataPilotTablesXml interno polje i jedino vidljivo ponašanje je da definicija preživi. Fragment se ponovno serijalizira iz reader događaja, a ne kopira kao bajtovi: citiranje atributa i samozatvarajući oblici normaliziraju se, dok se tekst i whitespace zadržavaju. Snimljeni XML emitira samo ODS pisac sadržaja, pa radna knjiga otvorena iz .ods i spremljena kao .xlsx gubi pivot, a radna knjiga otvorena iz .xlsx nema što reproducirati u .ods spremanje — asimetrije ODS putanja uvoza i izvoza vrijede ovdje kao i svugdje. A budući da je definicija neprozirna, ne može pratiti vaše izmjene: preimenujte Sheet1 ili premjestite izvorne podatke u HotXLS-u i spremljeni pivot i dalje pokazuje na Sheet1.A2:E30, ostavljajući potrošaču da prijavi pokvaren raspon kad se sljedeći put osvježi. Jedna ograda o redoslijedu spada ovdje također: HotXLS emitira AutoFilter raspone kao <table:database-ranges> nakon fragmenta pivota, a corpus uzorak ne nosi nijedan database range, pa radnu knjigu s filtrom i pivotom treba provesti kroz ODF validator sheme prije nego što se pouzdate u relativni redoslijed tih dvaju elemenata

Testirajte s datotekama vlastitog proizvođača, ne samo s corpus uzorkom. Prijenos namespacea pokriva svaki prefiks koji proizvođač deklarira na pretku, ali dokument koji deklarira prefiks na samom elementu pivota, ili koji koristi zadani namespace za table vokabular, izvježbava grane preskakanja i zasjenjenja koje LibreOffice uzorak ne dotiče. Obje su implementirane; nijedna još nema uzorak u corpusu, a ta razlika je upravo vrsta stvari koju zapis u changelogu obično zamuti

Doslovno hvatanje data pilota u v2.382.0 i popravak namespace opsega u v2.382.1 isporučuju se u trenutnoj HotXLS Delphi Excel Component, čija stranica proizvoda navodi punu pokrivenost čitanja i pisanja ODS-a, XLSX-a i XLS-a za Delphi i C++Builder