HotXLS Excel Library voor Delphi en C++Builder leest en schrijft de Compound File Binary-container achter elk legacy .xls-bestand in pure Object Pascal. De klasse TlxCompoundFile implementeert de [MS-CFB]-versie-3-lay-out rechtstreeks tegen een TStream — header, DIFAT, FAT-ketens, MiniFAT en de directoryboom — zonder ole32.dll en zonder COM-IStorage waar dan ook in het pad
Dat klinkt als loodgieterswerk, en twintig jaar lang was het loodgieterswerk dat iemand anders bezat. Elke Delphi-codebase die een .xls-bestand aanraakte, greep naar StgOpenStorage, kreeg een IStorage terug, en trok de Workbook-stream eruit. Drie regels, werkte prima, niemand dacht er nog aan — tot de dag dat dezelfde code ergens moest draaien waar geen Windows was
Waarom stopt StgOpenStorage met werken op een server?
De COM structured-storage-API faalt precies in de deploymentvormen waarin moderne Delphi-code leeft, om redenen die niets te maken hebben met het bestandsformaat. StgOpenStorage is een Win32-entrypunt in ole32.dll: het wil een pad op een bestandssysteem, het wil COM geïnitialiseerd op de aanroepende thread, en het wil op Windows draaien. De padvereiste doet het eerst pijn, want een REST-endpoint dat een geüploade workbook ontvangt, heeft de bytes in een buffer, niet op schijf — dus schrijf je de buffer naar een tijdelijk bestand, open je het, lees je het terug, verwijder je het, en bezit je nu een lifecycle voor tijdelijke bestanden die onder belasting fout kan gaan. ILockBytes is het gedocumenteerde ontsnappingsluik, maar een aangepaste implementatie bekabelen over een TMemoryStream is meer COM-interop dan de meeste teams willen. De initialisatievereiste bijt als tweede, meestal in een service-werkthread waarop niemand CoInitialize aangeroepen heeft, en de platformvereiste beëindigt het gesprek op het moment dat het doel Linux onder FPC is, een containerimage, of macOS. HotXLS houdt daarom het klassieke lxOLE-pad gebouwd op StgOpenStorage als standaard, aangezien het slagvaardig getest is en bestaande aanroepers niet zouden moeten hoeven te veranderen; TlxCompoundFile is het opt-in alternatief voor iedereen die anders werkt
Wat de header en de FAT-ketens daadwerkelijk zeggen
De eerste 512 bytes van een compound file beantwoorden elke structurele vraag die je nodig hebt voordat je een byte payload leest. [MS-CFB] §2.2 legt de header-signatuur op offset 0 vast als de acht bytes D0 CF 11 E0 A1 B1 1A E1, en lxIsCompoundStream controleert precies dat, en herstelt daarna de streampositie zodat een aanroeper kan snuffelen zonder iets te verstoren. Vier verdere velden bepalen de geometrie: byte-volgorde op 0x1C moet 0xFFFE zijn, wat dienst doet als een goedkope tweede signatuurcontrole; sectorshift op 0x1E geeft de sectorgrootte als 1 shl SectorShift, dus gebruikt versie 3 shift 9 voor sectoren van 512 bytes en versie 4 shift 12 voor 4096; mini-sectorshift op 0x20 is 6, wat mini-sectoren van 64 bytes maakt; en de mini-stream-afkapgrens op 0x38 is 4096. De adresrekenkunde die daarop volgt is de meest voorkomende plek om het fout te doen. Sector 0 begint direct na de header, dus begint sector N op byte-offset 512 + N * SectorSize — let op de letterlijke 512, niet SectorSize. Op een versie-3-bestand zijn de twee identiek en verschuilt de bug zich voor altijd; op een versie-4-bestand leest het stilzwijgend de verkeerde sector, wat is waarom HotXLS dit in één functie houdt, SidToOffset
Een compound file is een FAT-bestandssysteem binnen een bestand, dus het lezen ervan betekent het aflopen van gekoppelde lijsten van sector-ID's waar FAT[n] het ID vasthoudt dat op sector n volgt. Drie sentinelwaarden beëindigen of annoteren een keten — ENDOFCHAIN, FATSECT voor een sector die bij de FAT zelf hoort, en DIFSECT voor een DIFAT-sector — en alle drie lezen als negatieve signed 32-bit integers, wat de lusvoorwaarden eenvoudig houdt. Het vinden van de FAT vereist nog één indirectie: de DIFAT is de array van sector-ID's die zegt waar de FAT-sectoren wonen, en de eerste 109 entries ervan zitten in de header op offset 0x4C. TlxCompoundFile loopt door die 109, stopt bij de eerste negatieve entry, en voegt elke FAT-sector samen tot één platte Integer-array. Dat is 109 FAT-sectoren met elk 128 entries op een sector van 512 bytes, dus ongeveer 13.952 adresseerbare sectoren, dus ruwweg 6,8 MiB aan container voordat de DIFAT moet overlopen naar een eigen keten
De tweede allocatietabel bestaat omdat sectoren van 512 bytes het meeste van hun ruimte verspillen aan kleine streams. Elke stream onder de afkapgrens van 4096 bytes wordt helemaal niet in sectoren opgeslagen: hij leeft binnen de mini-stream, zelf een gewone stream die aan de root-directory-entry hangt, onderverdeeld in mini-sectoren van 64 bytes en gekoppeld via een parallelle MiniFAT geworteld op header-offset 0x3C. Open een echte .xls en de Workbook-stream zit op de normale FAT terwijl de summary-information-streams beneden in mini-sector-ruimte zitten, wat is waarom een implementatie die alleen het FAT-pad dekt correct lijkt te werken totdat hij documentmetadata nodig heeft. De directory is de derde structuur en degene die de container navigeerbaar maakt: elke entry is precies 128 bytes, vier per sector van 512 bytes, en draagt een UTF-16-naam in de eerste 64 bytes, zijn bytelengte op 0x40, het objecttype op 0x42 (1 = storage, 2 = stream, 5 = root), boomlinks op 0x44, 0x48 en 0x4C, de startsector op 0x74 en de 32-bits streamgrootte op 0x78. Die naamlengte telt bytes inclusief de afsluitende null, dus is het tekenaantal NameLen div 2 - 1, en er één naast zitten is hoe je eindigt met een stream genaamd Workboo
Een Workbook-stream trekken uit een geheugenbuffer
TlxCompoundFile.OpenStream verbergt al het bovenstaande achter één aanroep die een streamnaam neemt en een TlxCfbStream teruggeeft die de volledig gematerialiseerde bytes bevat. De hele reeks — snuffelen, laden, extraheren — draait tegen een TBytesStream zonder dat er ooit iets de schijf raakt
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;
Twee details daar zijn het waard om te benoemen. LoadFromStream neemt een AOwnsStream-vlag met standaard False, dus behoudt de aanroeper de verantwoordelijkheid voor de bronstream — bewust, omdat het gangbare geval een stream is die de applicatie al bezit. En OpenStream retourneert een TlxCfbStream die zijn eigen kopie van de bytes bezit, blootgesteld via Data, Size, Read, Seek en CopyTo. Die kopie is een echte kost bij een grote workbook, en het is de eerlijke prijs van een ontwerp waarbij het geretourneerde object geldig blijft nadat de container vrijgegeven is. Wanneer een workbook groot genoeg is dat een volledige in-memory kopie de verkeerde vorm is, is de streaming direct reader voor te grote spreadsheets het betere instappunt
Waarom lijkt een versleutelde XLSX op een XLS-bestand?
Omdat het er op containerniveau daadwerkelijk een is — en dit is de praktische opbrengst van het bezitten van die laag. Open een versleutelde .xlsx in een hex-editor en de eerste acht bytes zijn D0 CF 11 E0 A1 B1 1A E1, byte voor byte identiek aan een .xls van vintage 1997, omdat [MS-OFFCRYPTO]-versleuteling het ZIP-pakket niet ter plekke versleutelt: het wikkelt het hele pakket in een CFB-container als een stream genaamd EncryptedPackage, naast een EncryptionInfo-stream die het algoritme beschrijft. De signatuur identificeert dus de container en zegt niets over de payload. Een BIFF-workbook onderscheiden van een versleuteld OOXML-pakket betekent het lezen van de directory, wat na LoadFromStream een scan is over EntryCount en Entries, of een paar HasStream-probes
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;
Directorynamen verdienen hun eigen waarschuwing: de summary-information-streams dragen een leidend 0x05-besturingsteken in hun namen, dus zal een vergelijking geschreven tegen een gewone weergavestring ze nooit matchen en rendert een naïeve logregel ze als rommel. Alles stroomafwaarts van deze classificatie — het afleiden van de sleutel, het controleren van de wachtwoordverificatie — is een apart probleem, behandeld in de notities over waarom Excel een workbook versleuteld met de verkeerde ciphermodus afwijst. De containerlaag vertelt je alleen voor welke deur je staat
Een container schrijven die Excel daadwerkelijk zal openen
De schrijfkant van TlxCompoundFile is bewust smaller dan de leeskant, en begrijpen waarom bespaart een discussie met de spec. [MS-CFB] staat een enorme ruimte aan geldige containers toe: multi-level storages, correct gebalanceerde rood-zwart-directoryboomstructuren, mini-streams, DIFAT-ketens. Excel zendt een klein hoekje van die ruimte uit en leest een iets grotere. HotXLS schrijft een hoekje dat nog kleiner is — het minimum dat Excel aantoonbaar laadt. Elke stream gaat op de normale FAT zonder mini-stream-pad, wat schijfruimte kost en correctheid oplevert: een summary-stream van 300 bytes die Excel in vijf mini-sectoren van 64 bytes zou hebben verpakt, neemt in plaats daarvan een volle sector van 512 bytes in beslag, en voor een workbook is dat ruis naast het onderhouden van een tweede allocatietabel, een tweede kettingdoorloop en de root-entry-stream die het ondersteunt op het schrijfpad. Directoryentries vormen een platte sibling-keten onder de root met elke node zwart gekleurd, en de uitzendvolgorde ligt vast: header-placeholder, streamdatasectoren, directorysectoren, FAT-sectoren, en dan een seek terug om de header te herschrijven met de sector-ID's die pas aan het einde bekend zijn. De FAT bepaalt zijn eigen grootte via een korte fixed-point-lus, omdat het toevoegen van FAT-sectoren het sectoraantal hoog genoeg kan opdrijven om nog een FAT-sector te vereisen
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;
Waar de implementatie stopt
Drie grenzen zijn het waard om ronduit te noemen, want een containerlezer die stilzwijgend een randgeval verkeerd afhandelt is erger dan een die alarm slaat. TlxCompoundFile leest de 109 DIFAT-entries die in de header wonen en volgt de DIFAT-keten op 0x44 niet verder dan die, wat een leesbare container begrenst op ruwweg 6,8 MiB op sectoren van 512 bytes — comfortabel boven de echte .xls-bestanden die HotXLS in het veld tegenkomt, maar niettemin een harde grens, en de writer handhaaft dezelfde limiet expliciet in plaats van een container uit te zenden die hij niet kan beschrijven. Ten tweede worden versie-4-containers met sectoren van 4096 bytes opgevangen door de sectorgrootte-rekenkunde maar zijn ze niet waar de code op afgestemd is, en de 64-bits streamgrootte wordt niet geraadpleegd: HotXLS leest de lage 32 bits op offset 0x78 en laat de hoge helft met rust, wat correct is voor versie 3 en alleen voor versie 3. Ten derde is entry-lookup een platte scan op naam over de directorylijst in plaats van een doorloop van de rood-zwart-boom vanaf een parent-storage, dus lossen geneste storages op via naamcollisie in plaats van via pad — elke stream die een .xls-bestand nodig heeft, zit op het topniveau, wat het eenvoudigere ontwerp verdedigbaar maakt, maar code die verwacht SomeStorage/SomeStream te kunnen adresseren, zal dat niet vinden
Niets daarvan verandert waar de unit voor dient. Het bezitten van de containerlaag verandert .xls-afhandeling in gewoon Object Pascal: parseerbaar vanuit een byte-array, testbaar zonder bestandssysteem, overdraagbaar naar welk platform de compiler ook target, en vrij van een COM-apartment. Het pensioneert ook de snuffel-shortcuts, omdat het identificeren van een workbook nu betekent zijn directory lezen in plaats van zijn eerste acht bytes — dezelfde discipline achter het opsommen van bladnamen zonder de hele workbook te openen
TlxCompoundFile wordt geleverd als onderdeel van het HotXLS Excel Component voor Delphi en C++Builder, naast de BIFF- en OOXML-lagen die erbovenop zitten; de productpagina bevat de volledige unit-referentie en de ondersteunde compilermatrix