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
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 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
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ę