Articol tehnic

Citește fișiere compuse OLE2 în Delphi fără COM IStorage

HotXLS Excel Library pentru Delphi și C++Builder citește și scrie containerul Compound File Binary din spatele fiecărui fișier .xls vechi, în Object Pascal pur. Clasa TlxCompoundFile implementează layout-ul [MS-CFB] versiunea 3 direct contra unui TStream — header, DIFAT, lanțuri FAT, MiniFAT și arborele de directoare — fără niciun ole32.dll și fără COM IStorage nicăieri pe cale

Sună a instalație sanitară, iar timp de douăzeci de ani a fost o instalație pe care o deținea altcineva. Fiecare bază de cod Delphi care atingea un fișier .xls apela StgOpenStorage, primea înapoi un IStorage și scotea din el stream-ul Workbook. Trei linii, funcționa bine, nimeni nu se mai gândea la asta — până în ziua în care același cod trebuia să ruleze undeva unde Windows nu era

De ce nu mai funcționează StgOpenStorage pe un server?

API-ul COM de stocare structurată eșuează exact în formele de deployment în care trăiește codul Delphi modern, din motive care nu au nimic de-a face cu formatul fișierului. StgOpenStorage e un punct de intrare Win32 din ole32.dll: vrea o cale pe un sistem de fișiere, vrea COM inițializat pe thread-ul apelant și vrea să fie pe Windows. Cerința de cale doare prima, pentru că un endpoint REST care primește un workbook încărcat are bytes-ii într-un buffer, nu pe disc — așa că scrii bufferul într-un fișier temporar, îl deschizi, îl citești înapoi, îl ștergi, și acum deții un ciclu de viață de fișier temporar pe care să-l strici sub sarcină. ILockBytes e portița de scăpare documentată, dar cablarea unei implementări proprii peste un TMemoryStream e mai mult interop COM decât își doresc majoritatea echipelor. Cerința de inițializare mușcă a doua, de obicei într-un thread worker de serviciu pe care nimeni n-a apelat CoInitialize, iar cerința de platformă încheie conversația în momentul în care ținta e Linux sub FPC, o imagine de container sau macOS. HotXLS păstrează așadar calea clasică lxOLE, construită pe StgOpenStorage, ca implicită, pentru că e testată intens în producție, iar apelanții existenți n-ar trebui să fie nevoiți să schimbe nimic; TlxCompoundFile e alternativa opt-in pentru toți ceilalți

Ce spun de fapt header-ul și lanțurile FAT

Primii 512 de bytes ai unui fișier compus răspund la fiecare întrebare structurală de care ai nevoie înainte să citești vreun byte de payload. [MS-CFB] §2.2 fixează semnătura header-ului la offset-ul 0 ca cei opt bytes D0 CF 11 E0 A1 B1 1A E1, iar lxIsCompoundStream verifică exact asta, restaurând poziția în stream după, astfel încât un apelant să poată adulmeca fără să deranjeze nimic. Alte patru câmpuri decid geometria: ordinea de bytes la 0x1C trebuie să fie 0xFFFE, ceea ce dublează ca o a doua verificare de semnătură ieftină; shift-ul de sector la 0x1E dă dimensiunea sectorului ca 1 shl SectorShift, așa că versiunea 3 folosește shift 9 pentru sectoare de 512 bytes, iar versiunea 4 folosește shift 12 pentru 4096; shift-ul de mini-sector la 0x20 e 6, ceea ce face mini-sectoarele de 64 de bytes; iar pragul mini stream-ului la 0x38 e 4096. Aritmetica de adrese care urmează e locul cel mai comun unde se greșește. Sectorul 0 începe imediat după header, așa că sectorul N începe la offset-ul de byte 512 + N * SectorSize — observă literalul 512, nu SectorSize. Pe un fișier versiunea 3 cele două sunt identice, iar bug-ul se ascunde pentru totdeauna; pe un fișier versiunea 4 citește silențios sectorul greșit, motiv pentru care HotXLS păstrează asta într-o singură funcție, SidToOffset

Un fișier compus e un sistem de fișiere FAT în interiorul unui fișier, așa că citirea lui înseamnă parcurgerea unor liste înlănțuite de ID-uri de sector, unde FAT[n] ține ID-ul care urmează sectorului n. Trei sentinele termină sau adnotează un lanț — ENDOFCHAIN, FATSECT pentru un sector care aparține FAT-ului însuși și DIFSECT pentru un sector DIFAT — iar toate trei se citesc ca întregi semnați pe 32 de biți negativi, ceea ce menține condițiile buclei simple. Găsirea FAT-ului mai are nevoie de o indirecție: DIFAT e array-ul de ID-uri de sector care spune unde trăiesc sectoarele FAT, iar primele sale 109 intrări stau în header, la offset-ul 0x4C. TlxCompoundFile parcurge acele 109, se oprește la prima intrare negativă și concatenează fiecare sector FAT într-un singur array plat de Integer. Astea sunt 109 sectoare FAT cu câte 128 de intrări fiecare pe un sector de 512 bytes, deci 13.952 de sectoare adresabile, deci aproximativ 6,8 MiB de container înainte ca DIFAT să trebuiască să se reverse într-un lanț propriu

A doua tabelă de alocare există pentru că sectoarele de 512 bytes irosesc cea mai mare parte a spațiului lor pe stream-uri mici. Orice stream sub pragul de 4096 de bytes nu e deloc stocat în sectoare: trăiește în interiorul mini stream-ului, el însuși un stream obișnuit atârnat de intrarea directorului rădăcină, subdivizat în mini-sectoare de 64 de bytes și înlănțuit printr-un MiniFAT paralel, cu rădăcina la offset-ul de header 0x3C. Deschide un .xls real, iar stream-ul Workbook stă pe FAT-ul normal, în timp ce stream-urile de summary-information stau jos, în spațiul de mini-sector, motiv pentru care o implementare care acoperă doar calea FAT pare să funcționeze exact până când are nevoie de metadate de document. Directorul e a treia structură, și cea care face containerul navigabil: fiecare intrare are exact 128 de bytes, patru pe un sector de 512 bytes, purtând un nume UTF-16 în primii 64 de bytes, lungimea lui în bytes la 0x40, tipul obiectului la 0x42 (1 = storage, 2 = stream, 5 = root), legături de arbore la 0x44, 0x48 și 0x4C, sectorul de start la 0x74 și dimensiunea stream-ului pe 32 de biți la 0x78. Acea lungime de nume numără bytes, inclusiv null-ul terminator, așa că numărul de caractere e NameLen div 2 - 1, iar a greși cu unu acolo e cum ajungi cu un stream numit Workboo

Extragerea unui stream Workbook dintr-un buffer de memorie

TlxCompoundFile.OpenStream ascunde tot ce e mai sus în spatele unui singur apel care primește un nume de stream și returnează un TlxCfbStream care ține bytes-ii complet materializați. Întreaga secvență — adulmecare, încărcare, extragere — rulează contra unui TBytesStream, fără ca nimic să atingă vreodată discul

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;

Merită menționate două detalii aici. LoadFromStream primește un flag AOwnsStream, implicit False, așa că apelantul păstrează responsabilitatea pentru stream-ul sursă — deliberat, pentru că cazul obișnuit e un stream pe care aplicația îl deține deja. Iar OpenStream returnează un TlxCfbStream care deține propria lui copie a bytes-ilor, expusă prin Data, Size, Read, Seek și CopyTo. Acea copie e un cost real pe un workbook mare, și e prețul onest al unui design în care obiectul returnat rămâne valid după ce containerul e eliberat. Când un workbook e destul de mare încât o copie completă în memorie e pur și simplu forma greșită, cititorul direct în streaming pentru foi de calcul supradimensionate e punctul de intrare mai potrivit

De ce arată un XLSX criptat ca un fișier XLS?

Pentru că este unul, la nivel de container — iar acesta e câștigul practic al deținerii acelui nivel. Deschide un .xlsx criptat într-un editor hex, iar primii opt bytes sunt D0 CF 11 E0 A1 B1 1A E1, identici byte cu byte cu un .xls din 1997, pentru că criptarea [MS-OFFCRYPTO] nu criptează pachetul ZIP pe loc: învelește întregul pachet într-un container CFB ca un stream numit EncryptedPackage, alături de un stream EncryptionInfo care descrie algoritmul. Semnătura identifică deci containerul și nu spune nimic despre payload. A deosebi un workbook BIFF de un pachet OOXML criptat înseamnă să citești directorul, ceea ce, după LoadFromStream, e o scanare peste EntryCount și Entries, sau o pereche de 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;

Numele de director merită propriul lor avertisment: stream-urile de summary-information poartă un caracter de control 0x05 la începutul numelui, așa că o comparație scrisă contra unui șir simplu de afișare nu se va potrivi niciodată cu ele, iar o linie de log naivă le randează ca gunoi. Tot ce vine după această clasificare — derivarea cheii, verificarea password verifier-ului — e o problemă separată, acoperită în notele despre de ce Excel respinge un workbook criptat cu modul greșit de cifru. Nivelul de container îți spune doar în fața cărei uși stai

Scrierea unui container pe care Excel chiar îl va deschide

Partea de scriere a TlxCompoundFile e deliberat mai îngustă decât partea de citire, iar a înțelege de ce evită o ceartă cu specificația. [MS-CFB] permite un spațiu enorm de containere valide: storage-uri pe mai multe niveluri, arbori de directoare roșu-negru corect echilibrați, mini stream-uri, lanțuri DIFAT. Excel emite un colț mic din acel spațiu și citește unul ceva mai mare. HotXLS scrie un colț și mai mic — minimul pe care Excel îl încarcă demonstrabil. Fiecare stream merge pe FAT-ul normal, fără cale de mini-stream, ceea ce costă spațiu pe disc, dar cumpără corectitudine: un stream de summary de 300 de bytes, pe care Excel l-ar fi împachetat în cinci mini-sectoare de 64 de bytes, ocupă în schimb un sector întreg de 512 bytes, iar pentru un workbook, asta e zgomot pe lângă a menține o a doua tabelă de alocare, o a doua parcurgere de lanț și stream-ul de root-entry care o susține pe calea de scriere. Intrările de director formează un lanț plat de frați sub rădăcină, cu fiecare nod colorat negru, iar ordinea de emitere e fixă: placeholder de header, sectoare de date de stream, sectoare de director, sectoare FAT, apoi un seek înapoi ca să rescrie header-ul cu ID-urile de sector care se cunosc doar la sfârșit. FAT-ul își dimensionează singur mărimea printr-o buclă scurtă cu punct fix, pentru că adăugarea de sectoare FAT poate împinge numărul de sectoare destul de sus încât să ceară un alt sector 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;

Unde se oprește implementarea

Trei granițe merită menționate clar, pentru că un cititor de container care tratează silențios greșit un caz limită e mai rău decât unul care ridică o excepție. TlxCompoundFile citește cele 109 intrări DIFAT rezidente în header și nu urmărește lanțul DIFAT de la 0x44 dincolo de ele, plafonând un container citibil la aproximativ 6,8 MiB pe sectoare de 512 bytes — confortabil peste fișierele .xls reale pe care HotXLS le întâlnește pe teren, dar un plafon dur, totuși, iar scriitorul impune explicit aceeași limită, în loc să emită un container pe care nu-l poate descrie. În al doilea rând, containerele versiunea 4, cu sectoare de 4096 de bytes, sunt acomodate de aritmetica dimensiunii de sector, dar nu sunt pentru ce e ajustat codul, iar dimensiunea de stream pe 64 de biți nu e consultată: HotXLS citește cei 32 de biți inferiori la offset-ul 0x78 și lasă jumătatea superioară netratată, ceea ce e corect pentru versiunea 3 și doar pentru versiunea 3. În al treilea rând, căutarea intrărilor e o scanare plată după nume prin lista de directoare, nu o parcurgere în jos a arborelui roșu-negru dintr-un storage părinte, așa că storage-urile imbricate se rezolvă prin coliziune de nume, nu prin cale — fiecare stream de care are nevoie un fișier .xls stă la nivelul superior, ceea ce face designul mai simplu apărabil, dar codul care se așteaptă să adreseze SomeStorage/SomeStream nu-l va găsi

Nimic din toate astea nu schimbă la ce servește unit-ul. Deținerea nivelului de container transformă tratarea .xls în Object Pascal obișnuit: parsabil dintr-un array de bytes, testabil fără un sistem de fișiere, portabil pe orice platformă vizează compilatorul, și fără un apartament COM. De asemenea, retrage scurtăturile de adulmecare, pentru că identificarea unui workbook înseamnă acum citirea directorului lui, nu a primilor opt bytes — aceeași disciplină din spatele listării numelor de foi fără a deschide întregul workbook

TlxCompoundFile este livrat ca parte a HotXLS Excel Component pentru Delphi și C++Builder, alături de nivelurile BIFF și OOXML care stau deasupra lui; pagina produsului conține referința completă a unit-ului și matricea de compilatoare suportate