Technischer Artikel

OLE2-Compound-Files in Delphi lesen, ohne COM IStorage

Die HotXLS Excel Library für Delphi und C++Builder liest und schreibt den Compound-File-Binary-Container hinter jeder Legacy-.xls-Datei in reinem Object Pascal. Die Klasse TlxCompoundFile implementiert das [MS-CFB]-Version-3-Layout direkt gegen einen TStream – Header, DIFAT, FAT-Ketten, MiniFAT und Verzeichnisbaum – ohne ole32.dll und ohne COM-IStorage irgendwo im Pfad

Das klingt nach Klempnerarbeit, und zwanzig Jahre lang war es Klempnerarbeit, die jemand anders besaß. Jede Delphi-Codebasis, die eine .xls-Datei anfasste, griff zu StgOpenStorage, erhielt ein IStorage zurück und zog den Workbook-Stream heraus. Drei Zeilen, funktionierte tadellos, niemand dachte je wieder darüber nach – bis zu dem Tag, an dem derselbe Code irgendwo laufen musste, wo kein Windows war

Warum funktioniert StgOpenStorage auf einem Server nicht mehr?

Die COM-Structured-Storage-API scheitert genau in den Deployment-Formen, in denen modernes Delphi heute lebt, aus Gründen, die nichts mit dem Dateiformat zu tun haben. StgOpenStorage ist ein Win32-Einstiegspunkt in ole32.dll: Er will einen Pfad im Dateisystem, er will COM auf dem aufrufenden Thread initialisiert, und er will auf Windows laufen. Die Pfadanforderung schmerzt zuerst, denn ein REST-Endpunkt, der ein hochgeladenes Workbook empfängt, hat die Bytes in einem Puffer, nicht auf der Festplatte – also schreibt man den Puffer in eine temporäre Datei, öffnet sie, liest sie zurück, löscht sie, und besitzt nun einen Lebenszyklus für temporäre Dateien, den man unter Last falsch machen kann. ILockBytes ist die dokumentierte Ausweichmöglichkeit, aber eine eigene Implementierung über einen TMemoryStream zu verdrahten ist mehr COM-Interop, als die meisten Teams wollen. Die Initialisierungsanforderung beißt als Zweites, meist in einem Service-Worker-Thread, auf dem niemand CoInitialize aufgerufen hat, und die Plattformanforderung beendet das Gespräch, sobald das Ziel Linux unter FPC, ein Container-Image oder macOS ist. HotXLS behält daher den klassischen lxOLE-Pfad auf Basis von StgOpenStorage als Standard bei, weil er kampferprobt ist und bestehende Aufrufer sich nicht ändern müssen; TlxCompoundFile ist die Opt-in-Alternative für alle anderen

Was Header und FAT-Ketten tatsächlich verraten

Die ersten 512 Bytes einer Compound-Datei beantworten jede strukturelle Frage, die man braucht, bevor man auch nur ein Byte Nutzlast liest. [MS-CFB] §2.2 legt die Header-Signatur bei Offset 0 auf die acht Bytes D0 CF 11 E0 A1 B1 1A E1 fest, und lxIsCompoundStream prüft genau das, wobei die Stream-Position anschließend zurückgesetzt wird, sodass ein Aufrufer schnüffeln kann, ohne etwas zu stören. Vier weitere Felder entscheiden die Geometrie: die Byte-Reihenfolge bei 0x1C muss 0xFFFE sein, was zugleich als günstige zweite Signaturprüfung dient; die Sektor-Shift bei 0x1E gibt die Sektorgröße als 1 shl SectorShift an, sodass Version 3 Shift 9 für 512-Byte-Sektoren verwendet und Version 4 Shift 12 für 4096; die Mini-Sektor-Shift bei 0x20 ist 6, was Mini-Sektoren zu 64 Bytes macht; und die Mini-Stream-Grenze bei 0x38 ist 4096. Die darauffolgende Adressarithmetik ist die häufigste Stelle, an der man es falsch macht. Sektor 0 beginnt unmittelbar nach dem Header, sodass Sektor N bei Byte-Offset 512 + N * SectorSize beginnt – man beachte das wörtliche 512, nicht SectorSize. Bei einer Version-3-Datei sind beide identisch, und der Fehler versteckt sich für immer; bei einer Version-4-Datei liest er still den falschen Sektor, weshalb HotXLS das in einer einzigen Funktion hält, SidToOffset

Eine Compound-Datei ist ein FAT-Dateisystem innerhalb einer Datei, sodass sie zu lesen bedeutet, verkettete Listen von Sektor-IDs zu durchlaufen, wobei FAT[n] die auf Sektor n folgende ID hält. Drei Sentinels beenden oder kommentieren eine Kette – ENDOFCHAIN, FATSECT für einen Sektor, der zur FAT selbst gehört, und DIFSECT für einen DIFAT-Sektor – und alle drei lesen sich als negative vorzeichenbehaftete 32-Bit-Ganzzahlen, was die Schleifenbedingungen einfach hält. Die FAT zu finden braucht eine weitere Indirektion: Das DIFAT ist das Array von Sektor-IDs, das angibt, wo die FAT-Sektoren liegen, und seine ersten 109 Einträge sitzen im Header bei Offset 0x4C. TlxCompoundFile durchläuft diese 109, stoppt beim ersten negativen Eintrag und verkettet jeden FAT-Sektor zu einem flachen Integer-Array. Das sind 109 FAT-Sektoren zu je 128 Einträgen bei einem 512-Byte-Sektor, also 13.952 adressierbare Sektoren, mithin rund 6,8 MiB Container, bevor das DIFAT in eine eigene Kette überlaufen muss

Die zweite Zuweisungstabelle existiert, weil 512-Byte-Sektoren den größten Teil ihres Platzes bei kleinen Streams verschwenden. Jeder Stream unterhalb der 4096-Byte-Grenze wird überhaupt nicht in Sektoren gespeichert: Er lebt innerhalb des Mini-Streams, selbst ein gewöhnlicher Stream, der am Root-Verzeichniseintrag hängt, unterteilt in 64-Byte-Mini-Sektoren und verkettet über ein paralleles MiniFAT, verwurzelt bei Header-Offset 0x3C. Öffnet man eine echte .xls-Datei, sitzt der Workbook-Stream auf der normalen FAT, während die Summary-Information-Streams unten im Mini-Sektor-Raum sitzen, weshalb eine Implementierung, die nur den FAT-Pfad abdeckt, genau so lange korrekt wirkt, bis sie Dokument-Metadaten braucht. Das Verzeichnis ist die dritte Struktur und diejenige, die den Container navigierbar macht: Jeder Eintrag ist exakt 128 Bytes lang, vier pro 512-Byte-Sektor, trägt einen UTF-16-Namen in den ersten 64 Bytes, dessen Bytelänge bei 0x40, den Objekttyp bei 0x42 (1 = Storage, 2 = Stream, 5 = Root), Baumverknüpfungen bei 0x44, 0x48 und 0x4C, den Startsektor bei 0x74 und die 32-Bit-Stream-Größe bei 0x78. Diese Namenslänge zählt Bytes einschließlich des abschließenden Null-Zeichens, sodass die Zeichenzahl NameLen div 2 - 1 ist, und dabei um eins danebenzuliegen ist, wie man einen Stream namens Workboo bekommt

Einen Workbook-Stream aus einem Speicherpuffer ziehen

TlxCompoundFile.OpenStream verbirgt all das Obige hinter einem einzigen Aufruf, der einen Stream-Namen entgegennimmt und einen TlxCfbStream mit den vollständig materialisierten Bytes zurückgibt. Die gesamte Sequenz – schnüffeln, laden, extrahieren – läuft gegen einen TBytesStream, ohne dass je etwas die Festplatte berührt

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;

Zwei Details lohnen sich hervorzuheben. LoadFromStream nimmt ein AOwnsStream-Flag entgegen, das standardmäßig False ist, sodass der Aufrufer die Verantwortung für den Quell-Stream behält – bewusst so, weil der übliche Fall ein Stream ist, den die Anwendung bereits besitzt. Und OpenStream gibt einen TlxCfbStream zurück, der seine eigene Kopie der Bytes besitzt, freigelegt über Data, Size, Read, Seek und CopyTo. Diese Kopie ist bei einem großen Workbook ein echter Kostenpunkt, und sie ist der ehrliche Preis eines Designs, bei dem das zurückgegebene Objekt gültig bleibt, nachdem der Container freigegeben wurde. Ist ein Workbook groß genug, dass eine vollständige In-Memory-Kopie schlicht die falsche Form ist, ist der Streaming-Direct-Reader für übergroße Tabellenblätter der bessere Einstiegspunkt

Warum sieht ein verschlüsseltes XLSX wie eine XLS-Datei aus?

Weil es auf Container-Ebene tatsächlich eine ist – und das ist der praktische Nutzen davon, diese Schicht selbst zu besitzen. Öffnet man ein verschlüsseltes .xlsx in einem Hex-Editor, sind die ersten acht Bytes D0 CF 11 E0 A1 B1 1A E1, Byte für Byte identisch mit einer .xls-Datei aus dem Jahr 1997, denn [MS-OFFCRYPTO]-Verschlüsselung verschlüsselt das ZIP-Paket nicht an Ort und Stelle: Sie wickelt das gesamte Paket in einen CFB-Container als Stream namens EncryptedPackage, neben einem EncryptionInfo-Stream, der den Algorithmus beschreibt. Die Signatur identifiziert daher den Container und sagt nichts über die Nutzlast aus. Ein BIFF-Workbook von einem verschlüsselten OOXML-Paket zu unterscheiden bedeutet, das Verzeichnis zu lesen, was nach LoadFromStream ein Scan über EntryCount und Entries ist, oder ein Paar von HasStream-Sonden

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;

Verzeichnisnamen verdienen eine eigene Warnung: Die Summary-Information-Streams tragen ein führendes Steuerzeichen 0x05 in ihren Namen, sodass ein gegen einen schlichten Anzeige-String geschriebener Vergleich sie nie trifft und eine naive Log-Zeile sie als Datenmüll darstellt. Alles, was dieser Klassifikation nachgelagert ist – den Schlüssel ableiten, den Passwort-Verifier prüfen – ist ein separates Problem, behandelt in den Notizen dazu, warum Excel ein mit dem falschen Cipher-Modus verschlüsseltes Workbook zurückweist. Die Container-Schicht sagt einem nur, vor welcher Tür man steht

Einen Container schreiben, den Excel tatsächlich öffnet

Die Schreibseite von TlxCompoundFile ist bewusst schmaler als die Leseseite, und zu verstehen warum erspart einem einen Streit mit der Spezifikation. [MS-CFB] erlaubt einen enormen Raum gültiger Container: mehrstufige Storages, korrekt ausbalancierte Rot-Schwarz-Verzeichnisbäume, Mini-Streams, DIFAT-Ketten. Excel emittiert eine kleine Ecke dieses Raums und liest eine etwas größere. HotXLS schreibt eine noch kleinere Ecke – das Minimum, das Excel nachweislich lädt. Jeder Stream landet auf der normalen FAT ohne Mini-Stream-Pfad, was Speicherplatz kostet und Korrektheit erkauft: Ein 300-Byte-Summary-Stream, den Excel in fünf 64-Byte-Mini-Sektoren gepackt hätte, belegt stattdessen einen vollen 512-Byte-Sektor, und für ein Workbook ist das Rauschen im Vergleich zur Pflege einer zweiten Zuweisungstabelle, eines zweiten Kettendurchlaufs und des dahinterstehenden Root-Entry-Streams auf dem Schreibpfad. Verzeichniseinträge bilden eine flache Geschwisterkette unter dem Root, wobei jeder Knoten schwarz gefärbt ist, und die Ausgabereihenfolge ist fest: Header-Platzhalter, Stream-Datensektoren, Verzeichnissektoren, FAT-Sektoren, dann ein Rücksprung, um den Header mit den erst am Ende bekannten Sektor-IDs neu zu schreiben. Die FAT bemisst sich selbst durch eine kurze Fixpunktschleife, denn das Hinzufügen von FAT-Sektoren kann die Sektorzahl so weit hochtreiben, dass ein weiterer FAT-Sektor nötig wird

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;

Wo die Implementierung endet

Drei Grenzen sind es wert, offen benannt zu werden, denn ein Container-Reader, der einen Randfall still falsch behandelt, ist schlimmer als einer, der eine Exception auslöst. TlxCompoundFile liest die 109 im Header residenten DIFAT-Einträge und folgt der DIFAT-Kette bei 0x44 nicht darüber hinaus, was einen lesbaren Container bei rund 6,8 MiB auf 512-Byte-Sektoren deckelt – komfortabel über den echten .xls-Dateien, denen HotXLS im Feld begegnet, aber dennoch eine harte Obergrenze, und der Writer erzwingt dieselbe Grenze explizit, statt einen Container zu emittieren, den er nicht beschreiben kann. Zweitens werden Version-4-Container mit 4096-Byte-Sektoren durch die Sektorgrößen-Arithmetik unterstützt, sind aber nicht das, worauf der Code abgestimmt ist, und die 64-Bit-Stream-Größe wird nicht konsultiert: HotXLS liest die unteren 32 Bit bei Offset 0x78 und lässt die obere Hälfte unangetastet, was für Version 3 korrekt ist und nur für Version 3. Drittens ist der Eintrags-Lookup ein flacher Scan nach Namen über die Verzeichnisliste statt ein Abstieg im Rot-Schwarz-Baum von einem übergeordneten Storage aus, sodass verschachtelte Storages über Namenskollision statt über Pfad aufgelöst werden – jeder Stream, den eine .xls-Datei braucht, sitzt auf oberster Ebene, was das einfachere Design vertretbar macht, aber Code, der SomeStorage/SomeStream adressieren will, findet es nicht

Nichts davon ändert, wofür die Unit da ist. Die Container-Schicht selbst zu besitzen macht .xls-Handling zu gewöhnlichem Object Pascal: parsbar aus einem Byte-Array, testbar ohne Dateisystem, portabel auf jede Plattform, die der Compiler anvisiert, und frei von einem COM-Apartment. Es zieht auch die Schnüffel-Abkürzungen zurück, denn ein Workbook zu identifizieren bedeutet nun, sein Verzeichnis zu lesen statt seine ersten acht Bytes – dieselbe Disziplin steckt hinter dem Auflisten von Tabellenblattnamen, ohne das ganze Workbook zu öffnen

TlxCompoundFile ist Teil der HotXLS Excel Component für Delphi und C++Builder, zusammen mit den darüberliegenden BIFF- und OOXML-Schichten; die Produktseite führt die vollständige Unit-Referenz und die unterstützte Compiler-Matrix