Техническая статья

Чтение OLE2 составных файлов в Delphi без COM IStorage

Excel-библиотека HotXLS для Delphi и C++Builder читает и пишет контейнер Compound File Binary, стоящий за каждым устаревшим файлом .xls, на чистом Object Pascal. Класс TlxCompoundFile реализует разметку [MS-CFB] версии 3 напрямую поверх TStream — заголовок, DIFAT, цепочки FAT, MiniFAT и дерево каталога — без ole32.dll и без COM IStorage где бы то ни было на пути

Это звучит как сантехника, и двадцать лет ею именно и было, только владел ею кто-то другой. Каждая Delphi-кодовая база, касавшаяся файла .xls, тянулась к StgOpenStorage, получала обратно IStorage и вытаскивала поток Workbook оттуда. Три строки, работало нормально, никто больше об этом не думал — пока однажды тот же код не понадобилось запустить там, где не было Windows

Почему StgOpenStorage перестаёт работать на сервере?

COM API структурированного хранилища отказывает ровно в тех формах развёртывания, в которых сейчас живёт современный Delphi-код, по причинам, не имеющим ничего общего с форматом файла. StgOpenStorage — это точка входа Win32 в ole32.dll: ей нужен путь в файловой системе, ей нужен инициализированный COM на вызывающем потоке, и ей нужна Windows. Требование пути бьёт первым, потому что REST-эндпоинт, принимающий загруженную книгу, имеет байты в буфере, а не на диске — так что вы пишете буфер во временный файл, открываете его, читаете обратно, удаляете, и теперь владеете жизненным циклом временного файла, который легко испортить под нагрузкой. ILockBytes — документированный обходной путь, но подключение собственной реализации поверх TMemoryStream — это больше COM-взаимодействия, чем хочет большинство команд. Требование инициализации кусается вторым, обычно в рабочем потоке сервиса, для которого никто не вызвал CoInitialize, а требование платформы завершает разговор в момент, когда целью оказывается Linux под FPC, образ контейнера или macOS. Поэтому HotXLS сохраняет классический путь lxOLE, построенный на StgOpenStorage, как путь по умолчанию, поскольку он проверен боем, и существующим вызывающим сторонам не следует ничего менять; TlxCompoundFile — опциональная альтернатива для всех остальных

Что на самом деле говорят заголовок и цепочки FAT

Первые 512 байт составного файла отвечают на каждый структурный вопрос, который вам нужен, прежде чем прочитать хоть байт полезной нагрузки. [MS-CFB] §2.2 фиксирует сигнатуру заголовка на смещении 0 как восемь байт D0 CF 11 E0 A1 B1 1A E1, и lxIsCompoundStream проверяет именно это, восстанавливая позицию потока впоследствии, так что вызывающая сторона может принюхаться, ничего не потревожив. Ещё четыре поля решают геометрию: порядок байт на 0x1C должен быть 0xFFFE, что дублируется как дешёвая вторая проверка сигнатуры; сдвиг сектора на 0x1E даёт размер сектора как 1 shl SectorShift, так что версия 3 использует сдвиг 9 для 512-байтовых секторов, а версия 4 — сдвиг 12 для 4096; сдвиг мини-сектора на 0x20 равен 6, делая мини-секторы 64-байтовыми; а порог мини-потока на 0x38 — 4096. Арифметика адресов, следующая далее, — самое частое место, где ошибаются. Сектор 0 начинается сразу после заголовка, так что сектор N начинается по байтовому смещению 512 + N * SectorSize — обратите внимание на литерал 512, а не SectorSize. В файле версии 3 они идентичны, и баг прячется навсегда; в файле версии 4 он молча читает неверный сектор, поэтому HotXLS держит это в одной функции, SidToOffset

Составной файл — это файловая система FAT внутри файла, так что чтение его означает обход связных списков ID секторов, где FAT[n] держит ID, следующий за сектором n. Три сигнальных значения завершают или аннотируют цепочку — ENDOFCHAIN, FATSECT для сектора, принадлежащего самой FAT, и DIFSECT для сектора DIFAT — и все три читаются как отрицательные знаковые 32-битные целые, что упрощает условия цикла. Чтобы найти FAT, нужна ещё одна косвенность: DIFAT — это массив ID секторов, говорящий, где живут секторы FAT, и первые 109 его записей сидят в заголовке на смещении 0x4C. TlxCompoundFile обходит эти 109, останавливается на первой отрицательной записи и конкатенирует каждый сектор FAT в один плоский массив Integer. Это 109 секторов FAT по 128 записей каждый на 512-байтовом секторе, то есть примерно 13 952 адресуемых сектора, то есть примерно 6,8 МиБ контейнера, прежде чем DIFAT должен перелиться в собственную цепочку

Вторая таблица распределения существует, потому что 512-байтовые секторы тратят большую часть своего пространства на маленькие потоки. Любой поток ниже порога в 4096 байт вообще не хранится в секторах: он живёт внутри мини-потока, сам являющегося обычным потоком, висящим на записи корневого каталога, подразделённого на 64-байтовые мини-секторы и сцепленного через параллельную MiniFAT, корень которой на смещении заголовка 0x3C. Откройте реальный .xls, и поток Workbook сидит на обычной FAT, пока потоки сводной информации сидят внизу в пространстве мини-секторов, поэтому реализация, покрывающая только путь FAT, кажется рабочей ровно до тех пор, пока ей не понадобятся метаданные документа. Каталог — третья структура и та, что делает контейнер навигируемым: каждая запись ровно 128 байт, четыре на 512-байтовый сектор, несёт UTF-16-имя в первых 64 байтах, его байтовую длину на 0x40, тип объекта на 0x42 (1 = хранилище, 2 = поток, 5 = корень), связи дерева на 0x44, 0x48 и 0x4C, начальный сектор на 0x74 и 32-битный размер потока на 0x78. Эта длина имени считает байты, включая завершающий ноль, так что число символов — NameLen div 2 - 1, и ошибиться на единицу здесь — способ получить поток по имени Workboo

Извлечение потока Workbook из буфера памяти

TlxCompoundFile.OpenStream скрывает всё вышесказанное за одним вызовом, принимающим имя потока и возвращающим 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, так что ответственность за исходный поток остаётся у вызывающей стороны — намеренно, потому что типичный случай — это поток, которым приложение уже владеет. А OpenStream возвращает TlxCfbStream, владеющий собственной копией байт, выставленной через Data, Size, Read, Seek и CopyTo. Эта копия — реальная цена на большой книге, и это честная плата за дизайн, где возвращённый объект остаётся валидным после того, как контейнер освобождён. Когда книга достаточно велика, чтобы полная копия в памяти была в принципе неверной формой, потоковый прямой читатель для избыточно больших таблиц — лучшая точка входа

Почему зашифрованный XLSX выглядит как файл XLS?

Потому что это он и есть, на уровне контейнера — и это практическая выгода от владения этим слоем. Откройте зашифрованный .xlsx в hex-редакторе, и первые восемь байт — D0 CF 11 E0 A1 B1 1A E1, байт в байт идентичны .xls образца 1997 года, потому что шифрование [MS-OFFCRYPTO] не шифрует ZIP-пакет на месте: оно оборачивает весь пакет внутри контейнера CFB как поток по имени EncryptedPackage, рядом с потоком EncryptionInfo, описывающим алгоритм. Сигнатура поэтому идентифицирует контейнер и ничего не говорит о полезной нагрузке. Отличить книгу BIFF от зашифрованного пакета 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;

Имена каталога заслуживают собственного предупреждения: потоки сводной информации несут ведущий управляющий символ 0x05 в своих именах, так что сравнение, написанное против обычной строки отображения, никогда с ними не совпадёт, а наивная строка журнала отрисует их как мусор. Всё, что ниже по потоку этой классификации — вывод ключа, проверка верификатора пароля, — отдельная проблема, разобранная в заметках о том, почему Excel отклоняет книгу, зашифрованную с неверным режимом шифра. Слой контейнера лишь сообщает вам, перед какой дверью вы стоите

Запись контейнера, который Excel действительно откроет

Сторона записи TlxCompoundFile намеренно уже стороны чтения, и понимание почему избавляет от спора со спецификацией. [MS-CFB] допускает огромное пространство валидных контейнеров: многоуровневые хранилища, правильно сбалансированные красно-чёрные деревья каталогов, мини-потоки, цепочки DIFAT. Excel выпускает небольшой угол этого пространства и читает несколько больший. HotXLS пишет угол ещё меньше — минимум, который Excel демонстрируемо загружает. Каждый поток идёт на обычную FAT без пути мини-потока, что стоит места на диске и покупает корректность: 300-байтовый поток сводки, который Excel упаковал бы в пять 64-байтовых мини-секторов, вместо этого занимает целый 512-байтовый сектор, а для книги это шум по сравнению с поддержанием второй таблицы распределения, второго обхода цепочки и потока корневой записи, поддерживающего его на пути записи. Записи каталога формируют плоскую цепочку братьев под корнем, с каждым узлом, окрашенным в чёрный, и порядок выпуска фиксирован: заполнитель заголовка, секторы данных потока, секторы каталога, секторы FAT, затем возврат назад для перезаписи заголовка с ID секторов, которые известны только в конце. FAT определяет свой размер через короткий цикл с фиксированной точкой, потому что добавление секторов 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 и не следует по цепочке DIFAT на 0x44 дальше них, ограничивая читаемый контейнер примерно 6,8 МиБ на 512-байтовых секторах — с комфортным запасом выше реальных файлов .xls, встречающихся HotXLS на практике, но всё же жёсткий потолок, и писатель явно обеспечивает тот же лимит, а не выпускает контейнер, который не может описать. Во-вторых, контейнеры версии 4 с 4096-байтовыми секторами учитываются арифметикой размера секторов, но не то, под что настроен код, а 64-битный размер потока не консультируется: HotXLS читает младшие 32 бита на смещении 0x78 и оставляет старшую половину в покое, что верно для версии 3 и только для версии 3. В-третьих, поиск записи — это плоское сканирование по имени по списку каталога, а не обход красно-чёрного дерева от родительского хранилища, так что вложенные хранилища разрешаются коллизией имени, а не путём — каждый поток, нужный файлу .xls, сидит на верхнем уровне, что и делает более простой дизайн защитимым, но код, ожидающий адресовать SomeStorage/SomeStream, его не найдёт

Ничто из этого не меняет назначение модуля. Владение слоем контейнера превращает обработку .xls в обычный Object Pascal: парсируемый из массива байт, тестируемый без файловой системы, портируемый на любую платформу, под которую нацелен компилятор, и свободный от COM-апартамента. Это также упраздняет обходные пути принюхивания, потому что идентификация книги теперь означает чтение её каталога, а не первых восьми её байт — та же дисциплина стоит за перечислением имён листов без открытия всей книги

TlxCompoundFile поставляется как часть Excel-компонента HotXLS для Delphi и C++Builder, наряду со слоями BIFF и OOXML, лежащими поверх него; страница продукта содержит полный справочник модуля и поддерживаемую матрицу компиляторов