Technický článek

Kontingenční tabulky ODS v Delphi: scope XML namespaces

HotXLS Delphi Excel Component zachovává kontingenční tabulky OpenDocumentu během cyklu otevření a uložení ODS tak, že při otevření zachytí podstrom <table:data-pilot-tables> z content.xml doslovně a při uložení ho přehraje, a to od v2.382.0. Od v2.382.1 fragment navíc nese každou vazbu XML namespace, kterou deklarovali jeho předci, takže uložená definice pivotu zůstává well formed pro jakéhokoli konzumenta, ne jen pro HotXLS

Bug, který vynutil obě změny, vypadl z přísného korpusového běhu. Ukázka official-pivot.ods napsaná vývojářským buildem LibreOffice 6.1 drží jeden pivot jménem DataPilot1, který čte Sheet1.A2:E30 a výsledek ukotvuje v Sheet1.G6:J18. Otevřete ho HotXLsem, uložte beze změny, spočítejte elementy <table:data-pilot-table> ve výstupu: jedna dovnitř, nula ven, na Win32 i Win64 stejně. Nic v testu se pivotu nedotklo. První kolo sond porovnávalo jen konstanty buněk a prošlo; strukturní assertion je to, co ztrátu vystavil na světlo, což je připomínka, že „hodnoty sedí" je slabá definice věrnosti round tripu

Proč kontingenční tabulka ODS po uložení knihovnou zmizí?

ODS pivot zmizí proto, že HotXLS nemá žádný in-memory model kontingenčních tabulek OpenDocumentu a zapisovač ODS staví content.xml výhradně z modelu. Zapisovač skládá automatické styly, jeden <table:table> na list, <table:content-validations>, <table:named-expressions> a <table:database-ranges>, všechno generované z objektů, které sešit doopravdy drží. Definice pivotu — ODF 1.3 Part 3 §9.6, kontejner <table:data-pilot-tables> s jedním <table:data-pilot-table> na pivot, nesoucí jeho table:source-cell-range, potomky table:data-pilot-field, jeho table:target-range-address a table:buttons — nemá objekt, ve kterém by bydlela, takže regenerovaná část ji prostě vynechá

Kontrast s XLSX je záměrný. HotXLS parsuje pivot cache a pivot tabulky SpreadsheetML do reálného modelu, který můžete stavět, rozšiřovat počítanými poli a refreshovat z Delphi, takže ty uložení přežijí tím, že se přepíšou, ne zkopírují. ODS pivoty jsou mnohem vzácnější žádanka a modelovat slovník ODF data pilotů jen kvůli round tripu by bylo hodně kódu, který nikdo needituje. Pragmatická odpověď je ta samá, kterou HotXLS už aplikuje na neznámé bloky extLst v XLSX: nech si, co nemodeluješ, po bajtech, když to jde, po událostech, když nejde

Co první zachycení přes Pos pokazilo?

Zachycení z v2.382.0 vystřihlo definici pivotu z content.xml jako obyčejný řetězec a výstřižku chyběly deklarace namespace, které jí dávaly smysl. Implementace byla tak krátká, jak zní — dekóduj part do WideString, najdi otevírací tag přes Pos, najdi za ním zavírací tag, zkopíruj rozsah do FRawOdsDataPilotTablesXml na sešitu:

// HotXLS v2.382.0 -- o jedno vydání později nahrazeno
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);   // celé content.xml v paměti
  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;

Count assertion zezelenal a oprava vyšla. Chytilo ji to druhou, přísnější kontrolu přidanou týž den: každý XML part uloženého balíčku putuje do nezávislého parseru vnímajícího namespace mimo HotXLS a tenhle parser odmítl nové content.xml chybou unbound-prefix. Pivot z LibreOffice nese atributy rozšíření producenta — loext:ignore-selected-page="true" na page fieldu, calcext:repeat-item-labels="false" na každé úrovni — a vystřižený řetězec ty atributy obsahoval, ale ne deklarace xmlns:loext a xmlns:calcext, které je vázaly. Ty deklarace seděly na kořeni <office:document-content> zdrojového souboru, pětatřicet z nich, dva tisíce znaků od pivotu

W3C Namespaces in XML 1.0 §6.1 definuje pravidlo, které z toho dělá tvrdé selhání, ne kosmetickou vadu: deklarace namespace je ve scope od start tagu elementu, na kterém stojí, až po jeho end tag a každé prefixované jméno v tom scope se rozřešuje proti ní. Vystřihněte podstrom z dokumentu a vystřihnete ho i ze scope. HotXLS píše vlastní kořen <office:document-content> s jedenácti deklaracemi — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — takže calcext: se náhodou rozřešilo, table: se náhodou rozřešilo a loext: ne. Parser vnímající namespace bere nevázaný prefix jako porušení well-formedness, což znamená, že celý part je nečitelný, ne jen jeden atribut

Co zachycení official-pivot.ods přes Pos v HotXLS přehlédlo: podstrom pivotu nese rozšiřující atributy loext a calcext, zatímco deklarace xmlns, které je vážou, sedí na kořeni office:document-content pětatřicet vazeb daleko, takže vystřižený fragment nechal každý použitý prefix nevázaný a parser vnímající namespace odmítl celé content.xml
Deklarace namespace je ve scope od start tagu do end tagu a vystřižení podstromu z dokumentu ho vystřihne i ze toho scope, což z jednoho atributu udělá nečitelný part

Jak HotXLS přenáší vazby xmlns předků na fragment?

HotXLS v2.382.1 vyměnil řetězcový výstřižek za průchod content.xml vlastním streamovacím TXMLReader, který drží zásobník vazeb namespace označených hloubkou, v jaké byly deklarovány, a ve chvíli, kdy dosáhne cíle, zkopíruje stále účinné vazby na kořenový element fragmentu. Reader běží se zapnutým PreserveWhitespaceText, takže textové uzly se vracejí přesně tak, jak napsané, a znovu postavené tagy používají TXMLReader.RawName a TXMLReader.Attribute[I].RawName — hláskování prefixu ze souboru — místo kanonických jmen, která reader normálně podává parserům partů. Tady je jádro smyčky:

Jak HotXLS v2.382.1 zachycuje podstrom data pilot i s jeho namespace scopem: streamovací průchod TXMLReader drží zásobník vazeb xmlns označených deklarující hloubkou, u cíle table:data-pilot-tables ho prochází od nejvnitřnější, stínování řeší přes množinu Seen, přeskočí prefixy, které element deklaruje sám, a vysazuje vazby na end tagech i na prázdných elementech
Párování cíle podle kanonického jména readeru nechá fungovat producenty, kteří přepíší prefix table, a podstrom, který se nikdy nezavře, vyhodí výjimku místo zápisu napůl fragmentu při uložení
// Namespaces: TStringList 'xmlns:p=uri' s deklarující hloubkou v Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // element, text, CDATA, komentář
  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);   // nejdřív ořízni koncové '>' nebo '/>'
      ...
      // Přeneste účinné vazby předků na kořen fragmentu.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // vyhrává nejvnitřnější vazba
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // už tady deklarované? přeskočit
          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;                           // podstrom se zavřel
  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);                   // opustit scope
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Tři detaily v té smyčce nesou správnost. Procházení zásobníku od nejvnitřnější vazby ven a zapamatování si každého prefixu v Seen implementuje stínování: když blížší předek převáže xmlns:table, vyhrává bližší hodnota, přesně jak §6.1 nařizuje. Přeskakování prefixů, které element deklaruje sám, vyhne se emisi téhož atributu dvakrát, což by byla jiná chyba well-formedness. A pop pravidlo pálí na end tagech i na prázdných elementech, protože <x/> nikdy neprodukuje událost EndElement — stejná samouzavírací past, kterou se muselo naučit zachycení extLst v XLSX. Párování cíle přes Reader.Name místo RawName je tišší výhra: reader kanonizuje URI namespace ODF tabulky na prefix table, takže producent, který ho napíše jako t:data-pilot-tables, pořád sedne, zatímco emitovaný fragment drží ten prefix, jaký použil producent

Smyčka navíc odmítá hádat. Když part skončí, dokud je zachycení pořád otevřené — useknuté nebo poškozené content.xml — OdsCaptureDataPilotTablesXml vyhodí výjimku místo vrácení napůl fragmentu, protože napůl fragment by se při uložení zapsal zpátky a proměnil poškozený vstup v poškozený výstup se jménem knihovny na něm

Kam fragment v uloženém content.xml dopadne?

HotXLS zapisuje zachycený fragment do <office:spreadsheet> hned za jím generovaným <table:named-expressions> a před <table:database-ranges>. Content model <office:spreadsheet> z ODF 1.3 Part 3 předepisuje pro ty závěrečné potomky pevné pořadí, takže doslovný blok nelze prostě přilepit tam, kde se zapisovač zrovna nachází; musí dopadnout do konkrétního slotu. Ze strany volajícího neexistuje žádné API a není co konfigurovat; definice putuje spolu s obyčejným otevřením a uložením:

Kam zachycená definice pivotu dopadne při uložení ODS v HotXLS: potomci office:spreadsheet následují pevnou ODF sekvenci od generovaných table elementů přes table:content-validations a table:named-expressions, doslovný fragment table:data-pilot-tables zapadne do slotu před table:database-ranges a žádné API neexistuje, protože definice putuje spolu s OpenODS a SaveAsODS
Doslovný blok nelze přilepit kamkoliv, kde se zapisovač zrovna nachází, a kopie vazeb předků, které nese, jsou neškodné, protože Namespaces in XML dovoluje znovu deklarovat prefix ve vnořeném scope
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;   // editace uvnitř zdrojového rozsahu pivotu
    Book.SaveAsODS('official-pivot-out.ods');
    // content.xml ve výstupu stále nese DataPilot1 se svým
    // zdrojovým rozsahem, poli, cílovým rozsahem, buttons a atributy loext:/calcext:
  finally
    Book.Free;
  end;
end;

Redundance je záměrná a stojí za to o ní vědět. Kořen fragmentu teď opakuje xmlns:table a xmlns:calcext, i když je kořen uloženého dokumentu taky deklaruje; Namespaces in XML dovoluje znovu deklarovat prefix ve vnořeném scope, takže duplikáty jsou neškodné. U ukázky LibreOffice je přenášená sada všech pětatřicet kořenových deklarací, zhruba dva kilobajty navíc na 8357znakové definici, protože zachycení neanalyzuje, které prefixy podstrom doopravdy používá. Sken použitých prefixů by to zkrátil a může přijít později; nejdřív správnost, pak úspornost

Pravidlo pro vystřihování podstromů z XML k doslovnému přehrání

Obecné poučení zní, že podstrom je sám o sobě úplný, až když to zařídíte, a namespace scope je první věc, která se rozbije, když na to zapomenete. Checklist, který HotXLS teď aplikuje na jakékoli zachycení ve stylu „nech si, co nemodeluješ":

  • Procházejte dokument skutečným readerem a sledujte vazby ve scope. Řetězcové hledání přes Pos scope nevidí vůbec a navíc sedne na vnořené elementy stejného jména, na odpovídající řetězec uvnitř komentáře nebo sekce CDATA a na hodnoty atributů, které náhodou obsahují text tagu
  • Zkopírujte účinné vazby na kořen fragmentu, od nejvnitřnější, jednou na prefix, s vynecháním toho, co kořen už deklaruje
  • Držte v emitovaných tagech syrové hláskování prefixu; cíl párujte podle rozřešeného namespace, ne podle literálního prefixu
  • Zachovávejte textové uzly s bílými znaky a pamatujte, že prázdný element zavírá vlastní scope bez události end tagu
  • Validujte uložený part parserem, který není testovaná knihovna. Knihovna si s potěšením znovu přečte vlastní výstup touž shovívavou cestou, která ho napsala

Ten poslední bod je ten, který HXLS-003 podruhé doopravdy našel. Přijímací kontrola v2.382.0 byla regulární výraz počítající start tagy data-pilot-table v uloženém content.xml a regulární výraz vidí tag, ne dokument — je slepý k tomu, zda jsou prefixy na tom tagu vázané. Přísný korpusový běhač přidaný ve v2.382.1 parsuje každý XML a .rels part uloženého balíčku parserem vnímajícím namespace a pak porovnává pivot strom — tag, seřazené atributy, text, potomky, rekurzivně — s originálem. To porovnání je namespace-expandované, takže přepsání prefixu by pořád prošlo a nevázaný prefix nemůže

Kde doslovná záruka končí

Doslovné přehrání definici uchovává; nerozumí jí, a hranice z toho plynou. HotXLS nevystavuje žádné API pro čtení, editaci nebo refresh ODS pivotu, takže FRawOdsDataPilotTablesXml je interní pole a jediné pozorovatelné chování je, že definice přežije. Fragment se znovu serializuje z událostí readeru, ne kopíruje jako bajty: uvozování atributů a samouzavírací tvary se normalizují, text a bílé znaky se drží. Zachycené XML emituje jen zapisovač obsahu ODS, takže sešit otevřený z .ods a uložený jako .xlsx o pivot přijde a sešit otevřený z .xlsx nemá co přehrát do uložení .ods — asymetrie importní a exportní cesty ODS platí tady jako všude. A protože je definice neprůhledná, nemůže následovat vaše editace: přejmenujte Sheet1 nebo pohněte se zdrojovými daty v HotXLsu a uložený pivot pořád míří na Sheet1.A2:E30, přičemž na konzumentovi je, aby nahlásil rozbitý rozsah, až si příště refreshne. Jedna poznámka k pořadí sem taky patří: HotXLS emituje rozsahy AutoFilter jako <table:database-ranges> za fragmentem pivotu a korpusová ukázka nenese žádný database range, takže sešit s filtrem i pivotem zároveň byste měli pustit validátorem ODF schématu, než se spolehnete na vzájemné pořadí těch dvou elementů

Testujte na souborech vlastního producenta, ne jen na korpusové ukázce. Přenos namespace zvládne jakýkoli prefix, který producent deklaruje na předkovi, ale dokument, který deklaruje prefix přímo na elementu pivotu, nebo který pro tabulkový slovník používá default namespace, prověří větve přeskočení a stínování, které ukázka LibreOffice ne. Obě jsou implementované; ukázka v korpusu pro ně ještě není a právě tenhle rozdíl changelog záznam rád rozmazá

Doslovné zachycení data pilotů ve v2.382.0 a oprava namespace scope ve v2.382.1 dodává současný HotXLS Delphi Excel Component, jehož produktová stránka uvádí kompletní pokrytí čtení a zápisu ODS, XLSX a XLS pro Delphi a C++Builder