Technický článek

Klasifikace externích odkazů BIFF SupBook a XTI v Delphi

Otevřete staré xls, uložte ho znovu, a vzorec doplňku, který volal do zaregistrované analytické knihovny, teď ukazuje na prázdnou referenci uvnitř samotného sešitu. HotXLS vystopuje to tiché poškození k jedné špatné domněnce: že záznam BIFF SupBook je buď self, nebo externí soubor. [MS-XLS] definuje sedm druhů, ne dva

Proč uložený sešit ztrácí odkazy doplňků?

Protože klasifikační test byl strukturální místo typovaný. Tradiční zkratka načte záznam SupBook ($01AE), ověří, zda nese značku self, a když ne, bere co následuje jako adresu dokumentu. Každý záznam, který ani to ani ono není, propadne do výchozí větve a výchozí větev skoro vždy říká „toto je samotný sešit“. Podpůrný odkaz doplňku, odkaz na stejný list, nepoužitý slot a zkrácený záznam si nakonec všechny obléknou tutéž špatnou etiketu. Nic nehodí výjimku, zatímco se to děje: záznam se rozparsoval, vzorec zkompiloval znovu, soubor se uložil bez varování a vada vyjde najevo o tři týdny později, když si někdo všimne sloupce nul tam, kde bývala převod měny. [MS-XLS] §2.4.271 popisuje záznam, který může být samoodkazem, odkazem na stejný list, kontejnerem funkcí doplňku, externím sešitem s virtuální cestou a tabulkou názvů listů, datovým odkazem DDE nebo OLE, nebo nepoužitou zástupkou — a sedmý stav, který specifikace nezná, ale na reálných discích existuje: záznam, který se neparsuje. Oprava není lepší heuristika; je to odmítnutí heuristiky úplně

Sedm druhů, které záznam SupBook může nést

HotXLS deklaruje taxonomii podpůrných odkazů jako uzavřený výčet v lxExternSheet.pas a každé následné rozhodnutí se na něj přepíná. Devět hodnot výčtu pokrývá sedm kategorií, protože případ DDE a OLE potřebuje přechodný stav, než se dá rozřešit:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // nepodařilo se rozparsovat, nebo zbyly bajty na konci
    slkSelf,              // tento sešit
    slkSameSheet,         // značka U+0000
    slkAddIn,             // kontejner funkcí doplňku
    slkExternalWorkbook,  // virtuální cesta + tabulka názvů listů
    slkDde,               // rozřešeno z příznaků ExternName
    slkOle,               // rozřešeno z příznaků ExternName
    slkDdeOrOle,          // jeden z obou, ještě není známo který
    slkUnused);           // zástupka z jediné mezery

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

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // od nuly, jak je uloženo v ExternSheet.rgXTI
    ExternID    : Integer;   // od jedničky, interní konvence
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

Rozesílání řídí strážná hodnota, ne řetězce. Hodnota pole $0401 značí záznam self. Počet listů jedna spárovaný s $3A01 značí kontejner doplňku. Jen hodnota v rozsahu 1 až $00FF znamená, že následuje zakódovaná virtuální cesta, a jen tehdy HotXLS vůbec dekóduje řetězec. Cokoliv mimo ty tři tvary zůstává slkUnknown a záznam, jehož tabulka názvů listů nespotřebuje tělo záznamu přesně, se srazí zpět na slkUnknown, i když hlava vypadala věrohodně

Žebřík řízený strážnými hodnotami, který HotXLS používá ke klasifikaci záznamu BIFF SupBook do sedmi druhů: řetězec dekóduje jen pro hodnoty v rozsahu zakódované cesty a sestupuje do neznámého druhu místo do výchozí větve znamenající tento sešit
Ke každému druhu se dojde strážnou hodnotou, ne testem řetězce, a záznam, který neodpovídá žádnému tvaru, zůstává neznámý místo propadu do výchozí větve znamenající tento sešit

Proč se značka stejného listu dekóduje jako prázdný řetězec?

Protože všeobecný čtenář BIFF řetězců ničí bajt, na kterém klasifikace stojí. Podpůrný odkaz na stejný list je jednoznakový řetězec, jehož jediný znak je U+0000, a TXLSBlob.GetBiffString ho vrátí jako prázdný WideString, nerozlišitelný od skutečně prázdné cesty — což je přesně ten vstup, na který heuristika samoodkazu odpoví „self“. HotXLS proto čte surový první kódový bod přímo z těla záznamu, místo aby věřil dekódované hodnotě:

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)     // komprimovaný, jeden bajt
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // široký, dva 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;

Všimněte si větve komprimovaný proti širokému. Bajt voleb sedí na pevném posunu od hlavičky řetězce a první kódový bod je jeden bajt nebo dva podle bitu 0, takže bezpodmínečné čtení jako bajtu funguje na většině souborů a selhává na těch z lokalizovaných sestavení — co nejhůře možné rozložení pro chybu. Nepoužitá zástupka se odchytí stejným způsobem, podle svého doslovného obsahu z jediné mezery, a případ DDE nebo OLE podle oddělovače U+0003 zaneseného v zakódované cestě

Proč HotXLS čte surový první kódový bod z těla záznamu BIFF SupBook místo dekódovaného řetězce: všeobecný čtenář řetězců mění značku U+0000 stejného listu na prázdnou hodnotu
Značka stejného listu je jednoznakový řetězec se znakem U+0000, takže všeobecný čtenář řetězců ji srazí na prázdnou hodnotu a zachová ji jen surový kódový bod na posunu bajtu voleb

Proč nelze DDE a OLE oddělit už při čtení SupBook?

Protože záznam SupBook nenese rozhodující bity. Řekne vám, že odkaz je jeden z obou; příznaky fOle a fOleLink, které rozhodují který, žijí v záznamu ExternName ($0023) dorazivším ve streamu později. HotXLS zaznamená slkDdeOrOle při parsování a zúží ho v ParseExternalName, a když žádné ExternName nikdy nedorazí, druh zůstane přechodný navždy — což je správně, protože soubor to doopravdy neříká. Každý konzument dále po proudu bere tu přechodnou hodnotu jako skutečnou hodnotu, ne jako chybějící, takže žádný volající si nemusí vymýšlet rozsuďák. Hádání „asi DDE“ tady by koupilo úhlednější výčet a třídu špatných odpovědí, které by nikdo nevystopoval:

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;

Indexy XTI jsou na disku od nuly a uvnitř od jedničky

HotXLS provádí konverzi o jedničku přesně jednou, v bodě, kdy token vstupuje do interního syntaktického stromu, a nikde jinde. PtgNameX.ixti ([MS-XLS] §2.5.198.85) je index od nuly do pole rgXTI záznamu ExternSheet ($0017, §2.4.106), zatímco interní konvence ExternID knihovny je od jedničky s nulou rezervovanou pro „žádný externí list“. Čtenářská cesta BIFF8 udělá FExternID := wValue + 1, když dekóduje token tNameX, a zapisovací cesta vydá StoreExternID - 1, takže surový pohled na tokeny i sémantika na disku zůstanou nedotčené. Pokazit to se neobyčejně špatně chytá: externí definované názvy se rozřešou na vedlejší položku a v souboru s jedinou položkou XTI se index 0 stane indexem 1, mine se a název tiše degraduje. Regrese, která cvičí jen rekompilovaný text vzorců, ji nikdy neuzří, protože rekompilace diskového indexu vůbec nedotkne — stejná past, kvůli které stojí definované názvy přesahující listy a sešity za testování na skutečných bajtových streamech. Rozřešení je ohraničené na obou koncích: TlxExternSheetSheet.TryResolveXti vrátí False pro záporný index nebo chybějící položku, TXLSSupBook.TryGetKind vrátí False pro index SupBook mimo pole a ClassifyXti pak mapuje slkSelf a slkSameSheet na frcInternal, slkExternalWorkbook na frcExternalWorkbook a slkAddIn, slkDde, slkOle a slkDdeOrOle na frcExternalOther. Všechno ostatní, včetně každé cesty mimo rozsah, dopadne na frcUnknownOrMalformed

HotXLS převádí index XTI od nuly tokenu BIFF PtgNameX na interní ExternID od jedničky v jediném bodě, s ohraničeným rozřešením na obou koncích a klasifikační mapou, která ho konzumuje
Odhyluka o jedničku mezi diskovým indexem od nuly a interním ExternID od jedničky se aplikuje jednou, když token vstupuje do syntaktického stromu, a každý nerozřešitelný index dopadne na třídu znetvořených

Klasifikace vzorce před zmrznutím

TXLSCompiledFormula.ClassifyReferences skenuje zachovaný BIFF tokenový stream přímo místo dekompilace vzorce a hledání hranatých závorek. Lov závorek v textu vzorce je textová heuristika v plášti parseru: trefí řetězcové literály, trefí strukturované odkazy a externí definované názvy minete celé, protože ty v dekompilované podobě žádné závorky nenosí. Tokenový sken hledí jen na PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d a PtgAreaErr3d a sestupuje k procházení syntaktického stromu, když BIFF stream nepřežije. Slučování je záměrně pesimistické — pevná priorita je frcUnknownOrMalformed, pak frcExternalWorkbook, pak frcExternalOther, pak frcInternal — takže jediný nečitelný token otráví celý vzorec. U externího definovaného názvu se ověřuje i index názvu: od jedničky, v rozsahu a podložený uchovaným záznamem 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 je od jedničky
    begin
      Sheet := Wb.Sheets[i];
      // zmrznou JEN vzorce klasifikované jako frcExternalWorkbook;
      // interní, doplňkové, DDE/OLE a znetvořené odkazy zůstanou vzorci
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

Parametr OnlyExternal je místo, kde se taxonomie vyplatí. Zmrznutí vzorce je nevratné, takže operace musí dokázat, že odkaz je externí sešit, ne ho jen podezírat. Volání doplňků přežijí, odkazy DDE a OLE přežijí a všechno, čemu parser plně nerozuměl, přežije, protože bezpečný výsledek nejistoty je nic neměnit. Tatáž disciplína řídí přepojení vzorců kopírovaných mezi sešity, kde špatně klasifikovaný odkaz se přepojí na špatný sešit místo hlasitého selhání

Záznamy, které se neparsují, se zapisují zpět nedotčené

HotXLS si drží původní náklad záznamu SupBook a vydává ho bajt za bajt, když záznam nikdy nebyl editován. Selhání parsování nastaví slkUnknown a vyčistí odvozený stav, ale zachycené tělo zůstává v FRawData a zapisovací cesta mu dává přednost před jakýmkoli rekonstruováním, dokud položka není špinavá a nejde o záznam self. Alternativa — normalizovat neparseovaný záznam na samoodkaz, aby měl zapisovač něco dobře utvářeného k vydání — mění záznam, kterému jste nerozuměli, na záznam, který je jednoznačně špatně. Ten princip je tatáž smlouva aplikovaná na VBA projekty a jejich externí odkazy přes cyklus načtení a uložení, a je to rozdíl mezi knihovnou, která zpracovává tam a zpět reálné soubory, a knihovnou, která zpracovává soubory, které náhodou obsahuje její testovací sada. Sešit, který prošel patnácti lety verzí Excelu, generátorem sestav a dvěma migračními nástroji, bude obsahovat záznamy, které nikdo žijící nenavrhl. Zapisujte je zpět, jak jste je našli

Typovaná klasifikace záznamů SupBook a XTI dorazila v HotXLS 2.361.2 až 2.361.4 spolu s ohraničeným rozřešením XTI a bezpečnější cestou ConvertFormulasToValues popsanou tady. Pokud udržujete kód Delphi nebo C++Builder, který čte staré soubory xls nesoucí volání doplňků, odkazy DDE nebo OLE nebo externí definované názvy, HotXLS Delphi tabulková komponenta zvládne celou taxonomii nativně, bez instalace Excelu a bez OLE automatizace na stroji, který dělá práci