HotXLS Delphi Excel Component zachowuje tabele przestawne OpenDocument przez cykl otwarcia i zapisu ODS, przechwytując przy otwarciu poddrzewo <table:data-pilot-tables> z content.xml dosłownie i odtwarzając je przy zapisie — od v2.382.0. Od v2.382.1 fragment niesie dodatkowo każde wiązanie przestrzeni nazw XML zadeklarowane przez jego przodków, więc zapisana definicja tabeli przestawnej pozostaje poprawna dla każdego odbiorcy, nie tylko dla HotXLS
Błąd, który wymusił obie zmiany, wyszedł ze ścisłego przebiegu korpusu. Próbka official-pivot.ods, zapisana przez developerską kompilację LibreOffice 6.1, trzyma jedną tabelę przestawną o nazwie DataPilot1, która czyta Sheet1.A2:E30 i umieszcza wynik w Sheet1.G6:J18. Otwórz ją w HotXLS, zapisz bez zmian, policz elementy <table:data-pilot-table> w wyniku: jedno wchodzi, zero wychodzi, tak samo na Win32 i Win64. Nic w teście nie tknęło tabeli przestawnej. Pierwsza runda prób porównywała tylko stałe komórek i przeszła; dopiero asercja strukturalna ujawniła tę stratę, co przypomina, że „wartości się zgadzają” to słaba definicja wierności round-tripu
Dlaczego tabela przestawna ODS znika po zapisie z biblioteki?
Tabela przestawna ODS znika, bo HotXLS nie ma modelu w pamięci dla tabel przestawnych OpenDocument, a zapisujący ODS buduje content.xml w całości z modelu. Składa style automatyczne, po jednym <table:table> na arkusz, <table:content-validations>, <table:named-expressions> i <table:database-ranges> — każde generowane z obiektów, które skoroszyt faktycznie trzyma. Definicja tabeli przestawnej — ODF 1.3 Part 3 §9.6, kontener <table:data-pilot-tables> z jednym <table:data-pilot-table> na tabelę przestawną, niosący swój table:source-cell-range, swoje dzieci table:data-pilot-field, swój table:target-range-address i table:buttons — nie ma obiektu, w którym mogłaby zamieszkać, więc regenerowana część po prostu ją pomija
Kontrast z XLSX jest zamierzony. HotXLS parsuje cache i tabele przestawne SpreadsheetML do prawdziwego modelu, który możesz budować, rozszerzać o pola obliczane i odświeżać z Delphi, więc tamte przetrwają zapis, bo są zapisywane od nowa, a nie kopiowane. Tabele przestawne ODS to znacznie rzadsze życzenie, a modelowanie słownika tabel przestawnych ODF wyłącznie na potrzeby round-tripu byłoby sporą ilością kodu, którego nikt nie edytuje. Pragmatyczna odpowiedź jest ta sama, którą HotXLS stosuje już do nieznanych bloków extLst w XLSX: zachowaj to, czego nie modelujesz — bajt w bajt, jeśli potrafisz, zdarzenie po zdarzeniu, jeśli nie
Co pierwsze przechwytywanie oparte na Pos zrobiło źle?
Przechwytywanie z v2.382.0 wycinało definicję tabeli przestawnej z content.xml jako zwykły łańcuch i w tym wycinku brakowało deklaracji przestrzeni nazw, które czyniły go sensownym. Implementacja była tak krótka, jak brzmi — zdekoduj część do WideString, znajdź tag otwierający przez Pos, znajdź za nim tag zamykający, skopiuj przedział do FRawOdsDataPilotTablesXml w skoroszycie:
// HotXLS v2.382.0 -- zastąpione o jedno wydanie później
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); // cały content.xml w pamięci
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;
Asercja na liczbę elementów przeszła na zielono i poprawka wyszła. Wychwyciło ją drugie, ostrzejsze sprawdzenie dodane tego samego dnia: każda część XML zapisanego pakietu trafia do niezależnego parsera rozumiejącego przestrzenie nazw, stojącego poza HotXLS, a ten parser odrzucił nowy content.xml błędem niepowiązanego prefiksu. Tabela przestawna z LibreOffice niesie atrybuty rozszerzeń producenta — loext:ignore-selected-page="true" na polu strony, calcext:repeat-item-labels="false" na każdym poziomie — a wycięty łańcuch zawierał te atrybuty, ale nie deklaracje xmlns:loext i xmlns:calcext, które je wiązały. Te deklaracje siedziały w pliku źródłowym na korzeniu <office:document-content>, trzydzieści pięć z nich, dwa tysiące znaków od tabeli przestawnej
W3C Namespaces in XML 1.0 §6.1 definiuje regułę, która czyni z tego twardy błąd, a nie kosmetyczny: deklaracja przestrzeni nazw obowiązuje od tagu początkowego elementu, na którym występuje, do tagu końcowego tego elementu, a każda nazwa z prefiksem w tym zakresie rozwiązuje się względem niej. Wytnij poddrzewo z dokumentu, a wytniesz je także z tego zakresu. HotXLS zapisuje własny korzeń <office:document-content> z jedenastoma deklaracjami — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — więc calcext: przypadkiem się rozwiązywał, table: też, a loext: nie. Parser rozumiejący przestrzenie nazw traktuje niepowiązany prefiks jako naruszenie poprawności składniowej, co oznacza, że nieczytelna jest cała część, a nie tylko jeden atrybut
Jak HotXLS przenosi wiązania xmlns przodków na fragment?
HotXLS v2.382.1 zastąpił wycinanie łańcucha przebiegiem po content.xml przez własny strumieniowy TXMLReader, utrzymując stos wiązań przestrzeni nazw oznaczonych głębokością, na której każde zostało zadeklarowane, i kopiując wiązania wciąż obowiązujące na element główny fragmentu w momencie dotarcia do celu. Reader działa z włączonym PreserveWhitespaceText, żeby węzły tekstowe wracały dokładnie takie, jak zapisano, a odbudowane tagi używają TXMLReader.RawName i TXMLReader.Attribute[I].RawName — czyli pisowni prefiksów z pliku — a nie nazw kanonicznych, które reader zwykle podaje parserom części. Oto rdzeń pętli:
// Namespaces: TStringList par 'xmlns:p=uri' z głębokością deklaracji w Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // element, tekst, CDATA, komentarz
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); // najpierw zdejmij końcowe '>' albo '/>'
...
// Przenieś obowiązujące wiązania przodków na korzeń fragmentu.
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // wygrywa najbardziej wewnętrzne wiązanie
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // już tu zadeklarowane? pomiń
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; // poddrzewo zamknięte
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); // wyjdź z zakresu
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Poprawność w tej pętli trzymają trzy szczegóły. Przejście stosu od najbardziej wewnętrznego wiązania na zewnątrz i zapamiętywanie każdego prefiksu w Seen realizuje przesłanianie: jeśli bliższy przodek wiąże na nowo xmlns:table, wygrywa bliższa wartość, dokładnie tak, jak wymaga tego §6.1. Pomijanie prefiksów, które element deklaruje sam, zapobiega wyemitowaniu tego samego atrybutu dwa razy, co byłoby innym naruszeniem poprawności składniowej. A reguła zdejmowania działa na tagach końcowych oraz na elementach pustych, bo <x/> nigdy nie produkuje zdarzenia EndElement — ta sama pułapka samozamykającego się elementu, której musiało nauczyć się przechwytywanie extLst w XLSX. Dopasowanie celu przez Reader.Name, a nie RawName, to cichszy zysk: reader kanonikalizuje URI przestrzeni nazw tabel ODF do prefiksu table, więc producent, który pisze t:data-pilot-tables, wciąż pasuje, a emitowany fragment zachowuje prefiks użyty przez producenta
Pętla nie zgaduje też. Jeśli część skończy się, gdy przechwytywanie jest wciąż otwarte — ucięty albo zniekształcony content.xml — OdsCaptureDataPilotTablesXml zgłasza wyjątek zamiast zwrócić połowiczny fragment, bo połowiczny fragment zostałby zapisany z powrotem przy zapisie i zamienił uszkodzone wejście w uszkodzone wyjście z nazwą biblioteki na grzbiecie
Gdzie ten fragment ląduje w zapisanym content.xml?
HotXLS zapisuje przechwycony fragment do <office:spreadsheet> zaraz po generowanym <table:named-expressions> i przed <table:database-ranges>. Model treści <office:spreadsheet> z ODF 1.3 Part 3 narzuca stałą kolejność tych końcowych dzieci, więc dosłownego bloku nie można po prostu dopiąć tam, gdzie akurat stoi zapisujący; trzeba go wstawić w konkretne miejsce. Z punktu widzenia wołającego nie ma żadnego API ani nic do skonfigurowania; definicja jedzie razem ze zwykłym otwarciem i zapisem:
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; // edycja wewnątrz zakresu źródłowego tabeli przestawnej
Book.SaveAsODS('official-pivot-out.ods');
// content.xml w wyniku wciąż niesie DataPilot1 z jego
// zakresem źródłowym, polami, zakresem docelowym, przyciskami i atrybutami loext:/calcext:
finally
Book.Free;
end;
end;
Nadmiarowość jest zamierzona i warto o niej wiedzieć. Korzeń fragmentu powtarza teraz xmlns:table i xmlns:calcext, choć zapisany korzeń dokumentu też je deklaruje; Namespaces in XML pozwala zadeklarować prefiks na nowo w zagnieżdżonym zakresie, więc duplikaty są nieszkodliwe. Dla próbki z LibreOffice przenoszony zbiór to wszystkie trzydzieści pięć deklaracji korzenia, jakieś dwa kilobajty ponad definicję długości 8357 znaków, bo przechwytywanie nie analizuje, których prefiksów poddrzewo faktycznie używa. Skan użytych prefiksów by to przyciął i może przyjść później; najpierw poprawność, potem zwięzłość
Reguła wycinania poddrzew z XML do dosłownego odtworzenia
Ogólny wniosek jest taki, że poddrzewo jest samowystarczalne tylko wtedy, gdy sam je takim zrobisz, a zakres przestrzeni nazw to pierwsza rzecz, która się psuje, gdy o tym zapomnisz. Lista kontrolna, którą HotXLS stosuje teraz do każdego przechwytywania w duchu „zachowaj to, czego nie modelujesz”:
- Przejdź dokument prawdziwym readerem i śledź wiązania w zakresie. Wyszukiwanie łańcuchów przez
Posw ogóle nie widzi zakresu, a dodatkowo myli się na zagnieżdżonych elementach o tej samej nazwie, na pasującym tekście wewnątrz komentarza albo sekcjiCDATAi na wartościach atrybutów, które przypadkiem zawierają tekst tagu - Skopiuj obowiązujące wiązania na korzeń fragmentu, od najbardziej wewnętrznego, raz na prefiks, pomijając to, co korzeń już deklaruje
- Zachowaj surową pisownię prefiksów w emitowanych tagach; dopasowuj cel po rozwiązanej przestrzeni nazw, a nie po literalnym prefiksie
- Zachowuj węzły tekstowe z białymi znakami i pamiętaj, że element pusty zamyka swój zakres bez zdarzenia tagu końcowego
- Waliduj zapisaną część parserem, który nie jest testowaną biblioteką. Biblioteka z ochotą wczyta własny wynik tą samą pobłażliwą ścieżką kodu, która go zapisała
Ostatni punkt to ten, który faktycznie znalazł HXLS-003 za drugim razem. Sprawdzenie akceptacyjne z v2.382.0 było wyrażeniem regularnym liczącym tagi początkowe data-pilot-table w zapisanym content.xml, a wyrażenie regularne widzi tag, nie dokument — jest ślepe na to, czy prefiksy w tym tagu są powiązane. Ścisły runner korpusu dodany w v2.382.1 parsuje każdą część XML i każdy element .rels zapisanego pakietu parserem rozumiejącym przestrzenie nazw, a potem porównuje drzewo tabeli przestawnej — tag, posortowane atrybuty, tekst, dzieci, rekurencyjnie — z oryginałem. To porównanie jest rozwijane o przestrzenie nazw, więc inna pisownia prefiksu i tak by przeszła, a niepowiązany prefiks nie ma szans
Gdzie kończy się gwarancja dosłowności
Dosłowne odtworzenie zachowuje definicję; nie rozumie jej, i stąd wynikają granice. HotXLS nie wystawia żadnego API do czytania, edycji ani odświeżania tabeli przestawnej ODS, więc FRawOdsDataPilotTablesXml to pole wewnętrzne, a jedynym obserwowalnym zachowaniem jest to, że definicja przeżywa. Fragment jest serializowany ponownie ze zdarzeń readera, a nie kopiowany jako bajty: cudzysłowy atrybutów i formy samozamykające się są normalizowane, natomiast tekst i białe znaki zostają. Przechwycony XML emituje wyłącznie zapisujący treść ODS, więc skoroszyt otwarty z .ods i zapisany jako .xlsx traci tabelę przestawną, a skoroszyt otwarty z .xlsx nie ma czego odtworzyć przy zapisie do .ods — asymetrie ścieżek importu i eksportu ODS obowiązują tu tak jak wszędzie. A ponieważ definicja jest nieprzezroczysta, nie nadąży za twoimi edycjami: zmień nazwę Sheet1 albo przenieś dane źródłowe w HotXLS, a zapisana tabela przestawna wciąż wskazuje Sheet1.A2:E30, zostawiając odbiorcy zgłoszenie o zepsutym zakresie przy następnym odświeżeniu. Należy się tu też jedno zastrzeżenie o kolejności: HotXLS emituje zakresy AutoFilter jako <table:database-ranges> po fragmencie tabeli przestawnej, a próbka w korpusie nie niesie żadnego zakresu bazy danych, więc skoroszyt z filtrem i tabelą przestawną zarazem warto przepuścić przez walidator schematu ODF, zanim zaczniesz polegać na względnej kolejności tych dwóch elementów
Testuj na plikach własnego producenta, nie tylko na próbce z korpusu. Przenoszenie przestrzeni nazw obsłuży każdy prefiks, który producent deklaruje na przodku, ale dokument deklarujący prefiks na samym elemencie tabeli przestawnej albo używający domyślnej przestrzeni nazw dla słownika tabel ćwiczy gałęzie pomijania i przesłaniania, których próbka z LibreOffice nie dotyka. Obie są zaimplementowane; żadna nie ma jeszcze próbki w korpusie, a ta różnica to dokładnie ten rodzaj szczegółu, który wpis w changelogu zwykle rozmywa
Dosłowne przechwytywanie tabel przestawnych z v2.382.0 i poprawka zakresu przestrzeni nazw z v2.382.1 są w bieżącym HotXLS Delphi Excel Component, którego strona produktu wymienia pełne pokrycie odczytu i zapisu ODS, XLSX i XLS dla Delphi i C++Buildera