HotXLS Excel Library za Delphi i C++Builder čita i piše Compound File Binary kontejner iza svakog zastarelog .xls fajla u čistom Object Pascalu. Klasa TlxCompoundFile implementira [MS-CFB] verzije 3 raspored direktno nad TStream — zaglavlje, DIFAT, FAT lance, MiniFAT i stablo direktorijuma — bez ole32.dll i bez COM IStorage igde u putanji
To zvuči kao instalacija cevi, i dvadeset godina je to i bila cev koju je posedovao neko drugi. Svaka Delphi kodna baza koja je dodirivala .xls fajl posezala je za StgOpenStorage, dobijala nazad IStorage, i izvlačila tok Workbook iz njega. Tri linije, radilo je dobro, niko o tome više nije razmišljao — sve do dana kada je isti kod morao da se izvrši negde gde nije bilo Windowsa
Zašto StgOpenStorage prestaje da radi na serveru?
COM API strukturiranog skladištenja otkazuje tačno u onim oblicima deploymenta u kojima živi moderan Delphi kod, iz razloga koji nemaju veze sa formatom fajla. StgOpenStorage je Win32 ulazna tačka u ole32.dll: hoće putanju na fajl sistemu, hoće da COM bude inicijalizovan na pozivajućem thread-u, i hoće da bude na Windowsu. Zahtev za putanjom boli prvi, jer REST endpoint koji prima otpremljen workbook ima bajtove u baferu, ne na disku — pa pišete bafer u temp fajl, otvarate ga, čitate nazad, brišete ga, i sada posedujete životni ciklus temp fajla koji treba pogrešno da uradite pod opterećenjem. ILockBytes je dokumentovan izlaz, ali povezivanje prilagođene implementacije preko TMemoryStream je više COM interopa nego što većina timova želi. Zahtev za inicijalizacijom ujeda drugi, obično u thread-u servisnog radnika na kom niko nije pozvao CoInitialize, a zahtev za platformom završava razgovor u trenutku kada je cilj Linux pod FPC-om, kontejnerska slika, ili macOS. HotXLS zato zadržava klasičnu putanju lxOLE izgrađenu na StgOpenStorage kao podrazumevanu, jer je proverena u borbi i postojeći pozivaoci ne bi trebalo da moraju da se menjaju; TlxCompoundFile je opt-in alternativa za sve ostale
Šta zaglavlje i FAT lanci zapravo govore
Prvih 512 bajtova compound fajla odgovaraju na svako strukturno pitanje koje vam treba pre nego što pročitate ijedan bajt sadržaja. [MS-CFB] §2.2 fiksira potpis zaglavlja na ofsetu 0 kao osam bajtova D0 CF 11 E0 A1 B1 1A E1, a lxIsCompoundStream proverava tačno to, vraćajući poziciju toka posle, tako da pozivalac može da njuška bez ometanja bilo čega. Još četiri polja odlučuju geometriju: redosled bajtova na 0x1C mora biti 0xFFFE, što služi i kao jeftina druga provera potpisa; pomeraj sektora na 0x1E daje veličinu sektora kao 1 shl SectorShift, pa verzija 3 koristi pomeraj 9 za sektore od 512 bajtova, a verzija 4 koristi pomeraj 12 za 4096; pomeraj mini sektora na 0x20 je 6, čineći mini sektore veličine 64 bajta; a granica mini toka na 0x38 je 4096. Aritmetika adresa koja sledi je najčešće mesto gde se greši. Sektor 0 počinje odmah posle zaglavlja, pa sektor N počinje na bajt-ofsetu 512 + N * SectorSize — obratite pažnju na literalni 512, ne SectorSize. U fajlu verzije 3 to dvoje je identično i bag se zauvek krije; u fajlu verzije 4 tiho čita pogrešan sektor, zato HotXLS ovo drži u jednoj funkciji, SidToOffset
Compound fajl je FAT fajl sistem unutar fajla, pa njegovo čitanje znači obilaženje povezanih lista ID-eva sektora, gde FAT[n] drži ID koji sledi sektor n. Tri stražara terminišu ili obeležavaju lanac — ENDOFCHAIN, FATSECT za sektor koji pripada samom FAT-u, i DIFSECT za DIFAT sektor — a sva tri se čitaju kao negativni potpisani 32-bitni celi brojevi, što drži uslove petlje jednostavnim. Pronalaženje FAT-a zahteva još jednu indirekciju: DIFAT je niz ID-eva sektora koji govori gde žive FAT sektori, a njegovih prvih 109 stavki sedi u zaglavlju na ofsetu 0x4C. TlxCompoundFile obilazi tih 109, staje na prvoj negativnoj stavci, i spaja svaki FAT sektor u jedan ravan niz Integer. To je 109 FAT sektora sa po 128 stavki na sektoru od 512 bajtova, dakle 13.952 adresibilnih sektora, dakle otprilike 6,8 MiB kontejnera pre nego što DIFAT mora da se preleva u sopstveni lanac
Druga tabela alokacije postoji jer sektori od 512 bajtova troše većinu svog prostora na male tokove. Svaki tok ispod granice od 4096 bajtova uopšte se ne čuva u sektorima: živi unutar mini toka, samog po sebi običnog toka koji visi na stavci korenog direktorijuma, podeljenog u mini sektore od 64 bajta i ulančanog kroz paralelni MiniFAT ukorenjen na ofsetu zaglavlja 0x3C. Otvorite pravi .xls i tok Workbook sedi na običnom FAT-u dok tokovi sumarnih informacija sede dole u prostoru mini sektora, zato implementacija koja pokriva samo FAT putanju izgleda da radi ispravno sve dok joj ne zatrebaju metapodaci dokumenta. Direktorijum je treća struktura i ona koja čini kontejner navigabilnim: svaka stavka je tačno 128 bajtova, četiri po sektoru od 512 bajtova, noseći UTF-16 ime u prvih 64 bajta, njegovu dužinu u bajtovima na 0x40, tip objekta na 0x42 (1 = storage, 2 = stream, 5 = root), veze stabla na 0x44, 0x48 i 0x4C, početni sektor na 0x74 i 32-bitnu veličinu toka na 0x78. Ta dužina imena broji bajtove uključujući terminišuću nulu, pa je broj karaktera NameLen div 2 - 1, i pogrešiti to za jedan je kako se završi sa tokom po imenu Workboo
Izvlačenje Workbook toka iz memorijskog bafera
TlxCompoundFile.OpenStream skriva sve gore navedeno iza jednog poziva koji uzima ime toka i vraća TlxCfbStream koji drži u potpunosti materijalizovane bajtove. Cela sekvenca — njuškanje, učitavanje, ekstrakcija — izvršava se nad TBytesStream, bez da išta ikad dotakne disk
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;
Dva detalja tu vrede spominjanja. LoadFromStream uzima zastavicu AOwnsStream koja je podrazumevano False, pa pozivalac zadržava odgovornost za izvorni tok — namerno, jer je uobičajen slučaj tok koji aplikacija već poseduje. A OpenStream vraća TlxCfbStream koji poseduje sopstvenu kopiju bajtova, izloženu kroz Data, Size, Read, Seek i CopyTo. Ta kopija je stvaran trošak na velikom workbooku, i to je iskrena cena dizajna gde vraćeni objekat ostaje validan pošto se kontejner oslobodi. Kada je workbook dovoljno velik da je kopija u potpunosti u memoriji pogrešan oblik, streaming direktan čitač za predimenzionisane tabele je bolja ulazna tačka
Zašto enkriptovan XLSX izgleda kao XLS fajl?
Zato što na nivou kontejnera to i jeste — a to je praktična isplativost posedovanja tog sloja. Otvorite enkriptovan .xlsx u hex editoru i prvih osam bajtova je D0 CF 11 E0 A1 B1 1A E1, bajt po bajt identično sa .xls iz 1997, jer [MS-OFFCRYPTO] enkripcija ne enkriptuje ZIP paket na licu mesta: umotava ceo paket unutar CFB kontejnera kao tok po imenu EncryptedPackage, pored toka EncryptionInfo koji opisuje algoritam. Potpis stoga identifikuje kontejner i ne govori ništa o sadržaju. Razlikovanje BIFF workbooka od enkriptovanog OOXML paketa znači čitanje direktorijuma, što je posle LoadFromStream sken preko EntryCount i Entries, ili par provera 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;
Imena direktorijuma zaslužuju sopstveno upozorenje: tokovi sumarnih informacija nose vodeći kontrolni karakter 0x05 u svojim imenima, pa poređenje napisano protiv običnog prikaznog stringa nikad neće naći poklapanje, a naivna log linija ih renderuje kao smeće. Sve nizvodno od ove klasifikacije — izvođenje ključa, provera verifikatora lozinke — odvojen je problem, pokriven u belešci o tome zašto Excel odbija workbook enkriptovan pogrešnim modom šifre. Sloj kontejnera vam samo govori pred kojim vratima stojite
Pisanje kontejnera koji će Excel zaista otvoriti
Strana pisanja TlxCompoundFile je namerno uža od strane čitanja, i razumevanje zašto štedi svađu sa specifikacijom. [MS-CFB] dozvoljava ogroman prostor validnih kontejnera: skladišta na više nivoa, ispravno balansirana crveno-crna stabla direktorijuma, mini tokove, DIFAT lance. Excel emituje mali ugao tog prostora, a čita ponešto veći. HotXLS piše ugao još manji — minimum koji Excel dokazano učitava. Svaki tok ide na obični FAT bez putanje mini toka, što košta prostor na disku, a kupuje ispravnost: tok sumarnih informacija od 300 bajtova koji bi Excel spakovao u pet mini sektora od 64 bajta umesto toga zauzima ceo sektor od 512 bajtova, a za workbook to je šum naspram održavanja druge tabele alokacije, drugog obilaska lanca i toka korene stavke koji ga podupire na putanji pisanja. Stavke direktorijuma formiraju ravan lanac braće pod korenom sa svakim čvorom obojenim crno, a redosled emitovanja je fiksan: mesto zaglavlja, sektori podataka toka, sektori direktorijuma, FAT sektori, zatim seek nazad da se prepiše zaglavlje sa ID-evima sektora koji su poznati tek na kraju. FAT određuje sopstvenu veličinu kroz kratku fiksnu petlju, jer dodavanje FAT sektora može podići broj sektora dovoljno visoko da zahteva još jedan FAT sektor
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;
Gde se implementacija zaustavlja
Tri granice vredi jasno navesti, jer čitač kontejnera koji tiho pogrešno obradi granični slučaj je gori od onog koji prijavi grešku. TlxCompoundFile čita 109 DIFAT stavki koje rezidiraju u zaglavlju i ne prati DIFAT lanac na 0x44 dalje od njih, ograničavajući čitljiv kontejner na otprilike 6,8 MiB na sektorima od 512 bajtova — udobno iznad stvarnih .xls fajlova koje HotXLS sreće na terenu, ali svejedno tvrda granica, a writer sprovodi isto ograničenje eksplicitno umesto da emituje kontejner koji ne može opisati. Drugo, kontejneri verzije 4 sa sektorima od 4096 bajtova su prihvaćeni aritmetikom veličine sektora, ali nisu ono za šta je kod podešen, a 64-bitna veličina toka se ne konsultuje: HotXLS čita donjih 32 bita na ofsetu 0x78 i ostavlja gornju polovinu na miru, što je ispravno za verziju 3 i samo za verziju 3. Treće, pretraga stavke je ravan sken po imenu preko liste direktorijuma umesto obilaska niz crveno-crno stablo od roditeljskog skladišta, pa se ugnežđena skladišta razrešavaju sudarom imena, a ne putanjom — svaki tok koji .xls fajl treba sedi na najvišem nivou, što čini jednostavniji dizajn odbranjivim, ali kod koji očekuje da adresira SomeStorage/SomeStream neće ga pronaći
Ništa od toga ne menja čemu unit služi. Posedovanje sloja kontejnera pretvara obradu .xls u običan Object Pascal: parsibilnu iz niza bajtova, testabilnu bez fajl sistema, prenosivu na koju god platformu kompajler cilja, i oslobođenu COM apartmana. Takođe penzioniše prečice njuškanja, jer identifikovanje workbooka sada znači čitanje njegovog direktorijuma, a ne prvih osam bajtova — ista disciplina iza listanja imena listova bez otvaranja celog workbooka
TlxCompoundFile isporučuje se kao deo HotXLS Excel Component za Delphi i C++Builder, zajedno sa BIFF i OOXML slojevima koji sede na njemu; proizvodna stranica nosi kompletnu referencu unita i podržanu matricu kompajlera