HotXLS Excel Library för Delphi och C++Builder läser och skriver Compound File Binary-behållaren bakom varje äldre .xls-fil i ren Object Pascal. Klassen TlxCompoundFile implementerar [MS-CFB] version 3-layouten direkt mot en TStream — header, DIFAT, FAT-kedjor, MiniFAT och katalogträdet — utan ole32.dll och utan COM IStorage någonstans i kedjan
Det låter som rörläggning, och i tjugo år var det rörläggning som någon annan ägde. Varje Delphi-kodbas som rörde vid en .xls-fil grep efter StgOpenStorage, fick tillbaka en IStorage och drog ut Workbook-strömmen ur den. Tre rader, fungerade fint, ingen tänkte mer på det — tills den dag samma kod behövde köra någonstans där Windows inte fanns
Varför slutar StgOpenStorage fungera på en server?
COM:s API för strukturerad lagring misslyckas i precis de driftsformer som modern Delphi-kod lever i, av skäl som inte har något med filformatet att göra. StgOpenStorage är en Win32-entrypunkt i ole32.dll: den vill ha en sökväg på ett filsystem, den vill att COM ska vara initierat på det anropande tråden, och den vill vara på Windows. Sökvägskravet gör ont först, eftersom en REST-slutpunkt som tar emot en uppladdad arbetsbok har byten i en buffert, inte på disk — så du skriver bufferten till en temporär fil, öppnar den, läser tillbaka den, tar bort den, och äger nu en temporärfils-livscykel att göra fel under belastning. ILockBytes är den dokumenterade utvägen, men att koppla upp en egen implementation ovanpå en TMemoryStream är mer COM-interop än de flesta team vill ha. Initieringskravet biter näst, oftast i en tjänstearbetartråd som ingen kallat CoInitialize på, och plattformskravet avslutar samtalet i samma stund målet är Linux under FPC, en containerbild eller macOS. HotXLS behåller därför den klassiska lxOLE-vägen byggd på StgOpenStorage som standard, eftersom den är stridstestad och befintliga anropare inte ska behöva ändra sig; TlxCompoundFile är opt-in-alternativet för alla andra
Vad headern och FAT-kedjorna faktiskt säger
De första 512 byten av en sammansatt fil besvarar varje strukturfråga du behöver innan du läser en enda byte av innehållet. [MS-CFB] §2.2 fixerar header-signaturen vid offset 0 till de åtta byten D0 CF 11 E0 A1 B1 1A E1, och lxIsCompoundStream kontrollerar precis det, och återställer strömpositionen efteråt så en anropare kan sniffa utan att störa någonting. Fyra fält till avgör geometrin: byteordning vid 0x1C måste vara 0xFFFE, vilket dubblerar som en billig andra signaturkontroll; sektorväxling vid 0x1E ger sektorstorleken som 1 shl SectorShift, så version 3 använder växling 9 för 512-byte-sektorer och version 4 använder växling 12 för 4096; miniSektorväxling vid 0x20 är 6, vilket gör minisektorer 64 byte; och mini-strömgränsen vid 0x38 är 4096. Adressaritmetiken som följer är den vanligaste platsen att göra fel. Sektor 0 börjar omedelbart efter headern, så sektor N börjar vid byteoffset 512 + N * SectorSize — notera det bokstavliga 512:an, inte SectorSize. I en version 3-fil är de två identiska och buggen döljer sig för evigt; i en version 4-fil läser den tyst fel sektor, vilket är varför HotXLS håller detta i en enda funktion, SidToOffset
En sammansatt fil är ett FAT-filsystem inuti en fil, så att läsa den innebär att gå igenom länkade listor av sektor-ID:n där FAT[n] håller ID:t som följer sektor n. Tre vaktposter avslutar eller annoterar en kedja — ENDOFCHAIN, FATSECT för en sektor som tillhör själva FAT:en, och DIFSECT för en DIFAT-sektor — och alla tre läses som negativa signerade 32-bitars heltal, vilket håller loopvillkoren enkla. Att hitta FAT:en kräver ytterligare en indirektion: DIFAT är arrayen av sektor-ID:n som säger var FAT-sektorerna bor, och dess första 109 poster sitter i headern vid offset 0x4C. TlxCompoundFile går igenom dessa 109, stannar vid den första negativa posten, och sammanfogar varje FAT-sektor till en platt Integer-array. Det är 109 FAT-sektorer med 128 poster vardera i en 512-byte-sektor, alltså 13 952 adresserbara sektorer, alltså ungefär 6,8 MiB behållare innan DIFAT måste svämma över till en egen kedja
Den andra tilldelningstabellen finns eftersom 512-byte-sektorer slösar det mesta av sitt utrymme på små strömmar. Varje ström under 4096-byte-gränsen lagras inte i sektorer alls: den bor inuti mini-strömmen, själv en vanlig ström som hänger av rotkatalogposten, uppdelad i 64-byte-minisektorer och kedjad genom en parallell MiniFAT rotad vid header-offset 0x3C. Öppna en riktig .xls och Workbook-strömmen sitter på den vanliga FAT:en medan sammanfattnings-informationsströmmarna sitter nere i mini-sektorutrymme, vilket är varför en implementation som bara täcker FAT-vägen verkar fungera ända tills den behöver dokumentmetadata. Katalogen är den tredje strukturen och den som gör behållaren navigerbar: varje post är exakt 128 byte, fyra per 512-byte-sektor, med ett UTF-16-namn i de första 64 byten, dess bytelängd vid 0x40, objekttypen vid 0x42 (1 = lagring, 2 = ström, 5 = rot), trädlänkar vid 0x44, 0x48 och 0x4C, startsektorn vid 0x74 och den 32-bitars strömstorleken vid 0x78. Den namnlängden räknar byte inklusive den avslutande nollan, så teckenantalet är NameLen div 2 - 1, och att räkna fel med ett gör att man hamnar med en ström som heter Workboo
Att dra ut en Workbook-ström ur en minnesbuffert
TlxCompoundFile.OpenStream döljer allt ovanstående bakom ett enda anrop som tar ett strömnamn och returnerar en TlxCfbStream som håller de fullt materialiserade byten. Hela sekvensen — sniffa, ladda, extrahera — körs mot en TBytesStream utan att någonting någonsin rör 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;
Två detaljer där är värda att lyfta fram. LoadFromStream tar en AOwnsStream-flagga som är False som standard, så anroparen behåller ansvaret för källströmmen — avsiktligt, eftersom det vanliga fallet är en ström applikationen redan äger. Och OpenStream returnerar en TlxCfbStream som äger sin egen kopia av byten, exponerad via Data, Size, Read, Seek och CopyTo. Den kopian är en verklig kostnad på en stor arbetsbok, och det är det ärliga priset för en design där det returnerade objektet förblir giltigt efter att behållaren frigjorts. När en arbetsbok är stor nog att en fullständig kopia i minnet är helt fel form är den strömmande direktläsaren för överdimensionerade kalkylblad den bättre ingångspunkten
Varför ser en krypterad XLSX ut som en XLS-fil?
Därför att den är det, på behållarnivå — och det är den praktiska vinsten av att äga det lagret. Öppna en krypterad .xlsx i en hexeditor och de första åtta byten är D0 CF 11 E0 A1 B1 1A E1, byte för byte identiska med en .xls från 1997, eftersom [MS-OFFCRYPTO]-kryptering inte krypterar ZIP-paketet på plats: den lindar in hela paketet inuti en CFB-behållare som en ström vid namn EncryptedPackage, bredvid en EncryptionInfo-ström som beskriver algoritmen. Signaturen identifierar därför behållaren och säger ingenting om innehållet. Att skilja en BIFF-arbetsbok från ett krypterat OOXML-paket innebär att läsa katalogen, vilket efter LoadFromStream är en genomgång av EntryCount och Entries, eller ett par HasStream-sonderingar
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;
Katalognamn förtjänar en egen varning: sammanfattnings-informationsströmmarna bär ett inledande 0x05-styrtecken i sina namn, så en jämförelse skriven mot en vanlig visningssträng kommer aldrig att matcha dem, och en naiv loggrad renderar dem som skräp. Allt nedströms från denna klassificering — härledning av nyckeln, kontroll av lösenordsverifieraren — är ett separat problem, behandlat i anteckningarna om varför Excel avvisar en arbetsbok krypterad med fel chiffreringsläge. Behållarlagret talar bara om vilken dörr du står framför
Att skriva en behållare som Excel faktiskt öppnar
Skrivsidan av TlxCompoundFile är avsiktligt smalare än läsningssidan, och att förstå varför sparar en diskussion med specifikationen. [MS-CFB] tillåter ett enormt utrymme av giltiga behållare: flernivåslagringar, korrekt balanserade röd-svarta katalogträd, mini-strömmar, DIFAT-kedjor. Excel skriver ut ett litet hörn av det utrymmet och läser ett något större. HotXLS skriver ett hörn ännu mindre — minimumet Excel bevisligen laddar. Varje ström hamnar på den vanliga FAT:en utan minisström-väg, vilket kostar diskutrymme och köper korrekthet: en 300-byte sammanfattningsström som Excel skulle ha paketerat i fem 64-byte-minisektorer upptar istället en hel 512-byte-sektor, och för en arbetsbok är det brus jämfört med att underhålla en andra tilldelningstabell, en andra kedjegenomgång och rotpostens ström som stödjer den på skrivvägen. Katalogposter bildar en platt syskonkedja under roten med varje nod färgad svart, och emissionsordningen är fast: header-platshållare, strömdatasektorer, katalogsektorer, FAT-sektorer, sedan en sökning tillbaka för att skriva om headern med de sektor-ID:n som bara är kända i slutet. FAT:en dimensionerar sig själv genom en kort fastpunktsloop, eftersom att lägga till FAT-sektorer kan trycka upp sektorantalet tillräckligt högt för att kräva ytterligare en 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;
Var implementationen stannar
Tre gränser är värda att säga rakt ut, eftersom en behållarläsare som tyst misshanterar ett gränsfall är värre än en som höjer ett undantag. TlxCompoundFile läser de 109 DIFAT-posterna som finns i headern och följer inte DIFAT-kedjan vid 0x44 bortom dem, vilket sätter ett tak för en läsbar behållare på ungefär 6,8 MiB på 512-byte-sektorer — bekvämt över de riktiga .xls-filer HotXLS möter i fält, men ett hårt tak likväl, och skrivaren tillämpar samma gräns uttryckligen snarare än att skriva ut en behållare den inte kan beskriva. För det andra hanteras version 4-behållare med 4096-byte-sektorer av sektorstorlekens aritmetik men är inte vad koden är inställd på, och den 64-bitars strömstorleken konsulteras inte: HotXLS läser de låga 32 bitarna vid offset 0x78 och lämnar den övre halvan orörd, vilket är korrekt för version 3 och bara för version 3. För det tredje är postuppslagning en platt genomsökning efter namn över kataloglistan snarare än en gång ner genom det röd-svarta trädet från en föräldralagring, så nästlade lagringar löses genom namnkollision snarare än via sökväg — varje ström en .xls-fil behöver sitter på toppnivå, vilket är det som gör den enklare designen försvarbar, men kod som förväntar sig att adressera SomeStorage/SomeStream kommer inte att hitta det
Inget av det ändrar vad enheten är till för. Att äga behållarlagret gör .xls-hantering till vanlig Object Pascal: parsningsbar från en bytearray, testbar utan filsystem, portabel till vilken plattform kompilatorn än riktar sig mot, och fri från ett COM-apartment. Det pensionerar också sniffningsgenvägarna, eftersom att identifiera en arbetsbok nu betyder att läsa dess katalog snarare än dess första åtta byte — samma disciplin som ligger bakom att lista arknamn utan att öppna hela arbetsboken
TlxCompoundFile levereras som en del av HotXLS Excel Component för Delphi och C++Builder, tillsammans med BIFF- och OOXML-lagren som sitter ovanpå den; produktsidan innehåller den fullständiga enhetsreferensen och den kompatibla kompilatormatrisen