Artigo Técnico

Ler Ficheiros Compostos OLE2 em Delphi Sem IStorage COM

A HotXLS Excel Library para Delphi e C++Builder lê e escreve o contentor Compound File Binary por trás de cada ficheiro .xls legado em Object Pascal puro. A classe TlxCompoundFile implementa diretamente o layout [MS-CFB] versão 3 contra um TStream — cabeçalho, DIFAT, cadeias FAT, MiniFAT e a árvore de diretório — sem ole32.dll nem IStorage COM em lado nenhum do caminho

Isto soa a canalização, e durante vinte anos foi canalização que outra pessoa possuía. Cada base de código Delphi que tocava num ficheiro .xls recorria a StgOpenStorage, obtinha de volta um IStorage, e retirava dali o stream Workbook. Três linhas, funcionava bem, ninguém voltava a pensar nisso — até ao dia em que o mesmo código teve de correr algures onde não havia Windows

Porque é que o StgOpenStorage deixa de funcionar num servidor?

A API de armazenamento estruturado COM falha exatamente nas formas de implantação em que o código Delphi moderno vive, por razões que nada têm a ver com o formato do ficheiro. StgOpenStorage é um ponto de entrada Win32 em ole32.dll: quer um caminho num sistema de ficheiros, quer o COM inicializado na thread chamadora, e quer estar em Windows. O requisito de caminho dói primeiro, porque um endpoint REST que recebe uma folha de cálculo carregada tem os bytes num buffer, não em disco — pelo que se escreve o buffer num ficheiro temporário, se abre, se lê de volta, se elimina, e agora é dono de um ciclo de vida de ficheiro temporário para errar sob carga. O ILockBytes é a válvula de escape documentada, mas ligar uma implementação personalizada sobre um TMemoryStream é mais interop COM do que a maioria das equipas quer. O requisito de inicialização morde a seguir, normalmente numa thread de trabalho de um serviço em que ninguém chamou CoInitialize, e o requisito de plataforma termina a conversa no momento em que o alvo é Linux sob FPC, uma imagem de contentor, ou macOS. O HotXLS, por isso, mantém o caminho clássico lxOLE construído sobre StgOpenStorage como predefinição, já que está testado em combate e os chamadores existentes não devem ter de mudar; TlxCompoundFile é a alternativa opt-in para todos os outros

O que o cabeçalho e as cadeias FAT realmente dizem

Os primeiros 512 bytes de um ficheiro composto respondem a cada questão estrutural que se precisa antes de ler um único byte de payload. O [MS-CFB] §2.2 fixa a assinatura de cabeçalho no offset 0 como os oito bytes D0 CF 11 E0 A1 B1 1A E1, e lxIsCompoundStream verifica exatamente isso, restaurando a posição do stream a seguir para que um chamador possa farejar sem perturbar nada. Mais quatro campos decidem a geometria: a ordem de bytes em 0x1C tem de ser 0xFFFE, o que serve também como uma segunda verificação de assinatura barata; o sector shift em 0x1E dá o tamanho do setor como 1 shl SectorShift, pelo que a versão 3 usa shift 9 para setores de 512 bytes e a versão 4 usa shift 12 para 4096; o mini sector shift em 0x20 é 6, o que torna os mini setores de 64 bytes; e o corte do mini stream em 0x38 é 4096. A aritmética de endereços que se segue é o sítio mais comum para errar. O setor 0 começa imediatamente a seguir ao cabeçalho, pelo que o setor N começa no offset de byte 512 + N * SectorSize — repare no literal 512, não SectorSize. Num ficheiro versão 3 os dois são idênticos e o bug esconde-se para sempre; num ficheiro versão 4 lê silenciosamente o setor errado, e é por isso que o HotXLS mantém isto numa só função, SidToOffset

Um ficheiro composto é um sistema de ficheiros FAT dentro de um ficheiro, pelo que lê-lo significa percorrer listas ligadas de IDs de setor onde FAT[n] guarda o ID que se segue ao setor n. Três sentinelas terminam ou anotam uma cadeia — ENDOFCHAIN, FATSECT para um setor pertencente à própria FAT, e DIFSECT para um setor DIFAT — e todos os três se leem como inteiros de 32 bits com sinal negativos, o que mantém as condições do ciclo simples. Encontrar a FAT precisa de mais uma indireção: a DIFAT é o array de IDs de setor que diz onde vivem os setores da FAT, e as suas primeiras 109 entradas ficam no cabeçalho no offset 0x4C. TlxCompoundFile percorre essas 109, para na primeira entrada negativa, e concatena cada setor da FAT num único array plano de Integer. Isso são 109 setores de FAT a 128 entradas cada num setor de 512 bytes, portanto 13 952 setores endereçáveis, portanto cerca de 6,8 MiB de contentor antes de a DIFAT ter de transbordar para uma cadeia própria

A segunda tabela de alocação existe porque os setores de 512 bytes desperdiçam a maior parte do seu espaço em streams pequenos. Qualquer stream abaixo do corte de 4096 bytes não é guardado em setores de todo: vive dentro do mini stream, ele próprio um stream comum pendurado na entrada de diretório da raiz, subdividido em mini setores de 64 bytes e encadeado através de uma MiniFAT paralela com raiz no offset de cabeçalho 0x3C. Abra um .xls real e o stream Workbook fica na FAT normal enquanto os streams de informação de resumo ficam lá em baixo no espaço de mini setores, e é por isso que uma implementação que cobre apenas o caminho FAT parece funcionar até precisar de metadados de documento. O diretório é a terceira estrutura e a que torna o contentor navegável: cada entrada tem exatamente 128 bytes, quatro por setor de 512 bytes, transportando um nome UTF-16 nos primeiros 64 bytes, o seu comprimento em bytes em 0x40, o tipo de objeto em 0x42 (1 = storage, 2 = stream, 5 = raiz), ligações de árvore em 0x44, 0x48 e 0x4C, o setor inicial em 0x74 e o tamanho de stream de 32 bits em 0x78. Esse comprimento de nome conta bytes incluindo o nulo terminador, pelo que a contagem de carateres é NameLen div 2 - 1, e errar isto por um é como se acaba com um stream chamado Workboo

Retirar um stream Workbook de um buffer de memória

TlxCompoundFile.OpenStream esconde tudo o que precede atrás de uma única chamada que recebe um nome de stream e devolve um TlxCfbStream a guardar os bytes totalmente materializados. Toda a sequência — farejar, carregar, extrair — corre contra um TBytesStream sem nada alguma vez tocar em 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;

Vale a pena destacar dois pormenores. LoadFromStream recebe uma flag AOwnsStream predefinida como False, pelo que o chamador mantém a responsabilidade pelo stream de origem — deliberado, porque o caso comum é um stream que a aplicação já possui. E OpenStream devolve um TlxCfbStream que possui a sua própria cópia dos bytes, exposta através de Data, Size, Read, Seek e CopyTo. Essa cópia é um custo real numa folha de cálculo grande, e é o preço honesto de um design em que o objeto devolvido permanece válido depois de o contentor ser libertado. Quando uma folha de cálculo é grande o suficiente para que uma cópia completa em memória seja simplesmente a forma errada, o leitor direto em streaming para folhas de cálculo sobredimensionadas é o ponto de entrada melhor

Porque é que um XLSX encriptado parece um ficheiro XLS?

Porque é um, ao nível do contentor — e este é o ganho prático de possuir essa camada. Abra um .xlsx encriptado num editor hexadecimal e os primeiros oito bytes são D0 CF 11 E0 A1 B1 1A E1, byte a byte idênticos a um .xls de 1997, porque a encriptação [MS-OFFCRYPTO] não encripta o pacote ZIP no próprio lugar: envolve o pacote inteiro dentro de um contentor CFB como um stream chamado EncryptedPackage, ao lado de um stream EncryptionInfo que descreve o algoritmo. A assinatura, portanto, identifica o contentor e não diz nada sobre o payload. Distinguir uma folha de cálculo BIFF de um pacote OOXML encriptado significa ler o diretório, o que depois de LoadFromStream é uma varredura sobre EntryCount e Entries, ou um par de sondas 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;

Os nomes de diretório merecem um aviso próprio: os streams de informação de resumo transportam um caráter de controlo 0x05 inicial nos seus nomes, pelo que uma comparação escrita contra uma string de exibição simples nunca vai corresponder a eles e uma linha de log ingénua renderiza-os como lixo. Tudo o que fica a jusante desta classificação — derivar a chave, verificar o verificador de palavra-passe — é um problema separado, coberto nas notas sobre porque é que o Excel rejeita uma folha de cálculo encriptada com o modo de cifra errado. A camada de contentor só diz a que porta está de frente

Escrever um contentor que o Excel realmente abre

O lado de escrita de TlxCompoundFile é deliberadamente mais estreito do que o lado de leitura, e perceber porquê poupa uma discussão com a especificação. O [MS-CFB] permite um espaço enorme de contentores válidos: storages de vários níveis, árvores de diretório red-black devidamente equilibradas, mini streams, cadeias DIFAT. O Excel emite um pequeno canto desse espaço e lê um um pouco maior. O HotXLS escreve um canto ainda mais pequeno — o mínimo que o Excel demonstravelmente carrega. Cada stream vai para a FAT normal sem caminho de mini stream, o que custa espaço em disco e compra correção: um stream de resumo de 300 bytes que o Excel teria empacotado em cinco mini setores de 64 bytes ocupa em vez disso um setor completo de 512 bytes, e para uma folha de cálculo isso é ruído comparado com manter uma segunda tabela de alocação, um segundo percurso de cadeia e o stream de entrada raiz que o suporta no caminho de escrita. As entradas de diretório formam uma cadeia irmã plana sob a raiz com cada nó colorido a preto, e a ordem de emissão é fixa: placeholder de cabeçalho, setores de dados de stream, setores de diretório, setores de FAT, depois um seek de volta para reescrever o cabeçalho com os IDs de setor que só são conhecidos no final. A FAT dimensiona-se a si própria através de um pequeno ciclo de ponto fixo, porque acrescentar setores de FAT pode empurrar a contagem de setores o suficiente para exigir mais um setor de 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;

Onde a implementação para

Vale a pena declarar três fronteiras com clareza, porque um leitor de contentor que trata mal um caso limite em silêncio é pior do que um que dispara um erro. TlxCompoundFile lê as 109 entradas DIFAT residentes no cabeçalho e não segue a cadeia DIFAT em 0x44 para lá delas, limitando um contentor legível a cerca de 6,8 MiB em setores de 512 bytes — confortavelmente acima dos ficheiros .xls reais que o HotXLS encontra no terreno, mas um teto rígido ainda assim, e o escritor impõe o mesmo limite explicitamente em vez de emitir um contentor que não consegue descrever. Segundo, os contentores versão 4 com setores de 4096 bytes são acomodados pela aritmética de tamanho de setor mas não são aquilo para que o código está afinado, e o tamanho de stream de 64 bits não é consultado: o HotXLS lê os 32 bits baixos no offset 0x78 e deixa a metade alta em paz, o que está correto para a versão 3 e só para a versão 3. Terceiro, a pesquisa de entrada é uma varredura plana por nome através da lista de diretório em vez de um percurso descendente na árvore red-black a partir de um storage pai, pelo que storages aninhados resolvem por colisão de nome em vez de por caminho — cada stream que um ficheiro .xls precisa fica ao nível de topo, o que é o que torna o design mais simples defensável, mas código à espera de endereçar SomeStorage/SomeStream não o vai encontrar

Nada disso muda para que serve a unit. Possuir a camada de contentor transforma o tratamento de .xls em Object Pascal comum: analisável a partir de um array de bytes, testável sem um sistema de ficheiros, portável para qualquer plataforma que o compilador tenha como alvo, e livre de um apartamento COM. Também reforma os atalhos de deteção, porque identificar uma folha de cálculo agora significa ler o seu diretório em vez dos seus primeiros oito bytes — a mesma disciplina por trás de listar nomes de folhas sem abrir a folha de cálculo inteira

TlxCompoundFile vem incluído no HotXLS Excel Component para Delphi e C++Builder, a par das camadas BIFF e OOXML que assentam sobre ela; a página de produto contém a referência completa da unit e a matriz de compiladores suportados