Műszaki cikk

ODS pivot körbejáratása Delphiben: XML-névtérhatókör

A HotXLS Delphi Excel Component a v2.382.0 óta megőrzi az OpenDocument data pilot táblákat egy ODS megnyitás-mentés cikluson át úgy, hogy megnyitáskor szó szerint elkapja a content.xml <table:data-pilot-tables> részfáját, és mentéskor visszajátssza. A v2.382.1 óta a fragmentum az összes olyan XML-névtérkötést is hordozza, amelyet az ősei deklaráltak, így a mentett pivotdefiníció minden feldolgozó számára jól formált marad, nem csak a HotXLS számára

A hibát, amely mindkét változtatást kikényszerítette, egy szigorú korpuszfutás hozta elő. Az official-pivot.ods minta, amelyet egy LibreOffice 6.1 fejlesztői build írt, egyetlen DataPilot1 nevű pivotot tartalmaz, amely a Sheet1.A2:E30 tartományt olvassa, és az eredményét a Sheet1.G6:J18 helyre teszi. Nyissa meg HotXLS-szel, mentse el változtatás nélkül, és számolja meg a <table:data-pilot-table> elemeket a kimenetben: egy be, nulla ki, Win32 és Win64 alatt egyaránt. A tesztben semmi nem nyúlt a pivothoz. Az első ellenőrzési kör csak a cellakonstansokat hasonlította össze, és átment; a szerkezeti állítás volt az, amely leleplezte a veszteséget, ami emlékeztető arra, hogy az „az értékek egyeznek" gyenge definíciója a körbejáratási hűségnek

Miért tűnik el egy ODS pivot tábla a könyvtárbeli mentés után?

Az ODS pivot tábla azért tűnik el, mert a HotXLS-nek nincs memóriabeli modellje az OpenDocument data pilot táblákhoz, az ODS-író pedig a content.xml fájlt teljes egészében a modellből építi fel. Az író összeállítja az automatikus stílusokat, munkalaponként egy <table:table> elemet, a <table:content-validations>, <table:named-expressions> és <table:database-ranges> blokkokat, mindegyiket a munkafüzetben ténylegesen meglévő objektumokból generálva. Egy pivotdefiníciónak — ODF 1.3 Part 3 §9.6, egy <table:data-pilot-tables> konténer pivotonként egy <table:data-pilot-table> elemmel, amely hordozza a table:source-cell-range elemét, a table:data-pilot-field gyerekeit, valamint a table:target-range-address és a table:buttons elemeit — nincs objektuma, amelyben élhetne, így az újragenerált rész egyszerűen kihagyja

Az XLSX-hez képesti ellentét szándékos. A HotXLS a SpreadsheetML pivot cache-eket és pivot táblákat valódi modellé dolgozza fel, amelyet Delphiből felépíthet, számított mezőkkel bővíthet és frissíthet, így azok túlélik a mentést, mert újraíródnak, nem másolódnak. Az ODS pivotok sokkal ritkább kérés, az ODF data pilot szókészlet modellezése pedig pusztán a körbejáratás kedvéért rengeteg olyan kód lenne, amelyhez senki nem nyúl. A pragmatikus válasz ugyanaz, amelyet a HotXLS már alkalmaz az XLSX ismeretlen extLst blokkjaira: őrizze meg, amit nem modellez, bájtról bájtra, ha lehet, eseményről eseményre, ha nem

Mit hibázott el az első, Pos-alapú kinyerés?

A v2.382.0-ban a kinyerés sima sztringként vágta ki a pivotdefiníciót a content.xml-ből, a kivágott darabból pedig hiányoztak azok a névtér-deklarációk, amelyek értelmessé tették. Az implementáció pont olyan rövid, amilyennek hangzik — dekódolja a részt egy WideString-be, megkeresi a nyitó taget a Pos hívással, megkeresi utána a záró taget, és a kifogott szakaszt a munkafüzet FRawOdsDataPilotTablesXml mezőjébe másolja:

// HotXLS v2.382.0 -- egy kiadással később felváltva
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);   // a teljes content.xml a memóriában
  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;

A darabszám-ellenőrzés zöldre váltott, a javítás pedig megjelent. Az fogta el, hogy még aznap bekerült egy második, szigorúbb ellenőrzés: a mentett csomag minden XML-részét átadjuk egy független, névtér-tudatos elemzőnek a HotXLS-en kívül, és az az elemző az új content.xml-t egy nem kötött előtag hibájával utasította el. A LibreOffice-ból származó pivot gyártóspecifikus kiterjesztés-attribútumokat hordoz — loext:ignore-selected-page="true" egy oldalmezőn, calcext:repeat-item-labels="false" minden szinten —, a kivágott sztring pedig tartalmazta ezeket az attribútumokat, de nem tartalmazta az őket kötő xmlns:loext és xmlns:calcext deklarációkat. Azok a deklarációk a forrásfájl <office:document-content> gyökerén ültek, harmincöt darab, kétezer karakterre a pivottól

A W3C Namespaces in XML 1.0 §6.1 írja le azt a szabályt, amely ezt kemény hibává, nem pedig kozmetikaivá teszi: egy névtér-deklaráció annak az elemnek a nyitó tagjától érvényes, amelyen szerepel, egészen ugyanazon elem záró tagjáig, és az ezen a hatókörön belüli minden előtaggal írt név ehhez oldódik fel. Vágjon ki egy részfát a dokumentumból, és ki fogja vágni a hatókörből is. A HotXLS a saját <office:document-content> gyökerét tizenegy deklarációval írja — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo —, így a calcext: épp feloldódott, a table: épp feloldódott, a loext: viszont nem. A névtér-tudatos elemző a nem kötött előtagot jólformáltsági hibaként kezeli, ami azt jelenti, hogy az egész rész olvashatatlan, nem csupán egyetlen attribútum

Mit hagyott ki a HotXLS Pos-alapú kinyerése az official-pivot.ods fájlból: a pivot-részfa loext és calcext kiterjesztés-attribútumokat hordoz, az őket kötő xmlns-deklarációk viszont az office:document-content gyökerén ülnek, harmincöt kötéssel odébb, így a kivágott fragmentum minden általa használt előtagot kötetlenül hagyott, és a névtér-tudatos elemző az egész content.xml-t visszautasította
A névtér-deklaráció a nyitó tagjától a záró tagjáig érvényes, és aki részfát vág ki a dokumentumból, az a hatókörből is kivágja, ami egyetlen attribútumból olvashatatlan részt csinál

Hogyan viszi rá a HotXLS az ős xmlns-kötéseket a fragmentumra?

A HotXLS v2.382.1 lecserélte a sztringkivágást egy, a saját streamelő TXMLReader osztályán keresztül vezető menetre a content.xml felett: veremben tartja a névtérkötéseket, megjelölve mindegyik deklarálásának mélységét, és abban a pillanatban, amikor eléri a cél elemet, rámásolja a fragmentum gyökér elemére a még érvényben lévő kötéseket. Az olvasó bekapcsolt PreserveWhitespaceText mellett fut, így a szöveges csomópontok pontosan úgy jönnek vissza, ahogy meg vannak írva, az újraépített tagek pedig a TXMLReader.RawName és a TXMLReader.Attribute[I].RawName értékét használják — az előtag fájlbeli írásmódját —, nem azokat a kanonikus neveket, amelyeket az olvasó egyébként a részelemzőknek ad. Íme a ciklus magja:

Hogyan kapja el a HotXLS v2.382.1 a data pilot részfát a névtérhatókörével együtt: a streamelő TXMLReader-menet veremben tartja a xmlns-kötéseket a deklarálási mélységgel megjelölve, a table:data-pilot-tables célnál belülről kifelé halad rajta, a Seen halmazon keresztül érvényesíti az árnyékolást, kihagyja azokat az előtagokat, amelyeket az elem maga deklarál, és a kötéseket a záró tageknél és az üres elemeknél egyaránt leveszi
A cél megcélzása a kanonikus olvasónév alapján életben tartja azokat a gyártókat, amelyek másképp írják a table előtagot, a soha le nem záródó részfa pedig kivételt dob ahelyett, hogy fél fragmentumot írna vissza mentéskor
// Namespaces: TStringList 'xmlns:p=uri' alakú sorokból, a deklarálási mélység az Objects[] tömbben
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // elem, szöveg, CDATA, megjegyzés
  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);   // előbb lehántjuk a záró '>' vagy '/>' jelet
      ...
      // A hatályos ős-kötések rámásolása a fragmentum gyökerére.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // a legbelső kötés nyer
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // itt már deklarálva van? kihagyjuk
          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;                           // a részfa lezárult
  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);                   // kilépünk a hatókörből
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Három részlet hordozza ebben a ciklusban a helyességet. A verem bejárása a legbelső kötéstől kifelé, és az egyes előtagok megjegyzése a Seen halmazban valósítja meg az árnyékolást: ha egy közelebbi ős újraköti az xmlns:table-t, a közelebbi érték nyer, pontosan ahogy a §6.1 megköveteli. Azoknak az előtagoknak a kihagyása, amelyeket az elem maga már deklarál, megakadályozza ugyanannak az attribútumnak a kétszeri kibocsátását, ami egy másfajta jólformáltsági hiba volna. A levesz-szabály pedig a záró tageknél és az üres elemeknél is tüzel, mert a <x/> soha nem hoz létre EndElement eseményt — ugyanaz az önmagát lezáró csapda, amelyet az XLSX extLst kinyerésének is meg kellett tanulnia. A cél megcélzása a Reader.Name alapján a RawName helyett csendesebb győzelem: az olvasó az ODF table névtér URI-ját a table előtagra kanonizálja, így az a gyártó is illeszkedik, amely t:data-pilot-tables alakban írja, a kibocsátott fragmentum pedig megtartja, milyen előtagot használt a gyártó

A ciklus a tippelést is visszautasítja. Ha a rész véget ér, miközben a kinyerés még nyitva van — csonkolt vagy hibás content.xml —, az OdsCaptureDataPilotTablesXml kivételt dob a fél fragmentum visszaadása helyett, mert a fél fragmentum mentéskor visszaíródna, és egy sérült bemenetből a könyvtár nevével ellátott sérült kimenetet csinálna

Hová kerül a fragmentum a mentett content.xml-ben?

A HotXLS a kinyert fragmentumot az <office:spreadsheet> elembe írja, közvetlenül az általa generált <table:named-expressions> után és a <table:database-ranges> előtt. Az <office:spreadsheet> ODF 1.3 Part 3 szerinti tartalmi modellje rögzített sorrendet ír elő ezekre a záró gyerekelemekre, így egy szó szerinti blokkot nem lehet egyszerűen oda fűzni, ahol az író épp tart; egy meghatározott helyre kell betenni. A hívó oldaláról nincs API és nincs mit konfigurálni; a definíció egy szokásos megnyitással és mentéssel együtt utazik:

Hová kerül a kinyert pivotdefiníció egy HotXLS ODS-mentésben: az office:spreadsheet gyerekei a rögzített ODF-sorrendet követik a generált táblaelemektől a table:content-validations és a table:named-expressions blokkon át, a szó szerinti table:data-pilot-tables fragmentum a table:database-ranges elé kerül be, és nem létezik API, mert a definíció az OpenODS és a SaveAsODS hívásokkal együtt utazik
Egy szó szerinti blokkot nem lehet akárhová odafűzni, ahol az író épp tart, az általa hordozott ős-kötések másolatai pedig ártalmatlanok, mert a Namespaces in XML megengedi egy előtag újradeklarálását beágyazott hatókörben
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;   // szerkesztés a pivot forrástartományán belül
    Book.SaveAsODS('official-pivot-out.ods');
    // a kimenet content.xml fájlja továbbra is hordozza a DataPilot1-et a
    // forrástartományával, mezőivel, céltartományával, gombjaival és loext:/calcext: attribútumaival
  finally
    Book.Free;
  end;
end;

A redundancia szándékos, és érdemes tudni róla. A fragmentum gyökere mostantól megismétli az xmlns:table és xmlns:calcext deklarációt, holott a mentett dokumentum gyökere is deklarálja őket; a Namespaces in XML megengedi egy előtag újradeklarálását beágyazott hatókörben, így a duplikátumok ártalmatlanok. A LibreOffice minta esetében az átvitt halmaz mind a harmincöt gyökérdeklaráció, mintegy két kilobájt a 8,357 karakteres definíció tetején, mert a kinyerés nem elemzi, hogy a részfa valójában mely előtagokat használja. Egy használt-előtag vizsgálat lefaragná ezt, és talán később meg is érkezik; előbb a helyesség, aztán a tömörség

Szabály arra, hogyan vágjunk részfákat XML-ből szó szerinti visszajátszáshoz

Az általános tanulság az, hogy egy részfa csak akkor önmagában megálló, ha önmagában megállóvá tettük, és a névtérhatókör az első dolog, ami eltörik, ha erről megfeledkezünk. Az ellenőrzőlista, amelyet a HotXLS mostantól minden olyan kinyerésre alkalmaz, amelynek a lényege: őrizze meg, amit nem modellez:

  • Járja be a dokumentumot valódi olvasóval, és kövesse nyomon a hatókörben lévő kötéseket. A Pos-szal végzett sztringkeresés egyáltalán nem látja a hatókört, ráadásul tévesen illeszkedik azonos nevű beágyazott elemekre, megjegyzésben vagy CDATA szakaszban lévő egyező sztringre, valamint olyan attribútumértékekre, amelyek épp tartalmazzák a tag szövegét
  • Másolja a hatályos kötéseket a fragmentum gyökerére, belülről kifelé, előtagoként egyszer, kihagyva azt, amit a gyökér maga már deklarál
  • Tartsa meg az előtagok nyers írásmódját a kibocsátott tagekben; a célpontot a feloldott névtér alapján illessze, ne a szó szerinti előtag alapján
  • Őrizze meg a whitespace szöveges csomópontokat, és ne feledje, hogy egy üres elem zárótag-esemény nélkül zárja le a saját hatókörét
  • Érvényesítse a mentett részt olyan elemzővel, amely nem a vizsgált könyvtár. A könyvtár boldogan visszaolvassa a saját kimenetét ugyanazon az elnéző kódúton, amely megírta

Az utolsó pont az, amely valójában másodszor is megtalálta a HXLS-003-at. A v2.382.0 elfogadási ellenőrzése egy reguláris kifejezés volt, amely a mentett content.xml-ben megszámolta a data-pilot-table nyitó tageket, a reguláris kifejezés pedig taget lát, nem dokumentumot — vak arra, hogy a tagen lévő előtagok kötöttek-e. A v2.382.1-ben hozzáadott szigorú korpuszfuttató a mentett csomag minden XML- és .rels részét névtér-tudatos elemzővel dolgozza fel, majd a pivotfát — tag, rendezett attribútumok, szöveg, gyerekek, rekurzívan — összeveti az eredetivel. Ez az összehasonlítás névtérrel kiterjesztett, így az előtag más írásmódja is átmenne, a kötetlen előtag viszont nem mehet át

Hol ér véget a szó szerinti garancia

A szó szerinti visszajátszás megőriz egy definíciót; nem érti meg, és a határok ebből következnek. A HotXLS nem tesz közzé API-t ODS pivot olvasására, szerkesztésére vagy frissítésére, így a FRawOdsDataPilotTablesXml belső mező, az egyetlen megfigyelhető viselkedés pedig az, hogy a definíció túléli az utat. A fragmentum olvasói eseményekből szerializálódik újra, nem bájtokként másolódik: az attribútumok idézőjelezése és az önmagukat lezáró formák normalizálódnak, a szöveg és a whitespace viszont megmarad. A kinyert XML-t kizárólag az ODS-tartalomíró bocsátja ki, így egy .ods-ből megnyitott és .xlsx-ként mentett munkafüzet elveszíti a pivotot, egy .xlsx-ből megnyitott munkafüzetnek pedig nincs mit visszajátszania egy .ods-mentésbe — az ODS import- és exportutak aszimmetriái itt is érvényesek, mint mindenhol. És mivel a definíció átlátszatlan, nem tudja követni a szerkesztéseit: nevezze át a Sheet1-et, vagy mozgassa el a forrásadatokat a HotXLS-ben, és a mentett pivot továbbra is a Sheet1.A2:E30 helyre mutat, a feldolgozónak pedig a következő frissítésnél törött tartományt kell jelentenie. Ide tartozik egy sorrendi figyelmeztetés is: a HotXLS az AutoFilter-tartományokat <table:database-ranges> formában a pivot-fragmentum után bocsátja ki, a korpuszminta pedig nem hordoz adatbázistartományt, így azt a munkafüzetet, amelyben szűrő és pivot is van, érdemes átfuttatni egy ODF-sémaérvényesítőn, mielőtt a két elem egymáshoz viszonyított sorrendjére támaszkodna

Teszteljen a saját gyártója fájljaival, ne csak a korpuszmintával. A névtér-átvitel minden olyan előtagot kezel, amelyet a gyártó egy ősön deklarál, de az a dokumentum, amely magán a pivot elemen deklarál előtagot, vagy amely alapértelmezett névteret használ a table szókészlethez, azokat a kihagyási és árnyékolási ágakat gyakorolja, amelyeket a LibreOffice minta nem. Mindkettő meg van valósítva; egyikhez sincs még minta a korpuszban, és pontosan ez az a fajta különbség, amelyet egy changelog-bejegyzés hajlamos elmosni

A v2.382.0 szó szerinti data pilot kinyerése és a v2.382.1 névtérhatókört javító módosítása a jelenlegi HotXLS Delphi Excel Componentben érkezik, amelynek termékoldala felsorolja a teljes ODS-, XLSX- és XLS-olvasási-írási lefedettséget Delphihez és C++Builderhez