Artykuł techniczny

Klasyfikacja łączy zewnętrznych BIFF SupBook i XTI w Delphi

Otwórz stary plik xls, zapisz go ponownie, a formuła dodatku, która wywoływała zarejestrowaną bibliotekę analityczną, wskazuje teraz puste odwołanie wewnątrz samego skoroszytu. HotXLS wywodzi tę cichą degradację z jednego złego założenia: że rekord BIFF SupBook jest albo samym skoroszytem, albo plikiem zewnętrznym. [MS-XLS] definiuje siedem rodzajów, nie dwa

Dlaczego zapisany skoroszyt traci łącza dodatków?

Bo test klasyfikacji był strukturalny, a nie typowany. Tradycyjny skrót czyta rekord SupBook ($01AE), sprawdza, czy niesie znacznik self, a jeśli nie, traktuje dowolny następujący ciąg jako URL dokumentu. Każdy rekord, który nie jest ani jednym, ani drugim, wpada do gałęzi domyślnej, a gałąź domyślna to prawie zawsze „to jest sam skoroszyt”. Łącze wspierające dodatek, łącze same-sheet, nieużywane gniazdo i ucięty rekord kończą w końcu w tej samej złej etykiecie. Nic nie rzuca wyjątku, gdy to się dzieje: rekord się sparsował, formuła się przekompilowała, plik się zapisał bez ostrzeżenia, a wada wychodzi na jaw trzy tygodnie później, gdy ktoś zauważa kolumnę zer tam, gdzie była konwersja waluty. [MS-XLS] §2.4.271 opisuje rekord, który może być odwołaniem do samego siebie, odwołaniem same-sheet, kontenerem funkcji dodatku, zewnętrznym skoroszytem ze ścieżką wirtualną i tabelą nazw arkuszy, łączem danych DDE lub OLE albo nieużywanym placeholderem — oraz siódmy stan, którego nie ma w specyfikacji, ale istnieje na prawdziwych dyskach: rekord, który nie parsuje się wcale. Poprawka nie polega na lepszej heurystyce; polega na odmowie posiadania jakiejkolwiek heurystyki

Siedem rodzajów, jakie rekord SupBook może nieść

HotXLS deklaruje taksonomię łączy wspierających jako domknięte wyliczenie w lxExternSheet.pas, i każda decyzja dalej w potoku przełącza się na nią. Dziewięć wartości wyliczenia pokrywa siedem kategorii, bo przypadek DDE i OLE potrzebuje stanu tymczasowego, zanim da się go rozstrzygnąć:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // nie sparsował się albo zostały bajty ogonowe
    slkSelf,              // ten skoroszyt
    slkSameSheet,         // znacznik U+0000
    slkAddIn,             // kontener funkcji dodatku
    slkExternalWorkbook,  // ścieżka wirtualna + tabela nazw arkuszy
    slkDde,               // rozstrzygany z flag ExternName
    slkOle,               // rozstrzygany z flag ExternName
    slkDdeOrOle,          // jeden z dwóch, jeszcze niewiadomo który
    slkUnused);           // placeholder z pojedynczą spacją

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // od zera, tak jak zapisane w ExternSheet.rgXTI
    ExternID    : Integer;   // od jedynki, konwencja wewnętrzna
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

Dyspozycja jest sterowana wartownikami, nie ciągami. Wartość pola $0401 oznacza rekord self. Liczba arkuszy równa jeden sparowana z $3A01 oznacza kontener dodatku. Tylko wartość z zakresu od 1 do $00FF znaczy, że za nią idzie zakodowana ścieżka wirtualna, i dopiero wtedy HotXLS w ogóle dekoduje ciąg. Cokolwiek poza tymi trzema kształtami zostaje slkUnknown, a rekord, którego tabela nazw arkuszy nie konsumuje ciała rekordu dokładnie, jest degradowany z powrotem do slkUnknown, nawet gdy nagłówek wyglądał wiarygodnie

Drabinka sterowana wartownikami, której HotXLS używa do klasyfikacji rekordu BIFF SupBook na siedem rodzajów, dekodując ciąg tylko dla wartości z zakresu zakodowanej ścieżki i cofając się do rodzaju nieznanego, a nie do gałęzi domyślnej
Każdy rodzaj jest osiągany wartownikiem, a nie testem ciągu, a rekord, który nie pasuje do żadnego kształtu, zostaje nieznany, zamiast wpaść do gałęzi domyślnej oznaczającej ten skoroszyt

Dlaczego znacznik same-sheet dekoduje się jako pusty ciąg?

Bo ogólnego przeznaczenia czytnik ciągów BIFF niszczy bajt, od którego zależy klasyfikacja. Łącze wspierające same-sheet to jednoznakowy ciąg, którego jedyny znak to U+0000, a TXLSBlob.GetBiffString oddaje go jako pusty WideString, nieodróżnialny od naprawdę pustej ścieżki — a to dokładnie to wejście, na które heurystyka odwołania do samego siebie odpowiada „self”. HotXLS czyta więc surowy pierwszy punkt kodowy prosto z ciała rekordu, zamiast ufać zdekodowanej wartości:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // skompresowany, jeden bajt
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // szeroki, dwa bajty
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

Zanotuj gałąź skompresowany-versus-szeroki. Bajt opcji siedzi na stałym odstępie od nagłówka ciągu, a pierwszy punkt kodowy to jeden bajt lub dwa w zależności od bitu 0, więc bezwarunkowe czytanie go jako bajtu działa na większości plików i zawodzi na tych zapisanych przez lokalizowane kompilacje — najgorszy możliwy rozkład dla błędu. Nieużywany placeholder jest łapany tak samo, po dosłownej pojedynczej spacji jako ładunku, a przypadek DDE lub OLE po separatorze U+0003 zatopionym w zakodowanej ścieżce

Dlaczego HotXLS czyta surowy pierwszy punkt kodowy z ciała rekordu BIFF SupBook, a nie zdekodowany ciąg, bo ogólny czytnik ciągów zamienia znacznik same-sheet U+0000 na pustą wartość
Znacznik same-sheet to jednoznakowy ciąg, którego znak to U+0000, więc ogólny czytnik ciągów zwija go do pustej wartości i tylko surowy punkt kodowy na odstępie bajtu opcji go zachowuje

Dlaczego DDE i OLE nie da się rozdzielić w czasie SupBook?

Bo rekord SupBook nie niesie bitów rozstrzygających. Mówi ci, że łącze jest jednym z dwóch; flagi fOle i fOleLink, które rozstrzygają którą, mieszkają w rekordzie ExternName ($0023) przybywającym później w strumieniu. HotXLS zapisuje slkDdeOrOle w czasie parsowania i zwęża go w ParseExternalName, a jeśli żadne ExternName nie przybynie, rodzaj zostaje tymczasowy na zawsze — co jest poprawne, bo plik naprawdę nie mówi. Każdy konsument dalej w potoku traktuje tę wartość tymczasową jako prawdziwą wartość, a nie brakującą, więc żaden wywołujący nie musi wymyślać rozstrzygnięcia remisu. Zgadywanie „pewnie DDE” kupiłoby tu schludniejsze wyliczenie i klasę złych odpowiedzi, których nikt nie umiałby wyjaśnić:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

Indeksy XTI są od zera na dysku i od jedynki wewnątrz

HotXLS wykonuje konwersję off-by-one dokładnie raz, w punkcie, w którym token wchodzi do wewnętrznego drzewa składni, i nigdzie indziej. PtgNameX.ixti ([MS-XLS] §2.5.198.85) jest indeksem od zera do tablicy rgXTI rekordu ExternSheet ($0017, §2.4.106), podczas gdy wewnętrzna konwencja ExternID biblioteki jest od jedynki, z zerem zarezerwowanym na „brak zewnętrznego arkusza”. Ścieżka odczytu BIFF8 robi FExternID := wValue + 1, gdy dekoduje token tNameX, a ścieżka zapisu emituje StoreExternID - 1, zostawiając surowy widok tokenów i semantykę na dysku nietkniętymi. Pomyłka tutaj jest niezwykle trudna do złapania: zewnętrzne nazwy zdefiniowane rozwiązują się na sąsiedni wpis, a w pliku z pojedynczym wpisem XTI indeks 0 staje się indeksem 1, chybia, i nazwa degraduje się po cichu. Regresja, która ćwiczy tylko przekompilowany tekst formuły, nigdy tego nie zobaczy, bo rekompilacja w ogóle nie dotyka indeksu z dysku — ta sama pułapka, przez którą nazwy zdefiniowane obejmujące arkusze i skoroszyty warto testować na prawdziwych strumieniach bajtów. Rozwiązanie jest ograniczone z obu końców: TlxExternSheetSheet.TryResolveXti zwraca False dla ujemnego indeksu lub brakującego wpisu, TXLSSupBook.TryGetKind zwraca False dla indeksu SupBook poza tablicą, a ClassifyXti mapuje potem slkSelf i slkSameSheet na frcInternal, slkExternalWorkbook na frcExternalWorkbook, a slkAddIn, slkDde, slkOle i slkDdeOrOle na frcExternalOther. Wszystko inne, włącznie z każdą ścieżką poza zakresem, ląduje na frcUnknownOrMalformed

HotXLS konwertuje indeks XTI od zera tokenu BIFF PtgNameX na jego wewnętrzne ExternID od jedynki w pojedynczym punkcie, z ograniczonym rozwiązaniem z obu końców i mapą klasyfikacji, która to zużywa
Off-by-one między indeksem od zera na dysku a wewnętrznym ExternID od jedynki jest stosowany raz, gdy token wchodzi do drzewa składni, a każdy nierozwiązywalny indeks ląduje na klasie zniekształconej

Klasyfikacja formuły przed jej zamrożeniem

TXLSCompiledFormula.ClassifyReferences skanuje zachowany strumień tokenów BIFF wprost, zamiast dekompilować formułę i szukać nawiasów kwadratowych. Polowanie na nawiasy w tekście formuły to heurystyka tekstowa w płaszczu parsera: dopasowuje literały ciągowe, dopasowuje odwołania strukturalne i całkiem gubi zewnętrzne nazwy zdefiniowane, bo te nie niosą nawiasów w formie zdekompilowanej. Skan tokenów patrzy wyłącznie na PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d i PtgAreaErr3d, cofając się do przejścia po drzewie składni, gdy strumień BIFF nie przetrwał. Scalanie jest celowo pesymistyczne — stały priorytet to frcUnknownOrMalformed, potem frcExternalWorkbook, potem frcExternalOther, potem frcInternal — więc pojedynczy nieczytelny token zatruje całą formułę. Dla zewnętrznej nazwy zdefiniowanej walidowany jest też indeks nazwy: od jedynki, w zakresie i podparty zatrzymanym rekordem ExternName

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets jest od jedynki
    begin
      Sheet := Wb.Sheets[i];
      // zamraża TYLKO formuły sklasyfikowane jako frcExternalWorkbook;
      // wewnętrzne, dodatkowe, DDE/OLE i zniekształcone odwołania zostają formułami
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

Parametr OnlyExternal to miejsce, gdzie taksonomia spłaca siebie. Zamrożenie formuły jest nieodwracalne, więc operacja musi udowodnić, że odwołanie jest zewnętrznym skoroszytem, a nie jedynie go podejrzewać. Wywołania dodatków przeżywają, łącza DDE i OLE przeżywają, i cokolwiek, czego parser nie zrozumiał w całości, przeżywa, bo bezpiecznym skutkiem niepewności jest niezmienianie niczego. Ta sama dyscyplina rządzi wiązaniem ponownym formuł kopiowanych między skoroszytami, gdzie źle sklasyfikowane odwołanie wiąże się ponownie do złego skoroszytu, zamiast zawieść głośno

Rekordy, które nie parsują się wcale, są zapisywane z powrotem nietknięte

HotXLS trzyma oryginalny ładunek SupBook i emituje go z powrotem bajt za bajtem, gdy rekord nie był nigdy edytowany. Porażka parsowania ustawia slkUnknown i czyści stan wywodzony, ale przechwycone ciało zostaje w FRawData, a ścieżka zapisu woli je nad każdą rekonstrukcją, dopóki element nie jest brudny i nie jest rekordem self. Alternatywa — normalizacja nieparsowanego rekordu do odwołania do samego siebie, żeby zapisujący miał coś dobrze uformowanego do wyemitowania — zamienia rekord, którego nie zrozumiałeś, w rekord, który jest definitywnie zły. Ta zasada to ten sam kontrakt stosowany do projektów VBA i ich zewnętrznych odwołań w cyklu odczytu i zapisu, i to jest różnica między biblioteką, która robi round-trip prawdziwych plików, a taką, która robi round-trip plików, które jej zestaw testowy akurat zawiera. Skoroszyt, który przeszedł przez piętnaście lat wersji Excela, generator raportów i dwa narzędzia migracyjne, będzie zawierał rekordy, których nikt żyjący obecnie nie zaprojektował. Zapisuj je z powrotem takie, jak je znalazłeś

Typowana klasyfikacja rekordów SupBook i XTI wyszła w HotXLS 2.361.2 przez 2.361.4, razem z ograniczonym rozwiązaniem XTI i bezpieczniejszą ścieżką ConvertFormulasToValues opisaną tutaj. Jeśli utrzymujesz kod Delphi lub C++Buildera czytający stare pliki xls niosące wywołania dodatków, łącza DDE lub OLE albo zewnętrzne nazwy zdefiniowane, komponent arkuszowy HotXLS Delphi obsługuje całą taksonomię natywnie, bez instalacji Excela i bez automatyzacji OLE na maszynie, która robi robotę