Technical Article

Read OLE2 Compound Files in Delphi Without COM IStorage

A legacy .xls uploaded to a REST endpoint arrives as a TBytes buffer, and the call most Delphi code reaches for next, StgOpenStorage, wants a file name, a thread with COM initialized and ole32.dll to load. A Linux build has no ole32.dll at all. The container itself is a documented format, [MS-CFB], and reading it straight from a TStream takes one 512-byte header, two allocation tables and a directory of 128-byte entries

None of those structures is hard on its own. The work is in a handful of offsets that look interchangeable on the files you test with and stop being interchangeable on the files your users send

Why does StgOpenStorage break on a server?

The file format is fine; the COM structured-storage API is what fails in the places modern Delphi code runs. StgOpenStorage is a Win32 entry point that takes a path, so a workbook held in memory has to be written to a temp file, opened, read back and deleted, and now the service owns a temp-file lifecycle that has to survive concurrent uploads and crashed requests. StgOpenStorageOnILockBytes is the documented way around the path, but it means implementing ILockBytes over a TMemoryStream, which is more COM interop than most teams want to maintain. Then comes the worker thread nobody called CoInitialize on. And the platform requirement ends the discussion for an FPC build on Linux, a container image or macOS

HotXLS reads and writes the container in Object Pascal through TlxCompoundFile in the lxCompoundFile unit. The class accepts version 3 (512-byte sectors) and version 4 (4096-byte sectors) files, works against any TStream, and never touches ole32.dll. Inside the library, the lxOLE storage layer binds to Windows structured storage on Windows and to this same class on every other target, and the encrypted-workbook code reads and writes its EncryptionInfo and EncryptedPackage streams through it on all platforms. For bytes that never touch disk, such as uploads, database BLOBs and mail attachments, call TlxCompoundFile directly on Windows too. A temp file written only so ole32.dll can read it back buys nothing

The header, and an offset formula that only holds on version 3

The first 512 bytes answer every structural question before a byte of payload is read. [MS-CFB] §2.2 fixes the signature at offset 0 as D0 CF 11 E0 A1 B1 1A E1. lxIsCompoundStream compares exactly those eight bytes and then seeks back by however many it read, so a sniff never moves the caller's position, even on a stream shorter than eight bytes. Four more fields decide the geometry: byte order at 0x1C must be 0xFFFE, sector shift at 0x1E is 9 or 12, mini sector shift at 0x20 is 6, and the mini stream cutoff at 0x38 is 4096. LoadFromStream accepts only those values and raises an exception for anything else ('CFB: unsupported sector size', 'CFB: bad byte order'). A malformed header with a shift of 0 or 31 would otherwise feed straight into sector arithmetic

Now the trap. Sector 0 starts right after the header, and on a version 3 file the header is 512 bytes and so is a sector, so 512 + N * SectorSize and (N + 1) * SectorSize give the same answer for every N. A parser can carry the first formula for years. On a version 4 file the header fields still occupy 512 bytes, but the format pads the header out to a full 4096-byte sector, so sector 0 begins at byte 4096. The 512-based formula lands 3,584 bytes early: inside the zero padding for sector 0, inside the previous sector for everything after it. TlxCompoundFile computes every sector address in one place as (N + 1) * SectorSize

Map of a Compound File Binary v3 container read by HotXLS in pure Pascal: the 512-byte header fields, DIFAT indexing the FAT sectors, FAT and MiniFAT chains, and the 128-byte directory entry layout
The header's DIFAT locates the FAT sectors, FAT chains reach large streams while MiniFAT reaches small ones, and the directory entry glues it all together

FAT chains, the DIFAT and the mini stream

A compound file is a FAT filesystem inside a file. FAT[n] holds the ID of the sector that follows sector n, and a stream is a linked list that starts at the sector its directory entry names and ends at ENDOFCHAIN. Read as signed 32-bit integers, the sentinels are all negative: -1 free, -2 end of chain, -3 a sector that belongs to the FAT itself, -4 a DIFAT sector. The FAT sectors are located through the DIFAT, whose first 109 entries live in the header at 0x4C. With 128 entries per 512-byte FAT sector that covers 13,952 sectors, about 6.8 MiB of container. Past that point the DIFAT continues as a chain of its own starting at header offset 0x44, and the reader follows it

Malformed files mostly go wrong here. A chain whose last link points back to an earlier sector turns a naive while loop into a hang, and two streams sharing a sector turn one corrupt file into two wrong payloads. TlxCompoundFile tracks visited sectors on every chain it walks (FAT, DIFAT, MiniFAT, directory and stream) and raises an exception that names the defect, such as 'CFB: cyclic stream chain' or 'CFB: shared FAT or DIFAT sector'. A header that claims an absurd number of FAT sectors stops at MaxReaderMetadataBytes, which caps the memory spent on allocation tables and directory entries and defaults to 256 MiB. Lower it for an upload endpoint; a real .xls needs a tiny fraction of that

Streams under the 4096-byte cutoff are not stored in regular sectors. They live in the mini stream, an ordinary stream owned by the root directory entry, cut into 64-byte mini sectors and chained through a second table, the MiniFAT, whose first sector is named at header offset 0x3C. In a typical .xls the Workbook stream sits on the FAT while the two summary-information streams sit in mini-sector space. A parser that handles only the FAT path reads workbooks correctly and then fails the first time someone asks for the document title

The directory ties the rest together. Each entry is exactly 128 bytes: a UTF-16 name in the first 64, the name length in bytes at 0x40, the object type at 0x42 (1 storage, 2 stream, 5 root), left, right and child links at 0x44, 0x48 and 0x4C, the start sector at 0x74 and the stream size at 0x78. The name length counts the terminating null, so the character count is NameLen div 2 - 1; drop the - 1 and every name picks up a trailing #0, or subtract one too many and you are looking for a stream called Workboo. The size field is 64 bits on version 4 files and the reader takes all eight bytes there, while on version 3 only the low 32 bits are defined. Lookup follows the format's own ordering, shorter names before longer ones and then a case-insensitive comparison, which lxCfbCompareNames exposes. OpenStream('workbook') therefore finds Workbook. Nested storages resolve by path: FindStorage returns the index of a sub-storage, and the OpenStream and HasStream overloads that take a ParentIndex reach the streams beneath it

Pulling a Workbook stream out of a memory buffer

LoadFromStream reads the header, FAT, directory and MiniFAT, and OpenStream returns a TlxCfbStream holding the stream's bytes. The whole sequence below runs against a TBytesStream, and nothing reaches the disk

Flow chart of HotXLS extracting the Workbook stream from an in-memory Compound File buffer in Delphi: signature sniff, LoadFromStream, directory lookup by name, and the mini-stream versus FAT decision
The caller sniffs the signature, LoadFromStream reads the header, FAT, directory and MiniFAT, then OpenStream walks either the MiniFAT or the FAT chain to materialize the stream bytes
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);         // raises on a malformed container
      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;

LoadFromStream takes an AOwnsStream flag that defaults to False, so the caller keeps the source stream, and it puts the source position back where it found it once loading finishes. OpenStream returns nil when no stream of that name exists, and the returned TlxCfbStream owns its own copy, exposed through Data, Size, Read, Seek and CopyTo. That copy is the price of an object that stays valid after the container is freed, and for a few megabytes of BIFF it is the right price

For a stream large enough that a second in-memory copy hurts, CopyEntryToStream writes the validated chain into any destination stream in bounded chunks, and CopyEntryRangeToStream does the same for an Int64 offset and count. OpenStream stops at streams up to MaxInt bytes, so anything beyond 2 GiB has to go this way. Both take a directory index, which a scan over Entries provides:

function CopyWorkbookTo(Cfb: TlxCompoundFile; Dest: TStream): Int64;
var
  I: Integer;
  E: TlxCfbEntry;
begin
  Result:= -1;
  for I:= 0 to Cfb.EntryCount - 1 do
  begin
    E:= Cfb.Entries(I);
    if (E.EntryType = cfbStream) and (E.ParentIndex = Cfb.RootIndex) and
       (lxCfbCompareNames(E.Name, 'Workbook') = 0) then
      Exit(Cfb.CopyEntryToStream(I, Dest)); // bytes written
  end;
end;

The source stream passed to LoadFromStream must still be alive when CopyEntryToStream runs, because the payload is read from it on demand. The streaming direct reader for oversized spreadsheets takes the same idea further, past the container layer and into the BIFF records

Why does an encrypted XLSX look like an XLS file?

An encrypted .xlsx starts with D0 CF 11 E0 A1 B1 1A E1 because it really is a compound file. [MS-OFFCRYPTO] does not encrypt the ZIP package in place: it stores the encrypted package as a stream named EncryptedPackage inside a CFB container, next to an EncryptionInfo stream that describes the algorithm. Code that classifies files by their first eight bytes calls it a legacy workbook, hands it to a BIFF parser, and gets an error about a missing Workbook stream instead of a password prompt. The signature identifies the container. The payload is a directory question

Classification diagram for HotXLS in Delphi: identical Compound File signatures hiding either a BIFF Workbook stream or an encrypted OOXML EncryptedPackage, distinguished only by directory names
The same eight signature bytes open two doors: a directory scan separates cpBiffWorkbook from cpEncryptedOoxml
type
  TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);

function ClassifyContainer(AStream: TStream): TCfbPayload;
var
  Cfb: TlxCompoundFile;
begin
  Result:= cpUnknown;
  if not lxIsCompoundStream(AStream) then
    Exit;
  Cfb:= TlxCompoundFile.Create;
  try
    Cfb.LoadFromStream(AStream);
    if Cfb.HasStream('EncryptedPackage') and Cfb.HasStream('EncryptionInfo') then
      Result:= cpEncryptedOoxml
    else if Cfb.HasStream('Workbook') or Cfb.HasStream('Book') then
      Result:= cpBiffWorkbook;
  finally
    Cfb.Free;
  end;
end;

HasStream uses the same case-insensitive lookup as OpenStream; a hand-rolled loop with a plain = on E.Name is stricter than the format. The summary-information streams need care too, because their names start with a 0x05 control character, so the lookup string is #5'SummaryInformation', and a log line that prints directory names raw shows a box or nothing in front of them. What happens after cpEncryptedOoxml (deriving the key, checking the password verifier) belongs to a different layer, covered in why Excel rejects a workbook encrypted with the wrong cipher mode

Writing a container Excel will open

The writer follows the layout Excel produces itself rather than every variation [MS-CFB] allows. Streams below 4096 bytes go into the mini stream, so a 300-byte summary stream takes five 64-byte mini sectors instead of a whole 512-byte sector, and larger streams go onto the FAT. The children of each storage are written as a balanced binary tree over names sorted in the format's length-first order, colored so that the result is a valid red-black tree without any rebalancing. Output stays on version 3 with 512-byte sectors until the sector count would push the file past 2 GiB, and only then does Save switch to 4096-byte sectors, extend the 109 header DIFAT slots with DIFAT sectors and leave the range-lock sector at 0x7FFFFF00 unused, as the format requires. FAT and DIFAT sizes come from a short fixed-point loop, because adding FAT sectors can raise the sector count enough to need one more

procedure WrapBiffFile(const BiffPath, Dest: string);
var
  Src, FS: TFileStream;
  Cfb: TlxCompoundFile;
begin
  Src:= TFileStream.Create(BiffPath, fmOpenRead or fmShareDenyWrite);
  try
    FS:= TFileStream.Create(Dest, fmCreate);
    try
      Cfb:= TlxCompoundFile.Create;
      try
        Cfb.CreateNew(FS);                // root entry is index 0
        // Borrows the range; Src must stay open until Save returns
        Cfb.AddStreamFromStream('Workbook', Src, 0, Src.Size);
        Cfb.Save;                         // data, directory, FAT, then header
      finally
        Cfb.Free;
      end;
    finally
      FS.Free;
    end;
  finally
    Src.Free;
  end;
end;

AddStreamFromStream queues a byte range of an existing stream without reading it, and Save copies it through a 64 KiB buffer. The source has to be a different stream from the destination; AddStreamFromStream raises 'CFB writer: source equals destination' otherwise. For payloads already in memory, AddStream takes a TBytes; AddStorage creates a sub-storage and returns its index for the ParentIndex overloads. MaxWriterEntries (1,000,000 by default) and MaxWriterMetadataBytes (256 MiB) bound what a single Save will plan

With the container in plain Object Pascal, .xls handling parses from a byte array, runs in tests without a filesystem, compiles for whatever the compiler targets and needs no COM apartment. Identification changes with it: a workbook is recognized by its directory, the same approach used for listing sheet names without opening the whole workbook

TlxCompoundFile and the BIFF and OOXML readers built on it are part of the HotXLS Delphi Excel Component for Delphi, C++Builder and Lazarus, where the product page lists the supported compilers and target platforms