Artykuł techniczny

Round-trip tabeli przestawnej ODS: zakres nazw XML

Skoroszyt ODS z jedną tabelą przestawną przechodzi przez bibliotekę, edytowana zostaje pojedyncza komórka źródłowa, a zapisany plik otwiera się z wszelkimi wartościami komórek nietkniętymi i bez tabeli: content.xml na wejściu miał jeden <table:data-pilot-table>, na wyjściu ma zero. Oczywista naprawa to skopiowanie XML-a tabeli jako napisu. To przywraca liczbę elementów, po czym parser rozumiejący przestrzenie nazw odmawia przyjęcia zapisanej części z błędem niepowiązanego prefiksu. Oba niepowodzenia mają jeden korzeń. Fragment XML-a podróżuje bezpiecznie tylko wtedy, gdy deklaracje przestrzeni nazw, od których zależy, podróżują razem z nim

Gdzie w content.xml mieszka definicja tabeli przestawnej ODS

Tabela przestawna OpenDocument, którą format nazywa data pilot, to definicja przechowywana za arkuszami w content.xml, a writer odbudowujący tę część z własnego modelu obiektów zachowa ją tylko wtedy, gdy model ma gdzie ją umieścić. ODF 1.3 Part 3 §9.6 opisuje kontener: <table:data-pilot-tables> mieści po jednym <table:data-pilot-table> na tabelę, każdy z table:source-cell-range, zestawem dzieci table:data-pilot-field, table:target-range-address i listą table:buttons. Komórki wynikowe, które tabela pokazuje, siedzą wewnątrz arkusza jako zwykłe komórki. Writer, który regeneruje tylko komórki, style, walidacje, nazwane wyrażenia i zakresy bazodanowe, produkuje więc zupełnie poprawny arkusz z tymi samymi liczbami na ekranie i bez tabeli przestawnej za nimi

HotXLS Delphi Excel Component wyczytuje całe poddrzewo <table:data-pilot-tables> z content.xml podczas OpenODS, zanim sparsuje choć jedną komórkę, a potem próbuje przełożyć kolekcję na swój model tabel przestawnych. Definicja w zwykłym kształcie, który model reprezentuje — z lokalnym zakresem źródłowym, polem wierszowym, miarami agregującymi i zapisanymi wynikami zgodnymi z danymi źródłowymi — staje się TXLSPivotTable na liście PivotTables arkusza, tym samym obiektem, który dostaje się z tabeli XLSX i można rozbudowywać o pola liczone i odświeżać z Delphi. Wszystko poza tym kształtem zostawia kolekcję jako przechwycony XML, a SaveAsODS zapisuje go z powrotem. Wystarczy atrybut w przestrzeni nazw, której tłumacz nie zna: tabele zapisane przez LibreOffice mogą nieść atrybuty loext: i calcext:, a atrybut loext: wysyła definicję na ścieżkę przechwyconą

Fallback jest all or nothing na cały skoroszyt. Jeśli jedna tabela z kolekcji trzech wypadnie poza tłumaczalny kształt, wszystkie trzy zostają przechwyconym XML-em. Rozdzielenie ich zostawiłoby w content.xml dwa źródła prawdy dla jednego kontenera, połowę odbudowaną z obiektów i połowę odtworzoną z tekstu, a każda rozbieżność między połówkami trafiłaby do pliku użytkownika. Trzymanie kolekcji w całości to bezpieczniejszy domyślny wybór i ta sama polityka, którą HotXLS stosuje dla nieznanych bloków extLst w XLSX: czego model nie umie ponieść, zostaje zapisane tak, jak było

Dlaczego pocięta definicja tabeli psuje zapisany plik?

Definicja tabeli wycięta z content.xml wyszukiwaniem napisów traci deklaracje przestrzeni nazw, które nadają jej prefiksom sens, a Namespaces in XML traktuje prefiks bez deklaracji jako błąd well-formedness. Podejście napisowe jest krótkie: zdekoduj część do WideString, znajdź <table:data-pilot-tables przez Pos, znajdź za nim pasujący tag zamykający i skopiuj wszystko pomiędzy. Wychodzący tekst wygląda na kompletny. Zawiera loext:ignore-selected-page="true" na polu strony i calcext:repeat-item-labels="false" na każdym poziomie, ale deklaracje xmlns:loext i xmlns:calcext, które wiążą te prefiksy, siedzą na korzeniu dokumentu <office:document-content>, tysiące znaków wcześniej

Namespaces in XML 1.0 §6.1 utrzymuje deklarację w zasięgu od tagu początkowego, który ją niesie, do pasującego tagu końcowego, i każda nazwa z prefiksem wewnątrz rozwiązuje się względem niej. Wytnij poddrzewo z dokumentu, a wycinasz je z tego zasięgu. Gdy fragment trafi do nowego dokumentu, każdy prefiks rozwiąże się tylko wtedy, gdy nowy korzeń akurat go deklaruje. HotXLS pisze własny <office:document-content> z przestrzeniami nazw, których używa jego writer, w tym office, table, text, style, fo, draw, calcext, tableooo i of. W pociętej kopii table: się rozwiąże, calcext: się rozwiąże, a loext: nie. Jeden niepowiązany prefiks wystarcza, by parser rozumiejący przestrzenie nazw odrzucił całą część, więc tabela nie degraduje się łagodnie; dane arkusza wokół niej też stają się nieczytelne

Czego przechwytywanie oparte na Pos gubi przy tabeli przestawnej LibreOffice: poddrzewo tabeli niesie atrybuty rozszerzeń loext i calcext, podczas gdy deklaracje xmlns, które je wiążą, siedzą na korzeniu office:document-content, więc pocięty fragment zostawia te prefiksy niepowiązane, a parser rozumiejący przestrzenie nazw odrzuca cały content.xml
Deklaracja przestrzeni nazw obowiązuje od swojego tagu początkowego do końcowego, a wycięcie poddrzewa z dokumentu wycina je z tego zasięgu, co zamienia jeden atrybut w nieczytelną część

Wyszukiwanie napisów ma i inne ślepe pola, z którymi przechwyt oparty na czytniku nigdy się nie spotka. Pos trafia w tekst tagu wewnątrz komentarza, sekcji CDATA albo wartości atrybutu, która go cytuje. Nie odróżni zagnieżdżonych elementów o tej samej nazwie. I dopasowuje literał prefiksu table:, podczas gdy ODF pozwala producentowi związać przestrzeń nazw table z dowolnym prefiksem

Jak HotXLS przenosi zasięg przestrzeni nazw na fragment

HotXLS przechwytuje poddrzewo tabeli czytnikiem XML strumieniowym, który śledzi każdą deklarację przestrzeni nazw razem z głębokością, na której się pojawiła, i w momencie dotarcia do elementu docelowego kopiuje deklaracje wciąż obowiązujące na tag otwierający fragmentu. Stos rośnie na każdym tagu początkowym deklarującym xmlns lub xmlns:prefix i maleje, gdy element, który zadeklarował wiązanie, się zamyka. Puste elementy wymagają tego samego traktowania: <x xmlns:p="..."/> nigdy nie generuje osobnego zdarzenia tagu końcowego, więc zdjęcie ze stosu musi odpalić się także na pustym tagu początkowym, inaczej wiązanie przecieka do rodzeństwa

W miejscu docelowym przechwyt przechodzi stos od wiązania najgłębszego na zewnątrz i pisze każdy prefiks raz. Chodzenie od środka na zewnątrz realizuje shadowing. Jeśli <office:spreadsheet> wiąże ponownie prefiks już zadeklarowany na korzeniu, bliższa wartość jest tą obowiązującą w tabeli, dokładnie jak wymaga §6.1, a zbiór już zapisanych prefiksów pilnuje, by wartość zewnętrzna nie podążyła za nią. Prefiks, który element tabeli deklaruje na samym sobie, jest pomijany, bo podwójne xmlns:table na jednym tagu to inny błąd well-formedness

Jak HotXLS przechwytuje poddrzewo data pilot razem z zasięgiem przestrzeni nazw: przebieg strumieniowego czytnika XML trzyma stos wiązań xmlns oznaczonych głębokością deklaracji, na celu table:data-pilot-tables przechodzi go od najgłębszego wiązania, respektuje shadowing przez zbiór już zapisanych prefiksów, pomija prefiksy, które element deklaruje na samym sobie, i zdejmuje wiązania zarówno na tagach końcowych, jak i na pustych elementach
dopasowywanie celu po przestrzeni nazw, a nie po literale prefiksu, utrzymuje w ruchu producentów, którzy przeliterują prefiks table inaczej, a poddrzewo, które się nigdy nie zamyka, kończy wyjątkiem zamiast zapisu połówki fragmentu przy następnym zapisie

Cel jest dopasowywany po przestrzeni nazw, więc producent piszący <t:data-pilot-tables> z t związanym z URI tabeli ODF też zostaje rozpoznany, a przechwycone tagi zachowują pisownię z pliku. Węzły tekstowe ze spacjami wracają dokładnie tak, jak były zapisane. Cytowanie atrybutów i wybór między <x/> a <x></x> podlegają normalizacji, bo fragment jest ponownie serializowany ze zdarzeń czytnika, a nie kopiowany bajt w bajt. Jeśli content.xml skończy się, gdy poddrzewo tabeli jest wciąż otwarte — jak w skróconym pliku — otwarcie kończy się wyjątkiem, zamiast trzymać pół definicji, która zostałaby zapisana przy kolejnym zapisie

Przenoszony zbiór jest celowo hojny. Przechwyt kopiuje każde wiązanie w zasięgu, niezależnie czy poddrzewo go używa, więc korzeń fragmentu powtarza xmlns:table i xmlns:calcext, choć korzeń zapisanego dokumentu też je deklaruje. Ponowna deklaracja prefiksu w zagnieżdżonym zasięgu jest legalna wg Namespaces in XML, a plik od producenta deklarującego dziesiątki przestrzeni nazw na korzeniu tyje o parę kilobajtów. Przycięcie listy oznaczałoby skanowanie poddrzewa pod kątem każdego prefiksu w nazwach elementów, nazwach atrybutów i wartościach atrybutów trzymających QName, a jedna pomyłka w tej robocie sprowadza błąd niepowiązanego prefiksu z powrotem. Kilka redundantnych deklaracji kosztuje znacznie mniej niż to ryzyko

Otwarcie, edycja i zapis skoroszytu niosącego tabelę

Od strony wywołującej nie ma czego włączać: definicja tabeli jedzie razem ze zwykłym OpenODS i SaveAsODS. Arkusze numeruje się od 1, a obie metody zwracają 1 przy sukcesie:

uses
  System.SysUtils, lxHandleX;

procedure EditPivotSource(const InFile, OutFile: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS(InFile) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InFile]);
    // E2 leży wewnątrz zakresu źródłowego tabeli Sheet1.A2:E30
    Book.Sheets[1].Cells[2, 5].Value := 1250.0;
    if Book.SaveAsODS(OutFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [OutFile]);
  finally
    Book.Free;
  end;
end;

W zapisanym content.xml kolekcja tabel siedzi wewnątrz <office:spreadsheet> tuż za generowanym przez writer <table:named-expressions> i przed <table:database-ranges>. Skoroszyt niosący jednocześnie AutoFilter, który HotXLS zapisuje jako zakres bazodanowy, i tabelę przestawną stawia oba elementy obok siebie, i jeśli twój potok przepuszcza plik przez ścisły walidator schematu ODF, to właśnie ta para jest miejscem, od którego warto zacząć szukanie

Gdzie w zapisie ODS HotXLS ląduje przechwycona definicja tabeli przestawnej: najpierw table:content-validations i generowane tabele, potem table:named-expressions, dosłowny fragment table:data-pilot-tables i table:database-ranges, bez API, bo definicja jedzie razem z OpenODS i SaveAsODS
dosłowny blok jest zapisywany w jednym stałym miejscu wewnątrz office:spreadsheet, a niesione przez niego kopie wiązań przodków są nieszkodliwe, bo Namespaces in XML pozwala na ponowną deklarację prefiksu w zagnieżdżonym zasięgu

Edycja z tego przykładu zmienia wartość źródłową i zostawia komórki wynikowe tabeli takie, jakie były. Przechwycona definicja jest odtwarzana dosłownie: HotXLS jej nie przelicza, a aplikacja arkuszowa otwierająca plik przeliczy tabelę przy następnym odświeżeniu. To samo dotyczy adresów. Zmień nazwę Sheet1 albo przenieś blok źródłowy, a zapisana definicja wciąż będzie wskazywać Sheet1.A2:E30, co konsumująca aplikacja zgłosi jako zepsuty zakres przy odświeżeniu. W skoroszytach, których tabele podróżują tą drogą, trzymaj nazwy arkuszy i blok źródłowy stabilne

Którą ścieżkę przeszła dana tabela?

Najszybszy test to lista PivotTables arkusza: przetłumaczona definicja pokazuje się tam jako TXLSPivotTable, przechwycona nie. Raport konwersji odpowiada na drugą połowę pytania: co stałoby się z tabelą w innym formacie wyjściowym. GetConversionReport ogląda skoroszyt bez zapisu i przeliczania, a zwrócony raport należy do wywołującego:

uses
  System.SysUtils, lxHandleX, lxDiagnostics;

function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
  I, Modeled: Integer;
  Report: TXLSConversionReport;
begin
  Modeled := 0;
  for I := 1 to Book.Sheets.Count do          // Sheets jest numerowane od 1
    Inc(Modeled, Book.Sheets[I].PivotTables.Count);

  Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
  try
    for I := 0 to Report.Count - 1 do
      if (Report[I].Feature = 'OpenDocumentPivotTables') and
         (Report[I].Fidelity = xlsConversionDropped) then
        Exit('Captured ODF pivot XML: keep this workbook on .ods');
  finally
    Report.Free;
  end;

  if Modeled > 0 then
    Result := Format('%d pivot table(s) in the pivot model', [Modeled])
  else
    Result := 'No pivot definitions';
end;

Ścieżka wyjściowa ma znaczenie, bo przechwycony XML to słownictwo ODF i emituje go tylko writer ODS. Zapisany jako .xlsx, przechwycona kolekcja nie ma odpowiednika w SpreadsheetML, a raport wypisuje ją pod OpenDocumentPivotTables z xlsConversionDropped. Writer binarnego .xlsb odmawia skoroszytu, który wciąż ją trzyma. Przetłumaczona tabela to zwykły obiekt modelu i podlega regułom formatu docelowego. Różnice między ścieżkami importu i eksportu ODS obowiązują tu jak wszędzie, a dla plików z tabelami bezpieczna droga to .ods na wejściu, .ods na wyjściu

Z fallbacku all-or-nothing wynika jeszcze jedna reguła. Skoroszyt albo odtwarza przechwyconą kolekcję, albo zapisuje swoje tabele z modelu; nigdy nie miesza jednego i drugiego w jednym content.xml. Dodanie tabeli przez AddPivotTable do skoroszytu, który już trzyma przechwycony XML tabel, zatrzymuje zapis ODS z błędem, zamiast splatać generowany XML obok odtwarzanego

Kontrola zapisanej części drugim parserem

Liczba elementów nie dowodzi niczego o przestrzeniach nazw. Wyrażenie regularne znajdujące tagi początkowe data-pilot-table w zapisanym content.xml widzi tekst, i pocięta kopia z drugiej sekcji przechodzi taki test. Parser rozumiejący przestrzenie nazw, niemający nic wspólnego z biblioteką, która pisała plik, jest sędzią o wiele surowszym, a Delphi ma taki w komplecie. Poniższa funkcja to kontrola do dorzucenia do własnego zestawu testów; TZipFile i TXMLDocument pochodzą z RTL Delphi, nie z HotXLS:

uses
  System.SysUtils, System.Classes, System.Zip,
  Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;

// Własny helper testowy: True, gdy content.xml z pakietu .ods
// wczytuje się w parserze DOM rozumiejącym przestrzenie nazw
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
  Zip: TZipFile;
  Bytes: TBytes;
  Stream: TBytesStream;
  Doc: IXMLDocument;
begin
  Error := '';
  Zip := TZipFile.Create;
  try
    Zip.Open(OdsFile, zmRead);
    Zip.Read('content.xml', Bytes);
  finally
    Zip.Free;
  end;
  Stream := TBytesStream.Create(Bytes);
  try
    Doc := TXMLDocument.Create(nil);
    try
      Doc.LoadFromStream(Stream);     // niepowiązany prefiks wywali się tu
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

Puszczaj ją na każdym .ods, który zapisuje twój kod, a dla plików z tabelami zrób krok dalej i porównaj po sparsowaniu poddrzewo tabeli wejścia i wyjścia: tag, posortowane atrybuty, tekst i dzieci, rekurencyjnie, z nazwami rozwiniętymi do URI przestrzeni nazw plus nazwy lokalnej. Takie porównanie przepuści przeliterowany inaczej prefiks i złapie niepowiązany, co jest dokładnie granicą między nieszkodliwym a zepsutym. Karm je plikami od każdego producenta, którego użytkownicy naprawdę używają. Dokument, który wiąże przestrzeń nazw table z innym prefiksem, deklaruje prefiks na samym elemencie tabeli albo wiąże go ponownie w <office:spreadsheet>, przetestuje reguły pomijania i shadowingu w sposób, którego pojedynczy plik LibreOffice nie wymusi

Czytnik i writer ODS, model tabel przestawnych dla XLSX i ODS oraz raport konwersji użyty wyżej to wszystko części HotXLS Delphi Excel Component; strona produktu opisuje pełne wsparcie odczytu i zapisu XLSX, XLS i ODS dla Delphi i C++Buildera