Artykuł techniczny

Odcisk wykresu HotXLS i offsety kotwicy w Delphi

Skoroszyt z dwoma wykresami przechodzi przez otwarcie i zapis, których nic nie edytuje, a na wyjściu mogą wylądować trzy różne rzeczy. Jeden wynik ma trzy części wykresu tam, gdzie źródło miało dwie. Drugi wciąż ma dwie, ale porównanie bajtów pokazuje, że każdy xl/charts/chartN.xml został przepisany, a kolory serii się rozjechały. Trzeci trzyma XML wykresów w nienaruszonym stanie, podczas gdy każdy wykres przeskoczył o kilka pikseli na linie siatki komórek. Excel otwiera wszystkie trzy bez monitu o naprawę. Każdy z tych objawów bierze się z jednej decyzji w ścieżce wczytywania i zapisu: jak arkusz znajduje swój rysunek, kiedy pobierany jest odcisk zmian wykresu i czy offsety kotwicy wewnątrz części rysunku są zapisywane z powrotem

Arkusz znajduje swój rysunek przez relacje

Objaw trzech części bierze się z loadera, który zgaduje nazwy części. W pakiecie XLSX arkusz wskazuje swój rysunek elementem <drawing r:id="…"/> (ECMA-376 Part 1 §18.3.1.36), a r:id rozwiązuje się przez część .rels arkusza na cel typu ../drawings/drawing1.xml. Excel z przypadku numeruje rysunki przewidywalnie, więc ręcznie pisany czytnik z czasem dopisuje sobie fallback: gdy relacji brakuje, spróbuj xl/drawings/drawing{n}.xml dla pozycji arkusza n i użyj go, jeśli archiwum go ma

// Skrót, który pojawia się w ręcznie pisanych czytnikach XLSX (nie wypuszczaj tego)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // rozwiązany z .rels arkusza, '' gdy brak
  if Result = '' then
  begin
    Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
    if Zip.IndexOf(Result) < 0 then
      Result := '';
  end;
end;

Zgadywanie działa dopóki pierwszy nie wypadnie arkusz bez wykresów. W skoroszycie, którego pierwszy arkusz nie ma rysunku ani w ogóle części .rels, podczas gdy drugi arkusz sięga po xl/drawings/drawing1.xml przez Target="../drawings/drawing1.xml", fallback wręcza arkuszowi 1 rysunek, do którego nigdy się nie odwoływał. chart1.xml jest parsowany dwa razy, po jednym razie na arkusz, a zapis wypisuje trzy części wykresu. Nazwy części w pakiecie OPC nie niosą znaczenia poza tym, które nadaje im graf relacji — to cały argument omówienia rozwiązywania relacji OPC w HotXLS

Jak arkusz znajduje swój rysunek w skoroszycie z dwoma wykresami: Sheet1 nie niesie relacji rysunku ani części rels, a Sheet2 sięga po xl/drawings/drawing1.xml przez swoją relację rysunku, więc loader zgadujący tę konwencjonalną nazwę z pozycji arkusza parsuje chart1.xml dwa razy i zapisuje trzy części wykresu — dlatego HotXLS wczytuje rysunek wyłącznie przez relację rysunku arkusza
Sheet1 nigdy nie odwoływał się do wykresu, więc graf relacji jest jedynym bezpiecznym źródłem celu rysunku, a zgadywana konwencjonalna nazwa zamienia skoroszyt z dwoma wykresami w zapis z trzema częściami

HotXLS przyjmuje ścisłe odczytanie. Rysunek arkusza jest wczytywany wyłącznie z celu relacji rysunku tego arkusza, a arkusz bez takiej relacji nie dostaje żadnego rysunku, cokolwiek leży w xl/drawings/. Część rysunku, do której nic się nie odwołuje, nigdy nie jest przypinana do arkusza

Co decyduje, czy nietknięty wykres jest kopiowany, czy budowany od nowa?

Decyduje o tym odcisk per wykres. Gdy PreserveUnsupportedParts ma wartość True w chwili Open (True to domyślne), HotXLS trzyma oryginalne bajty każdej części wykresu obok sparsowanego modelu wykresu. Potem serializuje model własnym pisarzem wykresów i zapisuje dwie liczby dla tego wyjścia: długość oraz 64-bitowy hash FNV-1a po jednostkach kodu UTF-16, ze standardową podstawą przesunięcia 14695981039346656037 i liczbą pierwszą 1099511628211. TXLSXChart.HasPreservedXml mówi, czy wykres niesie te oryginalne bajty

Przy zapisie model wykresu jest serializowany ponownie i obie liczby są porównywane. Równa długość i równy hash znaczą, że model jest dokładnie taki, jaki był przy imporcie, więc nic, co aplikacja mogła zmienić, się nie zmieniło. W tym przypadku, przy wyłączonym StrictOOXML, pisarz kopiuje skompresowany wpis wprost z archiwum źródłowego do wyniku pod nową nazwą części wykresu; bajty nie są ani dekodowane, ani ponownie deflowane. Gdy któraś liczba się różni, oryginalny XML jest zamiast tego scalany z modelem. Zmiana formatowania linii słupka błędu albo linii trendu też liczy się jako edycja, choć tego formatowania nie ma w serializowanym modelu

HotXLS pobiera odcisk wykresu przy imporcie, trzymając oryginalne bajty części wykresu i zapisując długość oraz 64-bitowy hash FNV-1a własnej serializacji modelu wykresu, a przy zapisie przebudowuje i porównuje obie wartości, więc zgodność odtwarza oryginalne bajty albo kopiuje skompresowany wpis, a niezgodność scala oryginalny XML z modelem
Odcisk jest tyle wart, ile moment, w którym go pobrano, a pobranie go zanim skończą się wszystkie przebiegi odzyskiwania gwarantuje hash, który nigdy nie zgodzi się z gotowym modelem

Scalenie to właściwa operacja dla wykresu, który ktoś edytował. Zachowuje te części oryginalnego XML, których model nie pokrywa, pozwala modelowi wygrywać dla serii, osi i grup wykresu, po czym reserializuje wynik. Czego nie potrafi, to odtworzyć oryginalnych bajtów. Notatka o edycji wykresów Excela bez utraty ChartML opisuje, co scalenie zachowuje; ten artykuł jest o warunkach, przy których nieedytowany wykres nigdy do niego nie trafia

uses System.SysUtils, lxHandleX;

procedure ResaveUntouched(const Src, Dst: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  S, C: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.PreserveUnsupportedParts := True;  // domyślne; musi obowiązywać przed Open
    Book.StrictOOXML := False;              // wyjście Strict przebudowuje każdy wykres
    if Book.Open(Src) <> 1 then
      raise Exception.Create('Cannot open ' + Src);
    for S := 1 to Book.Sheets.Count do      // Sheets[] jest indeksowane od 1
    begin
      Sheet := Book.Sheets[S];
      for C := 0 to Sheet.Charts.Count - 1 do  // Charts[] jest indeksowane od zera
        Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
          BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
    end;
    if Book.SaveAs(Dst) <> 1 then
      raise Exception.Create('Cannot save ' + Dst);
  finally
    Book.Free;
  end;
end;

Gdy jakiś wykres ma zostać przebudowany z modelu celowo, na przykład żeby pozbyć się formatowania, które niesie źródło, TXLSXChart.ClearPreservedXml wyrzuca zapisane bajty i ten wykres jest pisany wyłącznie z modelu

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

Bo odcisk pobrany jedno wołanie za wcześnie nigdy się nie zgodzi. XML wykresu rzadko da się przeczytać w jednym przebiegu. Parsowanie strumieniowe buduje serie, osie i grupy wykresu, a potem zestaw przebiegów następczych wyciąga z surowego tekstu wykresu szczegóły, które łatwiej odzyskać, gdy serie już istnieją: flagę <c:smooth> każdego <c:ser>, wartości srgbClr wypełnienia i linii znacznika, tryby przecięcia osi oraz style znaczników głównych i pomocniczych. Kolekcja serii w czasie przebiegu strumieniowego po prostu jeszcze nie istnieje, więc te przebiegi muszą przyjść po nim

Załóż, że odcisk jest pobierany na końcu przebiegu strumieniowego, zanim ruszą przebiegi następcze. Opisuje wtedy model bez flag smooth, bez kolorów znaczników i z domyślnymi znacznikami osi. Przy zapisie serializator działa na gotowym modelu i emituje <c:smooth val="1"/> plus odzyskane kolory. XML jest dłuższy, hash inny, i każdy wykres w każdym zachowanym skoroszycie przechodzi przez scalenie przy każdym zapisie. Na wykresie liniowym z wygładzonymi seriami i własnymi znacznikami przebudowane węzły <c:ser> zastępują oryginały — i stąd bierze się rozjechane kolory

HotXLS pobiera odcisk dopiero po zakończeniu wszystkich przebiegów odzyskiwania, więc hash opisuje model taki, jaki widzi go aplikacja po raz pierwszy. Te same dwie liczby są odtwarzane po udanym zapisie, tym razem względem pliku wyjściowego, i to drugie pobranie też startuje z w pełni sparsowanego modelu. Hash wykrywający zmiany trzeba pobrać po ostatnim przebiegu, który jeszcze może zmutować model. Pobrany wcześniej daje sprawdzian, który zawodzi przy każdym zapisie

Offsety kotwicy mieszkają w części rysunku

Objaw przyciągania do siatki nie ma nic wspólnego z XML wykresu. Pozycja wykresu na arkuszu jest zapisana w części rysunku, a typowa forma to xdr:twoCellAnchor (ECMA-376 Part 1 §20.5): narożnik from i narożnik to, każdy z col, colOff, row i rowOff. Offsety są w English Metric Units, 914400 na cal i 12700 na punkt, a Excel zapisuje niezerowe wartości zawsze, gdy wykres jest przeciągany albo skalowany myszą. Dla pliku, który deklaruje narożnik from w kolumnie 0, wierszu 0 z rowOff równym 19049, oraz narożnik to w kolumnie 8, wierszu 15 z colOff 247650 i rowOff 66674, wykres startuje 1,5 punktu pod górą wiersza 1 i kończy się nieco ponad ćwierć cala w głąb kolumny I

Anatomia narożników xdr:twoCellAnchor przykładowego wykresu: from trzyma col 0 i rowOff 19049, a to trzyma col 8, colOff 247650 i rowOff 66674 w EMU po 914400 na cal, a ponieważ pisarz emitujący zerowe offsety przyciąga wykresy do siatki, HotXLS zapisuje zaimportowane offsety z powrotem przy zapisie
Kotwica mieszka w części rysunku, a nie w części wykresu, więc odtwarzanie offsetów jest niezależne od odcisku, a wierny round-trip potrzebuje obu

Pisarz, który przeczyta te cztery liczby, a potem wyemituje <xdr:colOff>0</xdr:colOff> dla każdego narożnika, przenosi każdy wykres na linie siatki. Część wykresu może odtwarzać się bajt w bajt, a wykres i tak się przesunie, bo jedno i drugie mieszka w różnych częściach. HotXLS trzyma rodzaj kotwicy wczytany z pliku (twoCellAnchor, oneCellAnchor albo absoluteAnchor, wystawiony jako PhysicalAnchorKind) razem z wartościami EMU i zapisuje je bez zmian. Offsety są dostępne do odczytu jako FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU i ToOffsetYEMU, a przeliczniki jednostek stojące za nimi są rozpracowane w notatce o geometrii obrazów HotXLS i skalowaniu EMU

procedure NudgeChart(Chart: TXLSXChart);
const
  EmuPerPoint = 12700;
begin
  if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
    Exit;
  Writeln(Format('from R%dC%d +(%d, %d) EMU  to R%dC%d +(%d, %d) EMU',
    [Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
     Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
  // Podmień całą ramkę jednym wywołaniem: wiersze i kolumny liczone od 1,
  // offsety to EMU wewnątrz komórki, a ostatni argument to zachowanie editAs
  Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
    Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
    Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;

SetTwoCellAnchor używaj, gdy intencją jest przesunięcie albo zmiana rozmiaru. Przypisanie FromRow, FromCol, ToRow albo ToCol na wykresie dwukomórkowym zmienia tylko ten indeks komórki i zostawia offsety na miejscach — to właściwe zachowanie dla drobnej korekty i niewłaściwe dla stawiania wykresu od zera. SetTwoCellAnchor waliduje ramkę w całości i rzuca ERangeError, gdy narożnik końcowy wyprzedza startowy albo offsety wypadają poza zakres EMU Excela. Wykres utworzony przez AddChart startuje z zerowymi offsetami, więc leży dokładnie na granicach komórek, dopóki sam mu jakichś nie dasz

Dowodzenie round-tripu porównaniem bajtów

Otwarcie wyniku w Excelu niewiele tu dowodzi. Excel normalizuje przy wczytaniu tyle, że reserializowany wykres wygląda identycznie aż do momentu, gdy ktoś zauważy zmieniony kolor znacznika, a przeskok o kilka punktów do siatki łatwo przegapić na ekranie. Sprawdzenie, które warto zautomatyzować, porównuje części wykresów bajt w bajt po otwarciu i zapisie bez żadnych edycji, a do tego wystarczy 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;

Kilka warunków decyduje o tym, czy niezgodność wykryta przez to sprawdzenie coś znaczy:

  • PreserveUnsupportedParts musi mieć wartość True przed Open; bez tego żadne oryginalne bajty nie są trzymane i każdy wykres jest pisany z modelu
  • StrictOOXML musi mieć wartość False, bo wyjście Strict z założenia przebudowuje wykresy. W każdym potoku, który obiecuje, że nieedytowane wykresy przeżyją zapis, trzymaj je wyłączone
  • Pakiet źródłowy zapisany w nazwach przestrzeni Strict OOXML jest przy zapisie przejściowym konwertowany na przestrzenie przejściowe, więc bajty wykresów się różnią, choć nic innego się nie zmieniło
  • Kod między Open a SaveAs może czytać właściwości wykresu do woli, ale każdy setter zmieniający model zmienia odcisk i wysyła ten wykres na ścieżkę scalenia — to poprawne zachowanie i poza zakresem tego testu
  • Części wykresów są przy zapisie przenumerowywane licznikiem obejmującym 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; sprawdzian na ten przypadek idzie za relacjami rysunków, a nie za nazwami

Kotwice potrzebują porównania po wartości. Część rysunku jest pisana od nowa wokół trzymanych wykresów, więc jej bajty to zły kandydat do diffowania; ponowne otwarcie obu plików i porównanie FromOffsetXEMU przez ToOffsetYEMU wykres po wykresie wyłapie pisarza, który zamienia 247650 na 0. Sprawdzenie strukturalne, które przechodzi relacje arkuszy, rysunków i wykresów i wywala się na każdym zduplikowanym, osieroconym albo wiszącym odwołaniu do wykresu, łapie przypadek trzech części, zanim porównanie bajtów w ogóle ruszy

To wszystko biegnie w natywnym kodzie Delphi i C++Buildera bez instalacji Excela na maszynie, więc skoroszyt wracający z zadania wsadowego jest jedynym dowodem tego, co stało się z jego wykresami. Licencjonowanie, pełne API wykresów i wersja próbna są na stronie komponentu arkuszy HotXLS dla Delphi