HotXLS Excel Library dla Delphi i C++Builder odczytuje i zapisuje kontener Compound File Binary stojący za każdym klasycznym plikiem .xls w czystym Object Pascalu. Klasa TlxCompoundFile implementuje układ [MS-CFB] wersja 3 bezpośrednio na TStream — nagłówek, DIFAT, łańcuchy FAT, MiniFAT i drzewo katalogu — bez ole32.dll i bez COM IStorage gdziekolwiek na ścieżce
To brzmi jak hydraulika, i przez dwadzieścia lat była hydrauliką należącą do kogoś innego. Każda baza kodu Delphi dotykająca pliku .xls sięgała po StgOpenStorage, dostawała z powrotem IStorage i wyciągała z niego strumień Workbook. Trzy linie, działało dobrze, nikt więcej o tym nie myślał — aż do dnia, gdy ten sam kod musiał działać tam, gdzie nie było Windows
Dlaczego StgOpenStorage przestaje działać na serwerze?
API COM structured-storage zawodzi dokładnie w tych kształtach wdrożeniowych, w których żyje nowoczesny kod Delphi, z powodów niemających nic wspólnego z formatem pliku. StgOpenStorage to punkt wejścia Win32 w ole32.dll: chce ścieżki w systemie plików, chce COM zainicjowanego na wątku wywołującym i chce być na Windows. Wymóg ścieżki boli najpierw, ponieważ endpoint REST otrzymujący przesłany skoroszyt ma bajty w buforze, nie na dysku — więc zapisujesz bufor do pliku tymczasowego, otwierasz go, czytasz z powrotem, usuwasz, i teraz posiadasz cykl życia pliku tymczasowego do zepsucia pod obciążeniem. ILockBytes to udokumentowana furtka ucieczki, ale podłączenie własnej implementacji nad TMemoryStream to więcej interopu COM, niż większość zespołów chce. Wymóg inicjalizacji gryzie drugi, zwykle w wątku roboczym usługi, na którym nikt nie wywołał CoInitialize, a wymóg platformy kończy rozmowę w chwili, gdy celem jest Linux pod FPC, obraz kontenera lub macOS. HotXLS dlatego zachowuje klasyczną ścieżkę lxOLE zbudowaną na StgOpenStorage jako domyślną, ponieważ jest sprawdzona w boju, a istniejący wywołujący nie powinni musieć się zmieniać; TlxCompoundFile to opcjonalna alternatywa dla wszystkich innych
Co naprawdę mówią nagłówek i łańcuchy FAT
Pierwsze 512 bajtów pliku złożonego odpowiada na każde pytanie strukturalne, którego potrzebujesz przed odczytaniem bajtu ładunku. [MS-CFB] §2.2 ustala sygnaturę nagłówka pod przesunięciem 0 jako osiem bajtów D0 CF 11 E0 A1 B1 1A E1, a lxIsCompoundStream sprawdza dokładnie to, przywracając potem pozycję strumienia, więc wywołujący może obwąchać bez naruszania niczego. Cztery kolejne pola decydują o geometrii: kolejność bajtów pod 0x1C musi być 0xFFFE, co podwaja się jako tania druga kontrola sygnatury; przesunięcie sektora pod 0x1E daje rozmiar sektora jako 1 shl SectorShift, więc wersja 3 używa przesunięcia 9 dla sektorów 512-bajtowych, a wersja 4 używa przesunięcia 12 dla 4096; przesunięcie mini-sektora pod 0x20 to 6, czyniąc mini-sektory 64-bajtowymi; a próg mini-strumienia pod 0x38 to 4096. Arytmetyka adresów, która następuje, to najczęstsze miejsce, gdzie się myli. Sektor 0 zaczyna się bezpośrednio po nagłówku, więc sektor N zaczyna się pod przesunięciem bajtowym 512 + N * SectorSize — zwróć uwagę na literał 512, nie SectorSize. W pliku wersji 3 oba są identyczne i błąd ukrywa się na zawsze; w pliku wersji 4 po cichu czyta niewłaściwy sektor, dlatego HotXLS trzyma to w jednej funkcji, SidToOffset
Plik złożony to system plików FAT wewnątrz pliku, więc odczytanie go oznacza przechodzenie po połączonych listach identyfikatorów sektorów, gdzie FAT[n] trzyma identyfikator następujący po sektorze n. Trzy sygnały kończą lub adnotują łańcuch — ENDOFCHAIN, FATSECT dla sektora należącego do samego FAT, i DIFSECT dla sektora DIFAT — a wszystkie trzy odczytują się jako ujemne 32-bitowe liczby całkowite ze znakiem, co upraszcza warunki pętli. Znalezienie FAT wymaga jeszcze jednej pośredniości: DIFAT to tablica identyfikatorów sektorów mówiąca, gdzie żyją sektory FAT, a jej pierwsze 109 wpisów siedzi w nagłówku pod przesunięciem 0x4C. TlxCompoundFile przechodzi te 109, zatrzymuje się na pierwszym ujemnym wpisie i łączy każdy sektor FAT w jedną płaską tablicę Integer. To 109 sektorów FAT po 128 wpisów każdy na sektorze 512-bajtowym, więc mniej więcej 13 952 adresowalnych sektorów, więc mniej więcej 6,8 MiB kontenera, zanim DIFAT musi rozlać się do własnego łańcucha
Druga tabela alokacji istnieje, ponieważ 512-bajtowe sektory marnują większość swojego miejsca na małych strumieniach. Każdy strumień poniżej progu 4096 bajtów w ogóle nie jest przechowywany w sektorach: żyje wewnątrz mini-strumienia, samego zwykłego strumienia zwisającego z wpisu katalogu głównego, podzielonego na mini-sektory 64-bajtowe i połączonego przez równoległy MiniFAT zakorzeniony pod przesunięciem nagłówka 0x3C. Otwórz prawdziwy .xls, a strumień Workbook siedzi na zwykłym FAT, podczas gdy strumienie podsumowania informacji siedzą w przestrzeni mini-sektorów, dlatego implementacja obejmująca tylko ścieżkę FAT wygląda na działającą, dopóki nie potrzebuje metadanych dokumentu. Katalog to trzecia struktura i ta, która czyni kontener nawigowalnym: każdy wpis ma dokładnie 128 bajtów, cztery na sektor 512-bajtowy, niosąc nazwę UTF-16 w pierwszych 64 bajtach, jej długość bajtową pod 0x40, typ obiektu pod 0x42 (1 = storage, 2 = stream, 5 = root), łącza drzewa pod 0x44, 0x48 i 0x4C, sektor początkowy pod 0x74 i 32-bitowy rozmiar strumienia pod 0x78. Ta długość nazwy liczy bajty włącznie z terminującym zerem, więc liczba znaków to NameLen div 2 - 1, a pomylenie się o jeden to sposób, w jaki kończysz ze strumieniem nazwanym Workboo
Wyciąganie strumienia Workbook z bufora pamięci
TlxCompoundFile.OpenStream ukrywa to wszystko za jednym wywołaniem, które przyjmuje nazwę strumienia i zwraca TlxCfbStream trzymający w pełni zmaterializowane bajty. Cała sekwencja — obwąchaj, wczytaj, wyodrębnij — działa na TBytesStream, przy czym nic nigdy nie dotyka dysku
uses
Classes, SysUtils, lxCompoundFile;
function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
Src: TBytesStream;
Cfb: TlxCompoundFile;
Wb: TlxCfbStream;
begin
SetLength(Result, 0);
Src:= TBytesStream.Create(Blob);
try
if not lxIsCompoundStream(Src) then
Exit; // not a CFB container at all
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(Src); // header, FAT, directory, MiniFAT
Wb:= Cfb.OpenStream('Workbook'); // BIFF8
if Wb = nil then
Wb:= Cfb.OpenStream('Book'); // BIFF5 / BIFF7
if Wb <> nil then
try
Result:= Wb.Data;
finally
Wb.Free;
end;
finally
Cfb.Free;
end;
finally
Src.Free;
end;
end;
Dwa szczegóły tam warto podkreślić. LoadFromStream przyjmuje flagę AOwnsStream domyślnie False, więc wywołujący zachowuje odpowiedzialność za strumień źródłowy — celowo, ponieważ zwykłym przypadkiem jest strumień, który aplikacja już posiada. A OpenStream zwraca TlxCfbStream posiadający własną kopię bajtów, wystawioną przez Data, Size, Read, Seek i CopyTo. Ta kopia jest realnym kosztem na dużym skoroszycie i jest uczciwą ceną projektu, w którym zwrócony obiekt pozostaje ważny po zwolnieniu kontenera. Gdy skoroszyt jest wystarczająco duży, że pełna kopia w pamięci jest całkowicie niewłaściwym kształtem, strumieniowy czytnik bezpośredni dla zbyt dużych arkuszy kalkulacyjnych jest lepszym punktem wejścia
Dlaczego zaszyfrowany XLSX wygląda jak plik XLS?
Ponieważ nim jest, na poziomie kontenera — i to jest praktyczna korzyść z posiadania tej warstwy. Otwórz zaszyfrowany plik .xlsx w edytorze hex, a pierwsze osiem bajtów to D0 CF 11 E0 A1 B1 1A E1, bajt w bajt identyczne z plikiem .xls z 1997 roku, ponieważ szyfrowanie [MS-OFFCRYPTO] nie szyfruje pakietu ZIP na miejscu: owija cały pakiet wewnątrz kontenera CFB jako strumień o nazwie EncryptedPackage, obok strumienia EncryptionInfo opisującego algorytm. Sygnatura więc identyfikuje kontener i nic nie mówi o ładunku. Odróżnienie skoroszytu BIFF od zaszyfrowanego pakietu OOXML oznacza odczytanie katalogu, co po LoadFromStream jest skanem po EntryCount i Entries, lub parą sondowań HasStream
type
TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);
function ClassifyContainer(AStream: TStream): TCfbPayload;
var
Cfb: TlxCompoundFile;
E: TlxCfbEntry;
I: Integer;
begin
Result:= cpUnknown;
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(AStream);
for I:= 0 to Cfb.EntryCount - 1 do
begin
E:= Cfb.Entries(I);
if E.EntryType <> cfbStream then
Continue;
if E.Name = 'EncryptedPackage' then
Result:= cpEncryptedOoxml
else if (E.Name = 'Workbook') or (E.Name = 'Book') then
Result:= cpBiffWorkbook;
end;
finally
Cfb.Free;
end;
end;
Nazwy katalogów zasługują na własne ostrzeżenie: strumienie podsumowania informacji niosą wiodący znak kontrolny 0x05 w swoich nazwach, więc porównanie napisane wobec zwykłego ciągu wyświetlania nigdy ich nie dopasuje, a naiwna linia dziennika renderuje je jako bałagan. Wszystko dalej za tą klasyfikacją to osobny problem — wyprowadzenie klucza, sprawdzenie weryfikatora hasła — omówiony w notatkach o tym, dlaczego Excel odrzuca skoroszyt zaszyfrowany niewłaściwym trybem szyfru. Warstwa kontenera mówi ci tylko, przed jakimi drzwiami stoisz
Zapisywanie kontenera, który Excel faktycznie otworzy
Strona zapisu TlxCompoundFile jest celowo węższa niż strona odczytu, a zrozumienie dlaczego oszczędza sporu ze specyfikacją. [MS-CFB] pozwala na ogromną przestrzeń poprawnych kontenerów: wielopoziomowe storage'e, właściwie zbalansowane drzewa katalogu czerwono-czarne, mini-strumienie, łańcuchy DIFAT. Excel emituje mały zakątek tej przestrzeni i odczytuje nieco większy. HotXLS zapisuje zakątek jeszcze mniejszy — minimum, jakie Excel demonstracyjnie wczytuje. Każdy strumień idzie na zwykły FAT bez ścieżki mini-strumienia, co kosztuje miejsce na dysku i kupuje poprawność: 300-bajtowy strumień podsumowania, który Excel spakowałby w pięć mini-sektorów 64-bajtowych, zamiast tego zajmuje pełny sektor 512-bajtowy, a dla skoroszytu to szum obok utrzymywania drugiej tabeli alokacji, drugiego przechodzenia łańcucha i strumienia wpisu głównego wspierającego to na ścieżce zapisu. Wpisy katalogu tworzą płaski łańcuch rodzeństwa pod korzeniem, przy czym każdy węzeł jest kolorowany na czarno, a kolejność emisji jest ustalona: placeholder nagłówka, sektory danych strumienia, sektory katalogu, sektory FAT, a potem seek z powrotem, by przepisać nagłówek identyfikatorami sektorów znanymi dopiero na końcu. FAT sam ustala swój rozmiar poprzez krótką pętlę punktu stałego, ponieważ dodawanie sektorów FAT może przepchnąć liczbę sektorów wystarczająco wysoko, by wymagać kolejnego sektora FAT
procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
FS: TFileStream;
Cfb: TlxCompoundFile;
begin
FS:= TFileStream.Create(Dest, fmCreate);
try
Cfb:= TlxCompoundFile.Create;
try
Cfb.CreateNew(FS); // v3 header, 512-byte sectors
Cfb.AddStream('Workbook', BiffBytes);
Cfb.Save; // data -> dir -> FAT -> header
finally
Cfb.Free;
end;
finally
FS.Free;
end;
end;
Gdzie kończy się implementacja
Trzy granice warto jasno stwierdzić, ponieważ czytnik kontenera, który po cichu źle obsługuje przypadek brzegowy, jest gorszy niż taki, który zgłasza wyjątek. TlxCompoundFile odczytuje 109 wpisów DIFAT rezydujących w nagłówku i nie podąża za łańcuchem DIFAT pod 0x44 poza nimi, ograniczając czytelny kontener do mniej więcej 6,8 MiB na sektorach 512-bajtowych — komfortowo powyżej rzeczywistych plików .xls, jakie HotXLS spotyka w terenie, ale mimo to twardy sufit, a writer wymusza ten sam limit jawnie, zamiast emitować kontener, którego nie potrafi opisać. Po drugie, kontenery wersji 4 z sektorami 4096-bajtowymi są obsługiwane przez arytmetykę rozmiaru sektora, ale to nie jest to, do czego kod jest dostrojony, a 64-bitowy rozmiar strumienia nie jest konsultowany: HotXLS odczytuje dolne 32 bity pod przesunięciem 0x78 i zostawia górną połowę w spokoju, co jest poprawne dla wersji 3 i tylko dla wersji 3. Po trzecie, wyszukiwanie wpisu to płaski skan po nazwie po liście katalogu, zamiast przejścia w dół drzewa czerwono-czarnego od storage'u rodzica, więc zagnieżdżone storage'e rozwiązują się przez kolizję nazw, a nie przez ścieżkę — każdy strumień, którego potrzebuje plik .xls, siedzi na najwyższym poziomie, co jest tym, co czyni prostszy projekt obronnym, ale kod oczekujący adresowania SomeStorage/SomeStream tego nie znajdzie
Nic z tego nie zmienia tego, do czego służy ta jednostka. Posiadanie warstwy kontenera zamienia obsługę .xls w zwykły Object Pascal: parsowalną z tablicy bajtów, testowalną bez systemu plików, przenośną na dowolną platformę, którą celuje kompilator, i wolną od apartamentu COM. Wycofuje też skróty obwąchiwania, ponieważ identyfikacja skoroszytu oznacza teraz odczytanie jego katalogu, a nie pierwszych ośmiu bajtów — ta sama dyscyplina stojąca za wypisywaniem nazw arkuszy bez otwierania całego skoroszytu
TlxCompoundFile jest dostarczany jako część komponentu HotXLS Excel dla Delphi i C++Builder, obok warstw BIFF i OOXML siedzących na nim; strona produktu zawiera pełne odniesienie jednostki i wspieraną macierz kompilatorów