HotXLS Excel Library per Delphi e C++Builder legge e scrive il contenitore Compound File Binary dietro ogni file .xls legacy in puro Object Pascal. La classe TlxCompoundFile implementa direttamente il layout [MS-CFB] versione 3 contro un TStream — header, DIFAT, catene FAT, MiniFAT e albero delle directory — senza alcun ole32.dll e senza COM IStorage in nessun punto del percorso
Suona come idraulica, e per vent'anni è stata idraulica di proprietà di qualcun altro. Ogni codebase Delphi che toccava un file .xls allungava la mano verso StgOpenStorage, otteneva indietro un IStorage, ed estraeva lo stream Workbook da esso. Tre righe, funzionava bene, nessuno ci pensava più — finché il giorno in cui lo stesso codice ha dovuto girare in un posto dove non c'era Windows
Perché StgOpenStorage smette di funzionare su un server?
L'API COM di structured storage fallisce esattamente nelle forme di deployment in cui vive il codice Delphi moderno, per motivi che non hanno nulla a che fare con il formato del file. StgOpenStorage è un punto di ingresso Win32 in ole32.dll: vuole un percorso su un filesystem, vuole COM inizializzato sul thread chiamante, e vuole trovarsi su Windows. Il requisito del percorso fa male per primo, perché un endpoint REST che riceve un workbook caricato ha i byte in un buffer, non su disco — quindi scrivi il buffer in un file temporaneo, lo apri, lo rileggi, lo cancelli, e ora possiedi un ciclo di vita di file temporaneo da sbagliare sotto carico. ILockBytes è la via di fuga documentata, ma cablare un'implementazione personalizzata sopra un TMemoryStream è più interop COM di quanto la maggior parte dei team voglia. Il requisito di inizializzazione morde per secondo, di solito in un thread worker di servizio su cui nessuno ha chiamato CoInitialize, e il requisito di piattaforma chiude la conversazione nel momento in cui il target è Linux sotto FPC, un'immagine container, o macOS. HotXLS quindi mantiene il classico percorso lxOLE costruito su StgOpenStorage come default, poiché è collaudato in battaglia e i chiamanti esistenti non dovrebbero dover cambiare; TlxCompoundFile è l'alternativa opt-in per tutti gli altri
Cosa dicono realmente l'header e le catene FAT
I primi 512 byte di un compound file rispondono a ogni domanda strutturale necessaria prima di leggere un solo byte di payload. [MS-CFB] §2.2 fissa la firma dell'header all'offset 0 come gli otto byte D0 CF 11 E0 A1 B1 1A E1, e lxIsCompoundStream verifica esattamente quello, ripristinando la posizione dello stream dopo così un chiamante può ispezionare senza disturbare nulla. Altri quattro campi decidono la geometria: l'ordine dei byte a 0x1C deve essere 0xFFFE, che funge anche da secondo controllo firma economico; lo shift di settore a 0x1E dà la dimensione del settore come 1 shl SectorShift, così la versione 3 usa shift 9 per settori da 512 byte e la versione 4 usa shift 12 per 4096; lo shift dei mini settori a 0x20 è 6, rendendo i mini settori di 64 byte; e il cutoff del mini stream a 0x38 è 4096. L'aritmetica degli indirizzi che segue è il punto più comune dove sbagliare. Il settore 0 inizia subito dopo l'header, quindi il settore N inizia all'offset in byte 512 + N * SectorSize — nota il 512 letterale, non SectorSize. Su un file versione 3 i due sono identici e il bug si nasconde per sempre; su un file versione 4 legge silenziosamente il settore sbagliato, motivo per cui HotXLS mantiene questo in un'unica funzione, SidToOffset
Un compound file è un filesystem FAT dentro un file, quindi leggerlo significa percorrere liste concatenate di ID di settore dove FAT[n] contiene l'ID che segue il settore n. Tre sentinelle terminano o annotano una catena — ENDOFCHAIN, FATSECT per un settore appartenente alla FAT stessa, e DIFSECT per un settore DIFAT — e tutte e tre si leggono come interi con segno a 32 bit negativi, il che mantiene semplici le condizioni di ciclo. Trovare la FAT richiede un'indirezione in più: la DIFAT è l'array di ID di settore che dice dove risiedono i settori FAT, e le sue prime 109 voci si trovano nell'header all'offset 0x4C. TlxCompoundFile percorre quelle 109, si ferma alla prima voce negativa, e concatena ogni settore FAT in un unico array Integer piatto. Sono 109 settori FAT con 128 voci ciascuno su un settore da 512 byte, quindi 13.952 settori indirizzabili, quindi circa 6,8 MiB di contenitore prima che la DIFAT debba traboccare in una propria catena
La seconda tabella di allocazione esiste perché i settori da 512 byte sprecano la maggior parte del proprio spazio sui piccoli stream. Ogni stream sotto il cutoff di 4096 byte non viene memorizzato affatto in settori: vive dentro il mini stream, esso stesso uno stream ordinario appeso alla voce di directory radice, suddiviso in mini settori da 64 byte e concatenato tramite una MiniFAT parallela radicata all'offset dell'header 0x3C. Apri un vero .xls e lo stream Workbook si trova sulla FAT normale mentre gli stream summary-information si trovano giù nello spazio dei mini settori, motivo per cui un'implementazione che copre solo il percorso FAT sembra funzionare fino al momento in cui serve il metadata del documento. La directory è la terza struttura ed è quella che rende navigabile il contenitore: ogni voce è esattamente 128 byte, quattro per settore da 512 byte, e porta un nome UTF-16 nei primi 64 byte, la sua lunghezza in byte a 0x40, il tipo di oggetto a 0x42 (1 = storage, 2 = stream, 5 = root), collegamenti dell'albero a 0x44, 0x48 e 0x4C, il settore iniziale a 0x74 e la dimensione a 32 bit dello stream a 0x78. Quella lunghezza di nome conta i byte incluso il null terminatore, quindi il conteggio dei caratteri è NameLen div 2 - 1, e sbagliarlo di uno è come finisci con uno stream chiamato Workboo
Estrarre uno stream Workbook da un buffer di memoria
TlxCompoundFile.OpenStream nasconde tutto quanto sopra dietro un'unica chiamata che prende un nome di stream e restituisce un TlxCfbStream che contiene i byte completamente materializzati. L'intera sequenza — ispezione, caricamento, estrazione — viene eseguita contro un TBytesStream senza che nulla tocchi mai il disco
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;
Due dettagli lì valgono la pena di essere segnalati. LoadFromStream prende un flag AOwnsStream il cui default è False, così il chiamante mantiene la responsabilità dello stream sorgente — deliberato, perché il caso comune è uno stream che l'applicazione già possiede. E OpenStream restituisce un TlxCfbStream che possiede una propria copia dei byte, esposta tramite Data, Size, Read, Seek e CopyTo. Quella copia è un costo reale su un workbook grande, ed è il prezzo onesto di un design in cui l'oggetto restituito resta valido dopo che il contenitore viene liberato. Quando un workbook è abbastanza grande che una copia completa in memoria è del tutto la forma sbagliata, il lettore diretto in streaming per fogli di calcolo di grandi dimensioni è il punto di ingresso migliore
Perché un XLSX crittografato sembra un file XLS?
Perché lo è, a livello di contenitore — ed è questo il ritorno pratico di possedere quello strato. Apri un .xlsx crittografato in un editor esadecimale e i primi otto byte sono D0 CF 11 E0 A1 B1 1A E1, identici byte per byte a un .xls d'annata 1997, perché la crittografia [MS-OFFCRYPTO] non crittografa il pacchetto ZIP sul posto: avvolge l'intero pacchetto dentro un contenitore CFB come stream chiamato EncryptedPackage, accanto a uno stream EncryptionInfo che descrive l'algoritmo. La firma quindi identifica il contenitore e non dice nulla sul payload. Distinguere un workbook BIFF da un pacchetto OOXML crittografato significa leggere la directory, che dopo LoadFromStream è una scansione su EntryCount ed Entries, oppure una coppia di sonde 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;
I nomi delle directory meritano un avviso a parte: gli stream summary-information portano un carattere di controllo 0x05 iniziale nei loro nomi, quindi un confronto scritto contro una semplice stringa di visualizzazione non li farà mai corrispondere e una riga di log ingenua li renderizza come spazzatura. Tutto ciò che sta a valle di questa classificazione — derivare la chiave, verificare il verificatore della password — è un problema separato, trattato nelle note su perché Excel rifiuta un workbook crittografato con la modalità di cifratura sbagliata. Lo strato del contenitore ti dice solo davanti a quale porta ti trovi
Scrivere un contenitore che Excel aprirà davvero
Il lato scrittura di TlxCompoundFile è deliberatamente più stretto del lato lettura, e capire perché evita una discussione con la specifica. [MS-CFB] permette uno spazio enorme di contenitori validi: storage multi-livello, alberi di directory red-black correttamente bilanciati, mini stream, catene DIFAT. Excel emette un piccolo angolo di quello spazio e ne legge uno un po' più ampio. HotXLS scrive un angolo ancora più piccolo — il minimo che Excel dimostrabilmente carica. Ogni stream va sulla FAT normale senza alcun percorso mini-stream, il che costa spazio su disco e compra correttezza: uno stream summary di 300 byte che Excel avrebbe impacchettato in cinque mini settori da 64 byte occupa invece un intero settore da 512 byte, e per un workbook quello è rumore rispetto a mantenere una seconda tabella di allocazione, un secondo percorso di catena e lo stream della voce radice che lo sostiene sul percorso di scrittura. Le voci di directory formano una catena piatta di fratelli sotto la radice con ogni nodo colorato nero, e l'ordine di emissione è fisso: segnaposto dell'header, settori dati dello stream, settori di directory, settori FAT, poi un seek all'indietro per riscrivere l'header con gli ID di settore che sono noti solo alla fine. La FAT si dimensiona da sola tramite un breve ciclo a punto fisso, perché aggiungere settori FAT può spingere il conteggio dei settori abbastanza in alto da richiedere un altro settore FAT
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;
Dove si ferma l'implementazione
Tre confini vale la pena dichiarare apertamente, perché un lettore di contenitori che gestisce silenziosamente male un caso limite è peggio di uno che solleva un'eccezione. TlxCompoundFile legge le 109 voci DIFAT residenti nell'header e non segue la catena DIFAT a 0x44 oltre esse, limitando un contenitore leggibile a circa 6,8 MiB su settori da 512 byte — comodamente sopra i veri file .xls che HotXLS incontra sul campo, ma un tetto rigido comunque, e lo scrittore applica esplicitamente lo stesso limite anziché emettere un contenitore che non può descrivere. Secondo, i contenitori versione 4 con settori da 4096 byte sono gestiti dall'aritmetica della dimensione settore ma non sono ciò per cui il codice è ottimizzato, e la dimensione stream a 64 bit non viene consultata: HotXLS legge i 32 bit bassi all'offset 0x78 e lascia stare la metà alta, il che è corretto per la versione 3 e solo per la versione 3. Terzo, la ricerca delle voci è una scansione piatta per nome attraverso l'elenco di directory anziché un percorso lungo l'albero red-black da uno storage genitore, quindi gli storage annidati si risolvono per collisione di nome anziché per percorso — ogni stream di cui un file .xls ha bisogno si trova al livello superiore, il che rende difendibile il design più semplice, ma codice che si aspetta di indirizzare SomeStorage/SomeStream non lo troverà
Nulla di tutto ciò cambia a cosa serve l'unit. Possedere lo strato del contenitore trasforma la gestione di .xls in ordinario Object Pascal: analizzabile da un array di byte, testabile senza un filesystem, portabile su qualunque piattaforma il compilatore prenda di mira, e libero da un apartment COM. Ritira anche le scorciatoie di ispezione, perché identificare un workbook ora significa leggere la sua directory anziché i suoi primi otto byte — la stessa disciplina dietro l'elenco dei nomi dei fogli senza aprire l'intero workbook
TlxCompoundFile viene fornito come parte del HotXLS Excel Component per Delphi e C++Builder, insieme agli strati BIFF e OOXML che poggiano su di esso; la pagina prodotto riporta il riferimento completo dell'unit e la matrice dei compilatori supportati