HotXLS Excel Library за Delphi и C++Builder чете и записва Compound File Binary контейнера зад всеки legacy .xls файл в чист Object Pascal. Класът TlxCompoundFile имплементира оформлението [MS-CFB] версия 3 директно върху TStream — header, DIFAT, FAT вериги, MiniFAT и дървото с директории — без ole32.dll и без COM IStorage никъде по пътя
Това звучи като водопровод, и за двадесет години беше водопровод, притежаван от някой друг. Всяка Delphi кодова база, докосвала .xls файл, посягаше към StgOpenStorage, получаваше IStorage обратно, и издърпваше stream-а Workbook от него. Три реда, работеше добре, никой не мислеше за това повече — докато денят, в който същият код трябваше да се изпълни някъде, където нямаше Windows
Защо StgOpenStorage спира да работи на сървър?
COM API-то за структурирано съхранение се проваля точно във формите на разгръщане, в които живее модерният Delphi код, по причини, нямащи нищо общо с формата на файла. StgOpenStorage е Win32 входна точка в ole32.dll: иска път във файлова система, иска COM инициализиран на извикващата нишка, и иска да е на Windows. Изискването за път боли първо, защото REST endpoint, получаващ качен workbook, има байтовете в буфер, не на диск — така че записвате буфера във временен файл, отваряте го, четете обратно, изтривате, и вече притежавате жизнен цикъл на временен файл, който да сгрешите под натоварване. ILockBytes е документираният изход, но окабеляването на персонализирана имплементация над TMemoryStream е повече COM interop, отколкото повечето екипи искат. Изискването за инициализация хапе второ, обикновено в service worker нишка, на която никой не е извикал CoInitialize, а изискването за платформа приключва разговора в момента, в който целта е Linux под FPC, container образ, или macOS. HotXLS затова пази класическия път lxOLE, изграден върху StgOpenStorage, като стойност по подразбиране, тъй като е изпитан в битка и съществуващите извикващи не бива да се налага да променят нищо; TlxCompoundFile е opt-in алтернативата за всички останали
Какво всъщност казват header-ът и FAT веригите
Първите 512 байта на compound file отговарят на всеки структурен въпрос, преди да прочетете байт от payload-а. [MS-CFB] §2.2 фиксира сигнатурата на header-а на offset 0 като осемте байта D0 CF 11 E0 A1 B1 1A E1, а lxIsCompoundStream проверява точно това, възстановявайки позицията на stream-а след това, така че извикващ да може да подуши без да смущава нищо. Още четири полета решават геометрията: byte order на 0x1C трябва да е 0xFFFE, което удвоява като евтина втора проверка на сигнатурата; sector shift на 0x1E дава размера на сектора като 1 shl SectorShift, така че версия 3 използва shift 9 за 512-байтови сектори, а версия 4 използва shift 12 за 4096; mini sector shift на 0x20 е 6, правейки mini секторите 64 байта; а прагът за mini stream на 0x38 е 4096. Аритметиката на адреси, следваща след това, е най-честото място за грешка. Сектор 0 започва веднага след header-а, така че сектор N започва на byte offset 512 + N * SectorSize — забележете буквалното 512, не SectorSize. Във версия 3 файл двете са идентични, и бъгът се крие завинаги; във версия 4 файл той тихо чете грешния сектор, поради което HotXLS държи това в една функция, SidToOffset
Compound file е файлова система от тип FAT вътре във файл, така че четенето му означава обхождане на свързани списъци от sector ID-та, където FAT[n] държи ID-то, следващо сектор n. Три sentinel-а прекратяват или анотират верига — ENDOFCHAIN, FATSECT за сектор, принадлежащ на самия FAT, и DIFSECT за DIFAT сектор — и трите се четат като отрицателни signed 32-битови цели числа, което държи условията на цикъла прости. Намирането на FAT изисква още едно ниво на непряка връзка: DIFAT е масивът от sector ID-та, казващи къде живеят FAT секторите, а първите му 109 записа седят в header-а на offset 0x4C. TlxCompoundFile обхожда тези 109, спира на първия отрицателен запис, и конкатенира всеки FAT сектор в един плосък масив Integer. Това са 109 FAT сектора по 128 записа всеки на 512-байтов сектор, тоест приблизително 13,952 адресируеми сектора, тоест приблизително 6.8 MiB контейнер, преди DIFAT да трябва да прелее в собствена верига
Втората таблица за разпределение съществува, защото 512-байтовите сектори пилеят повечето от мястото си за малки stream-ове. Всеки stream под прага от 4096 байта изобщо не се съхранява в сектори: той живее вътре в mini stream-а, самият той обикновен stream, висящ от root директория запис, разделен на 64-байтови mini сектори и верижен чрез паралелен MiniFAT, вкоренен на header offset 0x3C. Отворете истински .xls и stream-ът Workbook седи на нормалния FAT, докато summary-information stream-овете седят долу в mini-sector пространство, поради което имплементация, покриваща само пътя на FAT, изглежда работеща точно докато не потрябват метаданни на документа. Директорията е третата структура, и тази, правеща контейнера навигируем: всеки запис е точно 128 байта, четири на 512-байтов сектор, носещи UTF-16 име в първите 64 байта, дължината му в байтове на 0x40, типа обект на 0x42 (1 = storage, 2 = stream, 5 = root), връзките на дървото на 0x44, 0x48 и 0x4C, началния сектор на 0x74 и 32-битовия размер на stream на 0x78. Тази дължина на името брои байтове, включително терминиращата нула, така че броят символи е NameLen div 2 - 1, и грешката с едно тук е как се озовавате със stream, именуван Workboo
Извличане на Workbook stream от буфер в паметта
TlxCompoundFile.OpenStream крие всичко по-горе зад едно извикване, приемащо име на stream и връщащо TlxCfbStream, държащ напълно материализираните байтове. Цялата последователност — подуши, зареди, извлечи — се изпълнява срещу TBytesStream, без нищо изобщо да докосва диска
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;
Два детайла тук си струва да се отбележат. LoadFromStream приема флаг AOwnsStream, по подразбиране False, така че извикващият пази отговорността за изходния stream — умишлено, защото обичайният случай е stream, който приложението вече притежава. И OpenStream връща TlxCfbStream, притежаващ собствено копие на байтовете, изложено чрез Data, Size, Read, Seek и CopyTo. Това копие е реална цена при голям workbook, и е честната цена на дизайн, при който върнатият обект остава валиден след освобождаването на контейнера. Когато workbook е достатъчно голям, за да е грешна цялата форма на копие в паметта, streaming direct reader за прекалено големи таблици е по-добрата входна точка
Защо криптиран XLSX изглежда като XLS файл?
Защото е точно такъв, на ниво контейнер — и това е практическата отплата от притежаването на този слой. Отворете криптиран .xlsx в hex редактор и първите осем байта са D0 CF 11 E0 A1 B1 1A E1, байт по байт идентични с .xls от 1997 година, защото [MS-OFFCRYPTO] криптирането не криптира ZIP пакета на място: то увива целия пакет вътре в CFB контейнер като stream, именуван EncryptedPackage, до stream EncryptionInfo, описващ алгоритъма. Сигнатурата затова идентифицира контейнера и не казва нищо за payload-а. Разграничаването на BIFF workbook от криптиран OOXML пакет означава четене на директорията, което след LoadFromStream е сканиране на EntryCount и Entries, или чифт проверки с 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;
Имената в директорията заслужават собствено предупреждение: summary-information stream-овете носят водещ управляващ символ 0x05 в имената си, така че сравнение, написано срещу обикновен низ за показване, никога няма да съвпадне с тях, а наивен лог ред ги рендира като боклук. Всичко надолу по веригата от тази класификация — извеждане на ключа, проверка на верификатора за парола — е отделен проблем, разгледан в бележките за защо Excel отхвърля workbook, криптиран с грешен режим на шифъра. Слоят на контейнера само ви казва пред коя врата стоите
Записване на контейнер, който Excel действително ще отвори
Страната за писане на TlxCompoundFile е умишлено по-тясна от страната за четене, и разбирането защо спестява спор със спецификацията. [MS-CFB] позволява огромно пространство от валидни контейнери: многостепенни storage-и, правилно балансирани червено-черни дървета от директории, mini streams, DIFAT вериги. Excel излъчва малко ъгълче от това пространство и чете малко по-голямо. HotXLS пише ъгълче, по-малко и от двете — минимума, който Excel демонстративно зарежда. Всеки stream отива на нормалния FAT без път за mini-stream, което струва дисково пространство и купува коректност: 300-байтов summary stream, който Excel би пакетирал в пет 64-байтови mini сектора, вместо това заема цял 512-байтов сектор, а за workbook това е шум до поддържането на втора таблица за разпределение, второ обхождане на верига и root-entry stream-а, стоящ зад него по пътя на записа. Записите в директорията образуват плосък сиблинг chain под root-а, като всеки node е оцветен в черно, а редът на излъчване е фиксиран: placeholder за header, сектори с данни от stream, сектори с директория, сектори на FAT, после позициониране назад за пренаписване на header-а с sector ID-тата, известни само в края. FAT-ът се самооразмерява чрез къс fixed-point цикъл, защото добавянето на FAT сектори може да покачи броя сектори достатъчно, за да изисква още един 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;
Къде спира имплементацията
Три граници си струва да се посочат ясно, защото контейнерен четец, тихо разминаващ гранични случаи, е по-лош от такъв, който вдига грешка. TlxCompoundFile чете 109-те резидентни DIFAT записа в header-а и не следва DIFAT веригата на 0x44 отвъд тях, ограничавайки четим контейнер до приблизително 6.8 MiB на 512-байтови сектори — комфортно над реалните .xls файлове, които HotXLS среща на терен, но все пак твърд таван, а писателят налага същото ограничение изрично, вместо да излъчи контейнер, който не може да опише. Второ, версия 4 контейнери с 4096-байтови сектори се приспособяват от аритметиката за размер на сектора, но не са това, за което кодът е настроен, а 64-битовият размер на stream не се консултира: HotXLS чете долните 32 бита на offset 0x78 и оставя горната половина недокосната, което е правилно за версия 3 и само за версия 3. Трето, търсенето на записи е плоско сканиране по име през списъка с директории, а не обхождане надолу по червено-черното дърво от родителски storage, така че вложени storage-и се разрешават по съвпадение на име, а не по път — всеки stream, от който .xls файл се нуждае, седи на горно ниво, което прави по-простия дизайн защитим, но код, очакващ да адресира SomeStorage/SomeStream, няма да го намери
Нищо от това не променя за какво е unit-ът. Притежаването на слоя контейнер превръща обработката на .xls в обикновен Object Pascal: парсваем от масив от байтове, тестваем без файлова система, преносим на каквато и да е платформа, за която е насочен компилаторът, и свободен от COM apartment. Той също така пенсионира краткия път на подушването, защото идентифицирането на workbook сега означава четене на неговата директория, а не първите му осем байта — същата дисциплина зад изброяването на имена на листове без отваряне на целия workbook
TlxCompoundFile се доставя като част от HotXLS Excel Component за Delphi и C++Builder, заедно с BIFF и OOXML слоевете, седящи върху него; продуктовата страница носи пълния справочник за unit-а и поддържаната матрица от компилатори