Tehnički članak

ODS pivot tabela round trip u Delphiju: XML namespace scope

HotXLS Delphi Excel Component čuva OpenDocument data pilot tabele kroz ciklus otvaranja i čuvanja ODS-a tako što pri otvaranju snima podstablo <table:data-pilot-tables> iz content.xml verbatim i reprodukuje ga pri čuvanju, i to od v2.382.0. Od v2.382.1 fragment nosi i svako XML namespace vezivanje koje su deklarisali njegovi preci, pa sačuvana definicija pivota ostaje dobro formirana za svakog potrošača, ne samo za HotXLS

Bug koji je iznudio obe izmene izašao je iz strogog corpus pokretanja. Uzorak official-pivot.ods, koji je napisala razvojna verzija LibreOffice-a 6.1, drži jedan pivot po imenu DataPilot1 koji čita Sheet1.A2:E30 i smešta rezultat u Sheet1.G6:J18. Otvorite ga u HotXLS-u, sačuvajte nepromenjenog, prebrojte elemente <table:data-pilot-table> u izlazu: jedan ulazi, nula izlazi, isto na Win32 i Win64. Ništa u testu nije diralo pivot. Prvi krug proba poredio je samo konstante ćelija i prošao; strukturna tvrdnja je ono što je razotkrilo gubitak, što je podsetnik da je „vrednosti se poklapaju" slaba definicija vernosti round trip-a

Zašto ODS pivot tabela nestane posle čuvanja iz biblioteke?

ODS pivot tabela nestane jer HotXLS nema model u memoriji za OpenDocument data pilot tabele, a ODS pisac gradi content.xml u potpunosti 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 generisan iz objekata koje radna sveska zaista drži. Definicija pivota — ODF 1.3 Part 3 §9.6, kontejner <table:data-pilot-tables> sa po jednim <table:data-pilot-table> za svaki pivot, koji nosi svoj table:source-cell-range, svoju decu table:data-pilot-field, svoj table:target-range-address i table:buttons — nema objekat u kojem bi živela, pa je regenerisani deo jednostavno izostavlja

Kontrast sa XLSX-om je nameran. HotXLS parsira SpreadsheetML pivot caches i pivot tabele u pravi model koji možete da gradite, proširujete izračunatim poljima i osvežavate iz Delphi-ja, pa oni preživljavaju čuvanje jer se prepisuju, a ne kopiraju. ODS pivoti su mnogo ređi zahtev, a modelovanje ODF data pilot vokabulara samo zbog round trip-a bilo bi mnogo koda koji niko ne menja. Pragmatičan odgovor je isti onaj koji HotXLS već primenjuje na nepoznate extLst blokove u XLSX-u: sačuvaj ono što ne modeluješ, bajt za bajt ako možeš, događaj po događaj ako ne možeš

Šta je prvo hvatanje preko Pos-a pogrešilo?

Hvatanje u v2.382.0 iseklo je definiciju pivota iz content.xml kao običan string, a isečku su nedostajale deklaracije namespace-a koje su ga činile smislenim. Implementacija je bila kratka koliko i zvuči — dekodiraj deo u WideString, nađi otvarajući tag preko Pos, nađi zatvarajući tag posle njega, kopiraj raspon u FRawOdsDataPilotTablesXml na radnoj svesci:

// HotXLS v2.382.0 -- zamenjeno 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);   // ceo 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 ispravka je isporučena. Ono što ju je uhvatilo bila je druga, stroža provera dodata istog dana: svaki XML deo sačuvanog paketa prosleđuje se nezavisnom namespace-aware parseru izvan HotXLS-a, i taj parser odbio je novi content.xml greškom o nevezanom prefiksu. Pivot iz LibreOffice-a nosi producentske ekstenzijske atribute — loext:ignore-selected-page="true" na polju strane, calcext:repeat-item-labels="false" na svakom nivou — a isečeni string sadržao je te atribute ali ne i deklaracije xmlns:loext i xmlns:calcext koje su ih vezivale. Te deklaracije stajale su na korenu <office:document-content> izvornog fajla, trideset pet njih, dve hiljade znakova dalje od pivota

W3C Namespaces in XML 1.0 §6.1 definiše pravilo koje ovo čini tvrdim otkazom a ne kozmetičkim: deklaracija namespace-a je u opsegu od početnog taga elementa na kojem se pojavljuje do završnog taga tog elementa, i svako ime sa prefiksom unutar tog opsega razrešava se prema njoj. Isecite podstablo iz dokumenta i isekli ste ga iz opsega. HotXLS piše sopstveni koren <office:document-content> sa jedanaest deklaracija — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — pa se calcext: slučajno razrešio, table: se slučajno razrešio, a loext: nije. Namespace-aware parser tretira nevezani prefiks kao kršenje dobro formiranosti, što znači da je ceo deo nečitljiv, a ne samo jedan atribut

Šta je hvatanje preko Pos-a propustilo u official-pivot.ods: podstablo pivota nosi loext i calcext ekstenzijske atribute dok xmlns deklaracije koje ih vezuju stoje na korenu office:document-content trideset pet vezivanja dalje, pa je isečeni fragment ostavio svaki prefiks koji koristi nevezanim i namespace-aware parser odbio je ceo content.xml
Deklaracija namespace-a je u opsegu od svog početnog do završnog taga, a isecanje podstabla iz dokumenta iseca ga iz tog opsega, što jedan atribut pretvara u nečitljiv deo

Kako HotXLS prenosi xmlns vezivanja predaka na fragment?

HotXLS v2.382.1 zamenio je isečak stringa prolazom kroz content.xml preko sopstvenog streaming TXMLReader-a, održavajući stek namespace vezivanja označenih dubinom na kojoj je svako deklarisano, i kopirajući vezivanja koja su još na snazi na koreni element fragmenta u trenutku kada se stigne do cilja. Reader radi sa uključenim PreserveWhitespaceText tako da tekstualni čvorovi dolaze tačno onako kako su napisani, a rekonstruisani tagovi koriste TXMLReader.RawName i TXMLReader.Attribute[I].RawName — prefiks onako kako je napisan u fajlu — a ne kanonska imena koja reader inače predaje parserima delova. Evo jezgra petlje:

Kako HotXLS v2.382.1 hvata podstablo data pilot tabela zajedno sa namespace opsegom: streaming prolaz kroz TXMLReader drži stek xmlns vezivanja označenih dubinom deklaracije, obilazi ga od najunutrašnjijeg ka spolja na cilju table:data-pilot-tables, poštuje shadowing preko Seen skupa, preskače prefikse koje element sam deklariše i skida vezivanja i na završnim i na praznim elementima
Uparivanje cilja po kanonskom imenu readera pušta i producente koji pišu table prefiks drugačije, a podstablo koje se nikad ne zatvori baca izuzetak umesto da pri čuvanju upiše pola fragmenta
// Namespaces: TStringList sa 'xmlns:p=uri' i dubinom deklaracije 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 efektivna vezivanja predaka 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;   // najunutrašnjije vezivanje pobeđuje
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // već deklarisano ovde? 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 korektnost. Prolazak kroz stek od najunutrašnjijeg vezivanja ka spolja uz pamćenje svakog prefiksa u Seen implementira shadowing: ako bliži predak ponovo veže xmlns:table, bliža vrednost pobeđuje, tačno kako §6.1 kaže da mora. Preskakanje prefiksa koje element već sam deklariše izbegava emitovanje istog atributa dvaput, što bi bila druga vrsta kršenja dobro formiranosti. A pravilo za skidanje sa steka pali se na završnim tagovima i na praznim elementima, jer <x/> nikad ne proizvede EndElement događaj — ista samo-zatvarajuća zamka koju je hvatanje extLst u XLSX-u moralo da nauči. Uparivanje cilja preko Reader.Name a ne RawName je tiša pobeda: reader kanonizuje ODF table namespace URI u prefiks table, pa producent koji ga piše kao t:data-pilot-tables i dalje prolazi, dok emitovani fragment zadržava prefiks koji je producent koristio

Petlja takođe odbija da pogađa. Ako se deo završi dok je hvatanje još otvoreno — skraćen ili malformiran content.xml — OdsCaptureDataPilotTablesXml baca izuzetak umesto da vrati pola fragmenta, jer bi pola fragmenta bilo upisano nazad pri čuvanju i pretvorilo bi oštećen ulaz u oštećen izlaz sa imenom biblioteke na sebi

Gde fragment sleće u sačuvanom content.xml?

HotXLS piše snimljeni fragment u <office:spreadsheet> odmah posle <table:named-expressions> koje generiše i pre <table:database-ranges>. Sadržajni model <office:spreadsheet> iz ODF 1.3 Part 3 propisuje fiksnu sekvencu za tu završnu decu, pa se verbatim blok ne može jednostavno dodati gde god se pisac zatekne; mora pasti u određeni slot. Sa strane pozivaoca nema API-ja i nema šta da se konfiguriše; definicija putuje uz obično otvaranje i čuvanje:

Gde snimljena definicija pivota sleće u HotXLS ODS čuvanje: deca office:spreadsheet prate fiksnu ODF sekvencu od generisanih table elemenata preko table:content-validations i table:named-expressions, verbatim fragment table:data-pilot-tables upada pre table:database-ranges, a API ne postoji jer definicija putuje uz OpenODS i SaveAsODS
Verbatim blok se ne može dodati gde god se pisac zatekne, a kopije vezivanja predaka koje nosi bezopasne su jer Namespaces in XML dozvoljava ponovno deklarisanje prefiksa u ugnježdenom 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;   // izmena unutar izvornog opsega pivota
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml u izlazu i dalje nosi DataPilot1 sa svojim
    // izvornim opsegom, poljima, target opsegom, dugmićima i loext:/calcext: atributima
  finally
    Book.Free;
  end;
end;

Redundancija je namerna i vredi je znati. Koren fragmenta sada ponavlja xmlns:table i xmlns:calcext iako ih i koren sačuvanog dokumenta deklariše; Namespaces in XML dozvoljava ponovno deklarisanje prefiksa u ugnježdenom opsegu, pa su duplikati bezopasni. Za LibreOffice uzorak preneti skup su svih trideset pet root deklaracija, oko dva kilobajta povrh definicije od 8,357 znakova, jer hvatanje ne analizira koje prefikse podstablo zaista koristi. Sken korišćenih prefiksa to bi skratio, i možda dođe kasnije; korektnost prvo, kompaktnost drugo

Pravilo za isecanje podstabala iz XML-a za verbatim reprodukciju

Opšta pouka je da je podstablo samo-sadržano tek kada ga takvim napravite, a namespace opseg je prva stvar koja se polomi kada to zaboravite. Checklista koju HotXLS sada primenjuje na svako hvatanje u stilu „sačuvaj ono što ne modelujemo":

  • Prolazite kroz dokument pravim reader-om i pratite vezivanja u opsegu. Pretraga stringa preko Pos ne vidi opseg uopšte, a i promašuje kod ugnježdenih elemenata sa istim imenom, kod podudarnog stringa unutar komentara ili CDATA sekcije, i kod vrednosti atributa koje slučajno sadrže tekst taga
  • Kopirajte efektivna vezivanja na koren fragmenta, najunutrašnjije prvo, jednom po prefiksu, preskačući ono što koren već deklariše
  • Zadržite prefiks onako kako je napisan u emitovanim tagovima; uparujte cilj po razrešenom namespace-u, ne po doslovnom prefiksu
  • Sačuvajte tekstualne čvorove sa prazninama, i zapamtite da prazan element zatvara sopstveni opseg bez događaja završnog taga
  • Validirajte sačuvani deo parserom koji nije biblioteka koja je pod testom. Biblioteka će sa zadovoljstvom ponovo pročitati sopstveni izlaz kroz istu blagu putanju koda koja ga je i napisala

Poslednja tačka je ona koja je zaista našla HXLS-003 drugi put. Prihvatna provera u v2.382.0 bila je regularni izraz koji broji početne tagove data-pilot-table u sačuvanom content.xml, a regularni izraz vidi tag, ne dokument — slep je za to da li su prefiksi na tom tagu vezani. Strogi corpus runner dodat u v2.382.1 parsira svaki XML i .rels deo sačuvanog paketa namespace-aware parserom i zatim poredi stablo pivota — tag, sortirane atribute, tekst, decu, rekurzivno — sa originalom. To poređenje je namespace-ekspandovano, pa bi preimenovanje prefiksa i dalje prošlo a nevezani prefiks ne može

Gde se garancija verbatim-a završava

Verbatim reprodukcija čuva definiciju; ona je ne razume, i granice iz toga slede. HotXLS ne izlaže nijedan API za čitanje, izmenu ili osvežavanje ODS pivota, pa je FRawOdsDataPilotTablesXml interno polje i jedino vidljivo ponašanje je da definicija preživi. Fragment se reserializuje iz reader događaja, ne kopira kao bajtovi: navodnici atributa i samo-zatvarajući oblici se normalizuju, dok se tekst i praznine čuvaju. Snimljeni XML emituje samo ODS pisac sadržaja, pa radna sveska otvorena iz .ods i sačuvana kao .xlsx gubi pivot, a radna sveska otvorena iz .xlsx nema šta da reprodukuje u .ods čuvanje — asimetrije ODS uvozne i izvozne putanje važe ovde kao i svuda. A pošto je definicija neprozirna, ne može da prati vaše izmene: preimenujte Sheet1 ili pomerite izvorne podatke u HotXLS-u i sačuvani pivot i dalje pokazuje na Sheet1.A2:E30, ostavljajući potrošaču da prijavi pokvaren opseg kada sledeći put osveži. Ovde pripada i jedno upozorenje o redosledu: HotXLS emituje AutoFilter opsege kao <table:database-ranges> posle fragmenta pivota, a corpus uzorak ne nosi nijedan database range, pa radnu svesku koja ima i filter i pivot vredi provući kroz ODF validator šeme pre nego što se pouzdate u relativni redosled ta dva elementa

Testirajte na fajlovima sopstvenog producenta, ne samo na corpus uzorku. Prenos namespace-a obrađuje svaki prefiks koji producent deklariše na pretku, ali dokument koji deklariše prefiks na samom elementu pivota, ili koji koristi podrazumevani namespace za table vokabular, vežba grane preskakanja i shadowing-a koje LibreOffice uzorak ne vežba. Obe su implementirane; nijedna još nema uzorak u corpus-u, a ta razlika je upravo ono što stavka u changelog-u obično zamuti

Verbatim hvatanje data pilot tabela u v2.382.0 i ispravka namespace opsega u v2.382.1 isporučuju se u aktuelnom HotXLS Delphi Excel Component, čija stranica proizvoda navodi punu ODS, XLSX i XLS read-write pokrivenost za Delphi i C++Builder