Artykuł techniczny

Rekordy Selection i przewijanie okienek BIFF8 w HotXLS

HotXLS przechowuje zaznaczenia arkusza i pozycje przewinięcia per okienko przez jedno API świadome okienek, wspólne dla TXLSWorksheet i TXLSXWorksheet: SelectAreas, GetSelectedAreas, ScrollWindow i TryGetWindowScroll. Dla klasycznych plików .xls HotXLS zapisuje rekordy BIFF8 Selection (0x001D) po najwyżej 1369 obszarów każdy, konwertuje logiczne nazwy okienek na bajty okienek zdefiniowane w formacie i trzyma każdą oś przewijania na tym rekordzie Window2 albo Pane, po którym Excel się jej spodziewa

Problem zwykle wychodzi w narzędziu do uzgadniania albo audytu. Narzędzie otwiera eksport księgi, znajduje każdą komórkę, która rozmija się z systemem źródłowym, i zapisuje skoroszyt z tymi komórkami już zaznaczonymi pod zamrożonym wierszem nagłówka, żeby recenzent wylądował prosto na różnicach zamiast szukać ich scrollowaniem. Przy czterdziestu różnicach działa bez zarzutu. Plik na koniec miesiąca ma ich 3 000, a pojedynczy rekord Selection z 3 000 obszarów nie może istnieć: jego ciało musiałoby mieć 18 009 bajtów, ponad dwa razy więcej niż uniesie jedno ciało rekordu BIFF8. Pozycja przewinięcia ma podobną pułapkę. W arkuszu z zamrożonymi okienkami „to, na co patrzył użytkownik” to cztery okienka dzielące dwa położenia wierszy i dwa położenia kolumn, a nie jedna współrzędna

Dlaczego duże zaznaczenie potrzebuje więcej niż jednego rekordu Selection?

Duże zaznaczenie potrzebuje kilku rekordów, bo ciało rekordu BIFF8 ma limit 8224 bajtów, a każdy zaznaczony obszar kosztuje sztywne sześć bajtów. [MS-XLS] §2.4.248 rozkłada rekord Selection na 9-bajtową część stałą (bajt okienka, rwAct i colAct komórki aktywnej, irefAct obszaru aktywnego i cref — liczba obszarów), po której następuje cref struktur RefU, każda z dwoma 16-bitowymi wierszami i dwiema 8-bitowymi kolumnami. Największa mieszcząca się liczba to (8224 − 9) / 6 z zaokrągleniem w dół, czyli 1369, co daje ciało o długości 8223 bajtów — jeden bajt pod limitem. TXLSWorksheet.StoreSelectionGroup używa tej stałej jako MaxAreasPerRecord i zapisuje większą grupę jako kolejne rekordy Selection dla tego samego okienka, po 1369 obszarów na raz

Boli szczegół zwany irefAct. Każda porcja powtarza ten sam aktywny wiersz, aktywną kolumnę i indeks aktywnego obszaru, a irefAct indeksuje zagregowaną sekwencję wszystkich porcji, nie obszary wewnątrz rekordu, który go niesie. Zaznaczenie o jeden obszar za limitem pokazuje to na konkretach: 1370 obszarów z ostatnim aktywnym daje dwa rekordy — pierwszy z cref 1369, drugi z cref 1 — i oba niosą irefAct 1369. Ta wartość jest większa niż własna liczba obszarów drugiego rekordu. Reader, który sprawdza irefAct względem cref w każdym rekordzie osobno, odrzuci poprawny plik, a reader, który przy każdym rekordzie nadpisuje swój stan, zgubi pierwsze 1369 obszarów. Reader HotXLS dokleja kolejne rekordy tego samego okienka do jednej grupy, wymaga, by każda porcja zgadzała się co do komórki aktywnej i indeksu, i uruchamia kontrolę zakresu dopiero na rekordzie EOF arkusza, gdy znana jest już cała sekwencja. Overload SelectAreas przyjmujący okienko jako pierwszy argument nie ma więc sufitu 1369 obszarów. Waliduje każdą referencję A1 i indeks aktywny, zanim weźmie blokadę zapisu arkusza, a gdy cokolwiek jest zniekształcone, zwraca False z nietkniętym poprzednim zaznaczeniem

Dlaczego HotXLS zapisuje duże zaznaczenie arkusza jako kilka rekordów BIFF8 Selection: limit ciała 8 224 bajty mieści 9 bajtów stałych plus 1369 sześciobajtowych obszarów RefU, więc 3 000 obszarów staje się trzema rekordami tego samego okienka — 1369, 1369 i 262 — a irefAct indeksuje sekwencję zagregowaną, więc 1370 obszarów z ostatnim aktywnym daje obu rekordom irefAct 1369
Każda porcja powtarza tę samą komórkę aktywną i indeks, reader HotXLS dokleja kolejne rekordy tego samego okienka do jednej grupy, a kontrola zakresu rusza dopiero na rekordzie EOF, gdy znana jest cała sekwencja
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // wiersz nagłówka i kolumna A zostają na miejscu

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // Zamrożenie resetuje zapisane zaznaczenie, więc zaznaczaj po zamrożeniu.
    // 3000 obszarów zapisuje się jako trzy rekordy Selection: 1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

Jakiego bajtu okienka używa rekord Selection?

Rekord Selection identyfikuje swoje okienko numerycznym kodem zdefiniowanym w formacie: 0 dla prawego dolnego, 1 dla prawego górnego, 2 dla lewego dolnego i 3 dla lewego górnego. Publiczna enumeracja TXLSPanePosition jest zadeklarowana w kolejności czytania — xlspTopLeft, xlspTopRight, xlspBottomLeft, xlspBottomRight — więc Ord(xlspTopLeft) to 0, czyli w pliku okienko prawe dolne. Rzucenie enuma prosto na bajt okienka zapisze każde zaznaczenie lewego górnego na okienku prawym dolnym bez żadnego błędu. Każdy punkt wejścia HotXLS świadomy okienek konwertuje enuma przez jawną instrukcję case, więc wywołujący w ogóle nie styka się z kodami numerycznymi. Sprawdzane jest też istnienie okienka: prawe górne istnieje tylko przy pionowym podziale, lewe dolne tylko przy poziomym, a prawe dolne tylko przy obu. Dla okienka, którego bieżąca geometria podziału albo zamrożenia nie przewiduje, SelectAreas zwraca False, a GetSelectedAreas zwraca pustą tablicę z ActiveAreaIndex ustawionym na -1, bez tworzenia okienka, obiektu zaznaczenia czy komórki w skoroszycie

Jak HotXLS mapuje TXLSPanePosition na bajt okienka w rekordzie BIFF8 Selection: enum jest zadeklarowany w kolejności czytania, więc Ord(xlspTopLeft) to 0, podczas gdy plik definiuje 0 dla prawego dolnego, 1 dla prawego górnego, 2 dla lewego dolnego i 3 dla lewego górnego, więc każdy punkt wejścia świadomy okienek konwertuje przez jawną instrukcję case
Rzucenie enuma prosto na bajt okienka zapisałoby każde zaznaczenie lewego górnego na okienku prawym dolnym, więc HotXLS sprawdza przed zapisem także istnienie okienka względem bieżącej geometrii podziału albo zamrożenia

Gdzie mieszka pozycja przewinięcia każdego okienka?

Pozycja przewinięcia każdego okienka jest rozdzielona między dwa rekordy, bo cztery okienka dzielą między sobą tylko dwa położenia wierszy i dwa położenia kolumn. W klasycznym skoroszycie pierwszy widoczny wiersz okienek górnych i pierwsza widoczna kolumna okienek lewych to Window2.rwTop i Window2.colLeft, a wiersz okienek dolnych i kolumna okienek prawych to Pane.rwTop i Pane.colLeft. ScrollWindow(xlspTopRight, R, C) pisze więc Window2.rwTop i Pane.colLeft, a ustawienie kolumny prawego górnego przesuwa też prawy dolny — dokładnie tak, jak w Excelu oba dzielą jeden poziomy scrollbar. Publiczne metody używają numerów wierszy i kolumn liczonych od 1. Nieistniejące okienko zwraca False i zeruje oba wyjścia zapytania, a współrzędna poza zakresem jest odrzucana, zanim którakolwiek oś się ruszy. Nic tu nie zależy od tego, jak viewer maluje siatkę. Kontrolka renderująca trzyma swoje TopRow i LeftCol, jak opisuje artykuł o renderowaniu skoroszytów we własnej siatce VCL, i to jest stan runtime, nie to, co ląduje w pliku

Gdzie mieszka każda oś przewinięcia okienek w HotXLS: cztery okienka dzielą dwa położenia wierszy i dwa położenia kolumn, więc górny wiersz i lewa kolumna to Window2.rwTop i Window2.colLeft, dolny wiersz i prawa kolumna to Pane.rwTop i Pane.colLeft, a ScrollWindow(xlspTopRight, 1, 6) pisze jedno pole Window2 plus jedno pole Pane, więc prawy dolny podąża
XLSX rozkłada te same dane na atrybuty topLeftCell elementów sheetView i pane, a sklejenie tych dwóch warstw w jedną to dokładnie ten sposób, w jaki pozycja przewinięcia górnego albo lewego cicho znika przy wczytywaniu

XLSX rozkłada te same dane na dwa elementy: sheetView/@topLeftCell (ECMA-376 Part 1, §18.3.1.87) dla okna jako całości i podrzędny pane/@topLeftCell (§18.3.1.66) dla prawej dolnej strony podziału. Oba atrybuty mogą występować naraz. HotXLS czyta najpierw zewnętrzny atrybut do pól poziomu okna, pozwala potomkowi pane nadpisać wyłącznie pola poziomu okienka i zapisuje oba z powrotem osobno. Sklejenie tych dwóch warstw w jedną to dokładnie ten sposób, w jaki pozycja przewinięcia górnego albo lewego cicho znika przy wczytywaniu. Kopie arkuszy niosą obie warstwy w obu silnikach. Starsze punkty wejścia zachowują swoje pierwotne zachowanie: klasyczne właściwości ScrollRow i ScrollColumn oraz liczone od zera XLSX-owe SetPaneScroll i GetPaneScroll. Samą geometrię zamrożenia i podziału konfiguruje się ustawieniami poziomu arkusza, które opisuje tekst o ochronie arkusza, ustawieniach strony i drukowaniu

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // Prawy dolny: dolna oś wierszy (Pane.rwTop) i prawa oś kolumn (Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // Prawy górny dzieli prawą oś kolumn, więc to też przesuwa prawy dolny na kolumnę 6
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // Prawy dolny startuje od wiersza 500, kolumny 6
end;

Co się dzieje, gdy rekord Selection jest uszkodzony?

Gdy rekord Selection jest uszkodzony, HotXLS trzyma go jako nieprzejrzyste bajty, zgłasza kod diagnostyczny 1304 (xlsDiagnosticSelectionRecordInvalid) i przy zapisie odtwarza oryginalne ciało bajt w bajt. Zanim rekord dołączy do grupy swojego okienka, reader sprawdza go po kolei. Bajt okienka musi być mniejszy lub równy 3. Rekordy jednego okienka muszą leżeć w strumieniu obok siebie. 9 bajtów stałych musi być obecne. cref musi mieścić się między 1 a 1369, a ciało musi mieć dokładnie 9 + cref × 6 bajtów. Każda porcja w grupie musi zgadzać się co do komórki aktywnej i irefAct, irefAct nie może mieć ustawionego bitu znaku, aktywna kolumna musi leżeć na siatce, a żaden obszar nie może mieć zamienionych granic. Problemy w pojedynczym rekordzie fizycznym są zgłaszane raz na rekord. Sprzeczności widoczne dopiero po agregacji, jak irefAct wskazujący za łączną liczbę obszarów albo komórka aktywna poza indeksowanym obszarem, są zgłaszane raz na grupę przy EOF. Nieprawidłowa grupa pozostaje niewidzialna dla typowanego API: GetSelectedAreas zwraca dla tego okienka pustą tablicę z indeksem -1, a każde inne okienko dalej działa

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

Jak zaznaczenia przeżywają wstawianie wierszy i kolumn?

Zaznaczenia przeżywają zmiany strukturalne, bo wstawienie albo usunięcie pełnych wierszy lub kolumn przemapowuje każdą reprezentowaną grupę okienek w obu silnikach — klasycznym i XLSX — przez jeden wspólny remapper. Ocalałe obszary zachowują kolejność, a obszar aktywny zachowuje tożsamość. Gdy obszar aktywny zostanie usunięty, aktywny staje się pierwszy ocalały następnik, a gdy nic za nim nie stoi — ostatni ocalały poprzednik. Gdy usunięte zostaną wszystkie obszary, grupa zapada się do jednej komórki na granicy usuwania, a komórka aktywna, która już nie leży w wybranym obszarze, przesuwa się na jego lewy górny róg, więc indeks i współrzędna nigdy sobie nie przeczą. Limity są świadome. Nieprawidłowe grupy klasyczne remapper pomija, zamiast przepisywać je na zmyślone zaznaczenie, więc ich oryginalne bajty wciąż przechodzą round-trip. Edycja jednego okienka podmienia tylko rekordy tego okienka i zostawia pozostałe identyczne bajt w bajt. ODS nie dostaje żadnego stanu zaznaczenia okienek, bo ODF nie ma równoważnej struktury widoku arkusza, która mogłaby go nieść

Jeśli twoja aplikacja zapisuje pliki .xls, które użytkownicy otwierają i po których muszą się poruszać — czy to żeby przejrzeć oflagowane komórki, wrócić do miejsca, w którym skończyli, albo podzielić się zamrożonym dashboardem — API zaznaczania i przewijania świadome okienek jest częścią komponentu arkuszowego HotXLS dla Delphi i działa tak samo dla XLS i XLSX