Artykuł techniczny

Odcisk wykresu HotXLS i offsety kotwicy w Delphi

HotXLS Delphi Component odtwarza niezmodyfikowany wykres Excela bajt w bajt tylko wtedy, gdy spełnione są dwa warunki: do wykresu dotarto przez relację rysunku arkusza, a nie przez zgadywaną nazwę części, oraz 64-bitowy odcisk modelu został pobrany po zakończeniu parsowania modelu wykresu. Wersja 2.382.0 naprawiła pierwszy warunek, wersja 2.382.3 drugi — i zaczęła odtwarzać niezerowe offsety kotwicy xdr:colOff i xdr:rowOff, które zapisujący rysunek kodował na sztywno jako zero. Oba defekty wyszły z jednego lokalnego przypadku korpusu, two-charts.xlsx: najpierw asercja strukturalna zobaczyła, że dwie części wykresu zamieniają się w trzy, a potem porównanie bajtów każdego xl/charts/chartN.xml pokazało, że wykresy, których nikt nie tknął, wciąż są przepisywane — i żaden z tych problemów nie zgłosił wyjątku ani nie wywołał skargi Excela, dlatego przetrwały tak długo

Dlaczego skoroszyt z dwoma wykresami wracał z trzema częściami wykresu?

Bo loader miał fallback, który zgadywał. Gdy arkusz nie miał relacji rysunku w swojej części .rels, stary kod zakładał, że rysunek leży pod konwencjonalną nazwą xl/drawings/drawing{i+1}.xml, gdzie i to pozycja arkusza, i podpinał tę część, jeśli istniała w archiwum. W two-charts.xlsx pierwszy arkusz nie ma rysunku ani w ogóle części .rels, natomiast xl/drawings/drawing1.xml istnieje — należy do drugiego arkusza, który sięga po niego przez Target="../drawings/drawing1.xml". Arkusz 1 odziedziczył więc wykres, do którego nigdy się nie odwoływał, chart1.xml został sparsowany dwa razy, a zapis wyeksportował skoroszyt z trzema częściami wykresu zamiast dwóch

Jak HotXLS rozwiązuje rysunki arkuszy w próbce two-charts: Sheet1 nie ma relacji rysunku ani części rels, a Sheet2 sięga po xl/drawings/drawing1.xml przez ParPartTargets, a fallback sprzed 2.382.0 zgadywał tę konwencjonalną nazwę z pozycji arkusza, więc chart1.xml był parsowany dwa razy i zapisy tworzyły trzy części wykresu, dopóki poprawka nie zaczęła wczytywać rysunków wyłącznie przez XlsxRtDrawing
Sheet1 nigdy nie odwoływał się do wykresu, więc graf relacji jest jedynym bezpiecznym źródłem celu rysunku, a zgadywana konwencjonalna nazwa zamieniła skoroszyt z dwoma wykresami w zapis z trzema częściami

Poprawka w HotXLS v2.382.0 usunęła zgadywanie całkowicie. Rysunek arkusza jest teraz wczytywany wyłącznie przez ParPartTargets[i].Values[XlsxRtDrawing] — cel zarejestrowany dla typu relacji rysunku na tym arkuszu — a arkusz bez takiej relacji nie dostaje żadnego rysunku. Tego właśnie wymaga format: element <drawing r:id="…"/> w arkuszu (ECMA-376 Part 1 §18.3.1.36) to jedyne ogniwo między arkuszem a jego rysunkiem, a nazwy części w pakiecie OPC nie niosą żadnego znaczenia poza tym, które nadaje im graf relacji. Archiwa zapisywane przez Excela przypadkiem używają nazw konwencjonalnych i to pozwalało temu skrótowi przechodzić tak długo; omówienie rozwiązywania relacji OPC w HotXLS wyjaśnia, dlaczego zgadywanie nazwy części nigdy nie jest bezpieczne, nawet gdy zwykle trafia

// Przed v2.382.0: brakująca relacja rysunku spadała na zgadywanie
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // może należeć do innego arkusza

// Od v2.382.0: relacja albo nic
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Co gwarantuje odcisk wykresu?

Odcisk decyduje, osobno dla każdego wykresu, czy zapis może skopiować oryginalną część, czy musi ją wygenerować od nowa. Przy imporcie, gdy PreserveUnsupportedParts jest włączone przed Open, HotXLS trzyma surowe bajty UTF-8 każdej części wykresu w FRawChartXml, buduje własną serializację modelu typowanego przez BuildChartKnownXml i zapisuje długość tej serializacji w FRawChartModelLength, a jej hash w FRawChartModelHash. Hash to FNV-1a po jednostkach kodu UTF-16 wygenerowanego XML, ze standardową 64-bitową podstawą przesunięcia 14695981039346656037 i liczbą pierwszą 1099511628211. Przy zapisie XlsxChartRawModelUnchanged przebudowuje znany XML i porównuje długość oraz hash; zgodność oznacza, że model typowany jest dokładnie taki, jaki był przy imporcie, więc nic, co aplikacja mogłaby zmienić, się nie zmieniło

HotXLS pobiera odcisk wykresu przy imporcie, trzymając surowe bajty UTF-8 w FRawChartXml, podczas gdy BuildChartKnownXml daje FRawChartModelLength i hash FNV-1a, a przy zapisie XlsxChartRawModelUnchanged przebudowuje model i porównuje obie wartości, więc zgodność odtwarza oryginalne bajty albo kopiuje skompresowany wpis, a niezgodność spada do XlsxMergeChartXml
Odcisk jest tyle wart, ile moment, w którym go pobrano, a pobranie go przed zakończeniem wszystkich przebiegów odzyskiwania gwarantuje hash, który nigdy więcej nie zgodzi się z gotowym modelem
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // nic nie zachowano
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // dosłowne odtworzenie
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // scalanie strukturalne
end;

Zapisujący XLSX idzie o krok dalej niż BuildChartXmlFromKnown. Gdy model się nie zmienił, a StrictOOXML jest wyłączone, najpierw próbuje skopiować skompresowany wpis wprost z archiwum źródłowego do wyniku pod nową nazwą części wykresu, więc bajty nie są nawet dekodowane i kompresowane ponownie. Dopiero gdy ta kopia jest niemożliwa, schodzi na ścieżkę dekodowania albo scalania. Sam mechanizm — długość plus hash, odtworzenie przy zgodności, scalenie przy jej braku — to ten opisany w notatce o edycji wykresów Excela bez utraty ChartML. Ten artykuł jest o tym, jak po cichu przestał działać

Dlaczego każdy wykres i tak trafiał na ścieżkę scalania?

Bo odcisk był pobierany o jedno wywołanie za wcześnie. Parsowanie wykresu w HotXLS to przebieg SAX po części wykresu, po którym idzie zestaw przebiegów odzyskiwania wyciągających z surowego tekstu szczegóły, których handlery SAX nie modelują bezpośrednio: XlsxChartParseSeriesFlags czyta każdy blok <c:ser> pod kątem jego flagi <c:smooth> oraz wartości srgbClr wypełnienia i linii znacznika, a następnie odzyskuje tryby przecięcia osi i style znaczników głównych i pomocniczych dla osi kategorii i osi wartości. Przed v2.382.3 kolejność na końcu ParseChartXml była taka: sklasyfikuj grupy osi, zbuduj znany XML, pobierz długość i hash, a dopiero potem uruchom XlsxChartParseSeriesFlags. Odcisk opisywał więc model, w którym wciąż brakowało flag smooth, kolorów znaczników i znaczników osi. Przy zapisie BuildChartKnownXml działał już na gotowym modelu, który emitował <c:smooth val="1"/> i odzyskane kolory znaczników. Dłuższy XML, inny hash, XlsxChartRawModelUnchanged zwracał False, a wykres szedł przez XlsxMergeChartXml. Scalenie to poprawna operacja dla wykresu, który ktoś edytował, ale nie jest operacją zachowującą bajty: reserializuje drzewo, a reguła własności, która pozwala modelowi typowanemu wygrywać dla serii, osi i grup wykresu, sprawia, że wygenerowane na nowo węzły zastępują oryginały. Widocznym skutkiem w przebiegu korpusu były rozjechane kolory serii na wykresach, których nikt nie edytował — na każdym wykresie w każdym zachowanym skoroszycie, przy każdym zapisie, bez żadnej diagnostyki gdziekolwiek

Naprawa to jedno przestawienie: XlsxChartParseSeriesFlags działa teraz przed zbudowaniem znanego XML, więc odcisk opisuje model taki, jaki będzie, gdy aplikacja zobaczy go po raz pierwszy. Wniosek uogólnia się poza wykresy. Odcisk wykrywający zmiany jest tyle wart, ile moment, w którym go pobrano, a bezpieczny moment to chwila po zakończeniu każdego przebiegu, który może zmutować model. HotXLS ma drugie miejsce pobrania tych samych dwóch wartości — punkt odniesienia odtwarzany względem pliku wyjściowego po udanym zapisie — i to miejsce zawsze działało na w pełni sparsowanym modelu; to przy imporcie było wyjątkiem

Gdzie zniknęły offsety kotwicy?

W dosłowne zero. Element twoCellAnchor w części rysunku przypina wykres między dwiema komórkami, a każdy narożnik niesie indeks komórki plus przesunięcie wewnątrz tej komórki: from (ECMA-376 Part 1 §20.5.2.5) i to (§20.5.2.32) trzymają odpowiednio col, colOff (§20.5.2.4), row i rowOff. Przesunięcia są w English Metric Units, 914400 na cal, a Excel zapisuje niezerowe wartości zawsze, gdy wykres został umieszczony albo przeskalowany myszą — czyli w większości wykresów. Pierwszy wykres w two-charts.xlsx zaczyna się w wierszu 0 z rowOff równym 19049, a kończy w kolumnie 8, wierszu 15 z colOff 247650 i rowOff 66674 — jakieś ćwierć cala w głąb ostatniej kolumny. Parser rysunku w HotXLS zawsze te cztery wartości czytał — korzystał z nich kod obrazów — ale zapisujący wykres emitował <xdr:colOff>0</xdr:colOff> i <xdr:rowOff>0</xdr:rowOff> dla każdego narożnika, przyciągając każdy wykres do siatki komórek przy zapisie

Anatomia narożników xdr:twoCellAnchor pierwszego wykresu z próbki HotXLS: from trzyma col 0 i rowOff 19049, a to trzyma col 8, colOff 247650 i rowOff 66674 w EMU, gdzie cal to 914400, a zapisujący, który emitował zerowe offsety, przyciągał wykresy do siatki, dopóki FFromColOff, FToColOff i ich odpowiedniki nie zaczęły odtwarzać zaimportowanych wartości
Kotwica mieszka w części rysunku, a nie w części wykresu, więc ta poprawka jest niezależna od naprawy odcisku i obie musiały wyjść, zanim skoroszyt naprawdę zaczął robić round-trip
// Od v2.382.3 zapisujący kotwicę odtwarza zaimportowane offsety EMU
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart niesie teraz FFromColOff, FFromRowOff, FToColOff i FToRowOff, wypełniane przez parser rysunku i kopiowane razem z pozostałym stanem kotwicy, gdy wykres jest przypisywany. Są celowo prywatne: publiczna powierzchnia kotwicy to wciąż cztery współrzędne komórek FromRow, FromCol, ToRow i ToCol, a wykres tworzony z kodu Delphi ląduje na granicach komórek jak dotąd. Offsety istnieją po to, żeby round-trip był wierny, a nie po to, żeby wystawiać pozycjonowanie podkomórkowe jako funkcję. Warto zauważyć, że ta poprawka jest niezależna od odcisku: kotwica mieszka w części rysunku, nie w części wykresu, więc wykres, którego ChartML odtwarzał się bezbłędnie, i tak skakałby do siatki bez niej. Przeliczniki jednostek stojące za tymi wartościami EMU są omówione w notatce o geometrii obrazów HotXLS i skalowaniu EMU

Jak udowodnić, że wykres robi round-trip bez zmian?

Przez porównywanie bajtów, a nie przez otwieranie wyniku w Excelu. Excel przy wczytaniu tak dużo naprawia i normalizuje, że rozjechany wykres wygląda dobrze aż do momentu, gdy analityk zauważy zmieniony kolor znacznika. Test korpusu, który wyłapał oba defekty, robi trzy rzeczy po otwarciu i zapisie bez żadnych edycji: przechodzi relacje arkusza, rysunku i wykresu i przewraca się na każdym zduplikowanym, osieroconym albo wiszącym odwołaniu do wykresu; porównuje sygnaturę typu wykresu, formuł serii i geometrii kotwicy między oryginałem a wynikiem; a dla two-charts.xlsx czyta każdy xl/charts/chartN.xml z obu archiwów i wymaga identycznych bajtów. Ten sam test łatwo napisać w Delphi za pomocą TZipFile z RTL

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // zgłasza wyjątek, jeśli część zniknęła
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Trzy warunki czynią to porównanie sensownym i każdy z nich po cichu zawodzi, jeśli o nim zapomnisz. PreserveUnsupportedParts musi być True przed Open, inaczej żadne surowe bajty nie zostaną zachowane i każdy wykres będzie przebudowywany z modelu. StrictOOXML musi być False, bo tryb strict z założenia wymusza regenerację. A aplikacja nie może dotykać wykresu między otwarciem a zapisem — czytanie właściwości jest w porządku, ale każdy setter zmieniający model typowany przestawia odcisk i wysyła wykres na ścieżkę scalania, co jest poprawnym zachowaniem, ale nie tym, czego dotyczy ten test. Części wykresu są też przenumerowywane przy zapisie z licznika obejmującego cały skoroszyt, więc skoroszyt, w którym zmieniła się kolejność arkuszy albo wykresów, umieści identyczne bajty pod inną nazwą chartN.xml; właśnie dlatego sprawdzian korpusu idzie za relacjami, a nie za nazwami

Obie poprawki wyszły w HotXLS 2.382.0 i 2.382.3 i są zweryfikowane na Win32 i Win64 względem lokalnego korpusu, a zapisane ponownie próbki wykresów zostały dodatkowo wyrenderowane przez niezależny pakiet biurowy do PDF i porównane strona po stronie z oryginałami. HotXLS czyta, edytuje i zapisuje wykresy XLSX z natywnego kodu Delphi i C++Buildera bez instalacji Excela i to właśnie dlatego taki poziom wierności jest odpowiedzialnością biblioteki — strona komponentu HotXLS Delphi do arkuszy ma listę funkcji i wersję próbną do pobrania