Artigo Técnico

Ler Arquivos Compound OLE2 em Delphi Sem COM IStorage

O HotXLS Excel Library para Delphi e C++Builder lê e grava o container Compound File Binary por trás de todo arquivo .xls legado em Object Pascal puro. A classe TlxCompoundFile implementa o layout [MS-CFB] versão 3 diretamente contra um TStream — header, DIFAT, cadeias FAT, MiniFAT e a árvore de diretório — sem ole32.dll e sem COM IStorage em nenhum lugar do caminho

Isso soa como encanamento, e por vinte anos foi encanamento que outra pessoa possuía. Toda base de código Delphi que tocava em um arquivo .xls recorria a StgOpenStorage, recebia de volta um IStorage, e puxava o stream Workbook de dentro dele. Três linhas, funcionava bem, ninguém pensava nisso de novo — até o dia em que o mesmo código precisou rodar em algum lugar onde não havia Windows

Por que o StgOpenStorage para de funcionar em um servidor?

A API COM de structured storage 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 arquivo. StgOpenStorage é um ponto de entrada Win32 em ole32.dll: ele quer um caminho em um filesystem, quer o COM inicializado na thread chamadora, e quer estar no Windows. A exigência de caminho dói primeiro, porque um endpoint REST recebendo uma planilha enviada tem os bytes em um buffer, não em disco — então você grava o buffer em um arquivo temporário, o abre, lê de volta, exclui, e agora possui um ciclo de vida de arquivo temporário para errar sob carga. ILockBytes é a via de escape documentada, mas conectar uma implementação customizada sobre um TMemoryStream é mais interop COM do que a maioria das equipes quer. A exigência de inicialização morde em segundo lugar, geralmente em uma thread de worker de serviço em que ninguém chamou CoInitialize, e a exigência de plataforma encerra a conversa no momento em que o alvo é Linux sob FPC, uma imagem de container, ou macOS. O HotXLS, portanto, mantém o caminho clássico lxOLE construído sobre StgOpenStorage como padrão, já que ele é testado em batalha e chamadores existentes não deveriam precisar mudar; TlxCompoundFile é a alternativa opt-in para todo mundo mais

O que o header e as cadeias FAT realmente dizem

Os primeiros 512 bytes de um arquivo compound respondem toda pergunta estrutural que você precisa antes de ler um byte do payload. A [MS-CFB] §2.2 fixa a assinatura do header no offset 0 como os oito bytes D0 CF 11 E0 A1 B1 1A E1, e lxIsCompoundStream checa exatamente isso, restaurando a posição do stream depois para que quem chama possa farejar sem perturbar nada. Mais quatro campos decidem a geometria: a ordem de byte em 0x1C precisa ser 0xFFFE, que serve também como uma segunda checagem barata de assinatura; o sector shift em 0x1E dá o tamanho do setor como 1 shl SectorShift, então 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, tornando os mini setores de 64 bytes; e o corte do mini stream em 0x38 é 4096. A aritmética de endereço que segue é o lugar mais comum de errar. O setor 0 começa imediatamente depois do header, então o setor N começa no offset em bytes 512 + N * SectorSize — note o 512 literal, não SectorSize. Em um arquivo versão 3 os dois são idênticos e o bug se esconde para sempre; em um arquivo versão 4 ele lê silenciosamente o setor errado, e é por isso que o HotXLS mantém isso em uma única função, SidToOffset

Um arquivo compound é um sistema de arquivos FAT dentro de um arquivo, então lê-lo significa percorrer listas encadeadas de IDs de setor onde FAT[n] guarda o ID seguinte 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 são lidos como inteiros de 32 bits com sinal negativo, o que mantém as condições do loop simples. Encontrar a FAT precisa de mais uma indireção: a DIFAT é o array de IDs de setor dizendo onde vivem os setores da FAT, e suas primeiras 109 entradas ficam no header no offset 0x4C. A TlxCompoundFile percorre essas 109, para na primeira entrada negativa, e concatena cada setor de FAT em um único array Integer plano. Isso é 109 setores de FAT com 128 entradas cada em um setor de 512 bytes, então 13.952 setores endereçáveis, então aproximadamente 6,8 MiB de container antes que a DIFAT precise transbordar para uma cadeia própria

A segunda tabela de alocação existe porque 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 é armazenado em setores de forma alguma: ele vive dentro do mini stream, ele mesmo um stream comum pendurado na entrada de diretório raiz, subdividido em mini setores de 64 bytes e encadeado através de uma MiniFAT paralela enraizada no offset de header 0x3C. Abra um .xls real e o stream Workbook fica na FAT normal enquanto os streams de summary-information ficam lá embaixo no espaço de mini setor, o que é por que uma implementação cobrindo só o caminho da FAT parece funcionar até o momento em que precisa de metadados do documento. O diretório é a terceira estrutura e a que torna o container navegável: cada entrada tem exatamente 128 bytes, quatro por setor de 512 bytes, carregando um nome UTF-16 nos primeiros 64 bytes, seu comprimento em bytes em 0x40, o tipo de objeto em 0x42 (1 = storage, 2 = stream, 5 = root), links 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 null terminador, então a contagem de caracteres é NameLen div 2 - 1, e errar isso por um é como você acaba com um stream chamado Workboo

Extraindo um stream Workbook de um buffer de memória

TlxCompoundFile.OpenStream esconde tudo isso acima atrás de uma única chamada que recebe um nome de stream e retorna um TlxCfbStream guardando os bytes totalmente materializados. A sequência inteira — farejar, carregar, extrair — roda contra um TBytesStream sem nada jamais 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;

Dois detalhes ali valem a pena destacar. LoadFromStream aceita uma flag AOwnsStream com padrão False, então quem chama mantém a responsabilidade pelo stream de origem — deliberado, porque o caso comum é um stream que a aplicação já possui. E OpenStream retorna um TlxCfbStream que possui sua própria cópia dos bytes, exposta através de Data, Size, Read, Seek e CopyTo. Essa cópia é um custo real em uma planilha grande, e é o preço honesto de um design em que o objeto retornado permanece válido depois que o container é liberado. Quando uma planilha é grande o suficiente para que uma cópia completa em memória seja a forma errada por completo, o leitor direto em streaming para planilhas superdimensionadas é o ponto de entrada melhor

Por que um XLSX criptografado parece um arquivo XLS?

Porque ele é um, no nível de container — e esse é o retorno prático de possuir essa camada. Abra um .xlsx criptografado em um 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 safra 1997, porque a criptografia [MS-OFFCRYPTO] não criptografa o pacote ZIP no lugar: ela envolve o pacote inteiro dentro de um container CFB como um stream chamado EncryptedPackage, ao lado de um stream EncryptionInfo descrevendo o algoritmo. A assinatura, portanto, identifica o container e não diz nada sobre o payload. Distinguir um workbook BIFF de um pacote OOXML criptografado 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 summary-information carregam um caractere de controle 0x05 à frente em seus nomes, então uma comparação escrita contra uma string de exibição simples nunca vai bater com eles e uma linha de log ingênua os renderiza como lixo. Tudo a jusante dessa classificação — derivar a chave, checar o verificador de senha — é um problema separado, coberto nas notas sobre por que o Excel rejeita um workbook criptografado com o modo de cifra errado. A camada de container só te diz na frente de qual porta você está

Escrevendo um container que o Excel de fato vai abrir

O lado de escrita de TlxCompoundFile é deliberadamente mais estreito que o lado de leitura, e entender por quê poupa uma discussão com a especificação. A [MS-CFB] permite um espaço enorme de containers válidos: storages multinível, árvores de diretório red-black devidamente balanceadas, 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 menor — o mínimo que o Excel demonstravelmente carrega. Todo 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 summary de 300 bytes que o Excel teria empacotado em cinco mini setores de 64 bytes em vez disso ocupa um setor completo de 512 bytes, e para um workbook isso é ruído perto de manter uma segunda tabela de alocação, uma segunda varredura de cadeia e o stream da entrada raiz que a sustenta no caminho de escrita. As entradas de diretório formam uma cadeia irmã plana sob a raiz com todo nó colorido de preto, e a ordem de emissão é fixa: placeholder de header, setores de dados de stream, setores de diretório, setores de FAT, depois um seek de volta para reescrever o header com os IDs de setor que só são conhecidos no final. A FAT dimensiona a si mesma através de um pequeno loop de ponto fixo, porque adicionar setores de FAT pode empurrar a contagem de setores alto o suficiente para exigir outro 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

Três limites valem a pena declarar claramente, porque um leitor de container que trata mal silenciosamente um caso extremo é pior que um que dispara erro. TlxCompoundFile lê as 109 entradas de DIFAT residentes no header e não segue a cadeia de DIFAT em 0x44 além delas, limitando um container legível a aproximadamente 6,8 MiB em setores de 512 bytes — confortavelmente acima dos arquivos .xls reais que o HotXLS encontra em campo, mas um teto rígido de qualquer forma, e o writer impõe o mesmo limite explicitamente em vez de emitir um container que não consegue descrever. Segundo, containers de versão 4 com setores de 4096 bytes são acomodados pela aritmética de tamanho de setor mas não são para o 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 de lado, o que é correto para versão 3 e só para versão 3. Terceiro, a busca de entrada é uma varredura plana por nome através da lista de diretório em vez de uma travessia pela árvore red-black a partir de um storage pai, então storages aninhados resolvem por colisão de nome em vez de por caminho — todo stream que um arquivo .xls precisa fica no nível superior, o que é o que torna o design mais simples defensável, mas código esperando endereçar SomeStorage/SomeStream não vai encontrá-lo

Nada disso muda para que a unit serve. Possuir a camada de container transforma o tratamento de .xls em Object Pascal comum: analisável a partir de um array de bytes, testável sem um filesystem, portável para qualquer plataforma que o compilador tenha como alvo, e livre de um apartamento COM. Isso também aposenta os atalhos de farejamento, porque identificar um workbook agora significa ler seu diretório em vez de seus primeiros oito bytes — a mesma disciplina por trás de listar nomes de planilha sem abrir o workbook inteiro

TlxCompoundFile vem como parte do HotXLS Excel Component para Delphi e C++Builder, junto com as camadas BIFF e OOXML que ficam sobre ele; a página de produto traz a referência completa da unit e a matriz de compiladores suportados