Artículo técnico

Leer ficheros compuestos OLE2 en Delphi sin IStorage COM

HotXLS Excel Library para Delphi y C++Builder lee y escribe el contenedor Compound File Binary que hay detrás de cada fichero .xls heredado, en Object Pascal puro. La clase TlxCompoundFile implementa directamente el layout [MS-CFB] versión 3 contra un TStream —cabecera, DIFAT, cadenas FAT, MiniFAT y árbol de directorio— sin ole32.dll ni IStorage de COM en ningún punto del camino

Eso suena a fontanería, y durante veinte años fue fontanería que le pertenecía a otro. Cada base de código Delphi que tocaba un fichero .xls recurría a StgOpenStorage, obtenía un IStorage, y extraía de él el stream Workbook. Tres líneas, funcionaba bien, nadie volvía a pensar en ello —hasta el día en que ese mismo código tuvo que ejecutarse en algún sitio que no era Windows

¿Por qué StgOpenStorage deja de funcionar en un servidor?

La API COM de almacenamiento estructurado falla exactamente en las formas de despliegue en las que vive el código Delphi moderno, por razones que no tienen nada que ver con el formato de fichero. StgOpenStorage es un punto de entrada Win32 en ole32.dll: quiere una ruta en un sistema de ficheros, quiere COM inicializado en el hilo que llama, y quiere estar en Windows. El requisito de ruta duele primero, porque un endpoint REST que recibe un libro subido tiene los bytes en un búfer, no en disco —así que escribes el búfer en un fichero temporal, lo abres, lo lees de vuelta, lo borras, y ahora eres dueño de un ciclo de vida de fichero temporal que hacer mal bajo carga. ILockBytes es la vía de escape documentada, pero conectar una implementación propia sobre un TMemoryStream es más interoperabilidad COM de la que la mayoría de los equipos quieren. El requisito de inicialización muerde en segundo lugar, normalmente en un hilo de trabajo de un servicio en el que nadie llamó a CoInitialize, y el requisito de plataforma termina la conversación en el momento en que el destino es Linux bajo FPC, una imagen de contenedor, o macOS. HotXLS por tanto mantiene la ruta clásica lxOLE construida sobre StgOpenStorage como la opción por defecto, ya que está probada en batalla y los llamantes existentes no deberían tener que cambiar; TlxCompoundFile es la alternativa opcional para todos los demás

Lo que la cabecera y las cadenas FAT dicen en realidad

Los primeros 512 bytes de un fichero compuesto responden a cada pregunta estructural que necesitas antes de leer un solo byte de contenido útil. [MS-CFB] §2.2 fija la firma de cabecera en el offset 0 como los ocho bytes D0 CF 11 E0 A1 B1 1A E1, y lxIsCompoundStream comprueba exactamente eso, restaurando después la posición del stream para que un llamante pueda husmear sin alterar nada. Cuatro campos más deciden la geometría: el orden de bytes en 0x1C debe ser 0xFFFE, que también sirve como una segunda comprobación de firma barata; el desplazamiento de sector en 0x1E da el tamaño de sector como 1 shl SectorShift, así que la versión 3 usa desplazamiento 9 para sectores de 512 bytes y la versión 4 usa desplazamiento 12 para 4096; el desplazamiento de mini sector en 0x20 es 6, lo que hace que los mini sectores midan 64 bytes; y el corte de mini stream en 0x38 es 4096. La aritmética de direcciones que sigue es el lugar más habitual para equivocarse. El sector 0 empieza inmediatamente después de la cabecera, así que el sector N empieza en el offset de byte 512 + N * SectorSize —fíjate en el literal 512, no SectorSize. En un fichero versión 3 ambos son idénticos y el bug se esconde para siempre; en un fichero versión 4 lee silenciosamente el sector equivocado, razón por la cual HotXLS mantiene esto en una sola función, SidToOffset

Un fichero compuesto es un sistema de ficheros FAT dentro de un fichero, así que leerlo significa recorrer listas enlazadas de IDs de sector donde FAT[n] contiene el ID que sigue al sector n. Tres centinelas terminan o anotan una cadena —ENDOFCHAIN, FATSECT para un sector que pertenece a la propia FAT, y DIFSECT para un sector DIFAT— y los tres se leen como enteros con signo de 32 bits negativos, lo que mantiene simples las condiciones del bucle. Encontrar la FAT necesita una indirección más: el DIFAT es el array de IDs de sector que dice dónde viven los sectores de la FAT, y sus primeras 109 entradas están en la cabecera en el offset 0x4C. TlxCompoundFile recorre esas 109, se detiene en la primera entrada negativa, y concatena cada sector de la FAT en un único array plano de Integer. Eso son 109 sectores de FAT con 128 entradas cada uno en un sector de 512 bytes, así que 13.952 sectores direccionables, así que aproximadamente 6,8 MiB de contenedor antes de que el DIFAT tenga que desbordarse en una cadena propia

La segunda tabla de asignación existe porque los sectores de 512 bytes desperdician la mayor parte de su espacio en streams pequeños. Cualquier stream por debajo del corte de 4096 bytes no se almacena en sectores en absoluto: vive dentro del mini stream, que es en sí mismo un stream ordinario colgado de la entrada de directorio raíz, subdividido en mini sectores de 64 bytes y encadenado a través de un MiniFAT paralelo que arranca en el offset de cabecera 0x3C. Abre un .xls real y el stream Workbook está en la FAT normal mientras que los streams de información resumen están abajo, en espacio de mini sector, razón por la cual una implementación que solo cubra la ruta FAT parece funcionar bien hasta que necesita metadatos del documento. El directorio es la tercera estructura y la que hace navegable el contenedor: cada entrada mide exactamente 128 bytes, cuatro por sector de 512 bytes, con un nombre UTF-16 en los primeros 64 bytes, su longitud en bytes en 0x40, el tipo de objeto en 0x42 (1 = storage, 2 = stream, 5 = raíz), enlaces de árbol en 0x44, 0x48 y 0x4C, el sector inicial en 0x74 y el tamaño de stream de 32 bits en 0x78. Esa longitud de nombre cuenta bytes incluyendo el null terminador, así que el número de caracteres es NameLen div 2 - 1, y equivocarse en uno es cómo acabas con un stream llamado Workboo

Extraer un stream Workbook de un búfer de memoria

TlxCompoundFile.OpenStream oculta todo lo anterior tras una única llamada que recibe un nombre de stream y devuelve un TlxCfbStream que contiene los bytes ya materializados por completo. La secuencia completa —husmear, cargar, extraer— se ejecuta contra un TBytesStream sin que nada llegue a tocar 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;

Merece la pena señalar dos detalles ahí. LoadFromStream recibe un flag AOwnsStream con valor por defecto False, así que el llamante conserva la responsabilidad sobre el stream de origen —deliberado, porque el caso habitual es un stream que la aplicación ya posee. Y OpenStream devuelve un TlxCfbStream que posee su propia copia de los bytes, expuesta a través de Data, Size, Read, Seek y CopyTo. Esa copia es un coste real en un libro grande, y es el precio honesto de un diseño en el que el objeto devuelto sigue siendo válido después de liberar el contenedor. Cuando un libro es lo bastante grande como para que una copia completa en memoria sea directamente la forma equivocada, el lector directo en streaming para hojas de cálculo sobredimensionadas es el mejor punto de entrada

¿Por qué un XLSX cifrado parece un fichero XLS?

Porque lo es, a nivel de contenedor —y esa es la ventaja práctica de ser dueño de esa capa. Abre un .xlsx cifrado en un editor hexadecimal y los primeros ocho bytes son D0 CF 11 E0 A1 B1 1A E1, idénticos byte a byte a un .xls de 1997, porque el cifrado [MS-OFFCRYPTO] no cifra el paquete ZIP en su sitio: envuelve el paquete entero dentro de un contenedor CFB como un stream llamado EncryptedPackage, junto a un stream EncryptionInfo que describe el algoritmo. La firma, por tanto, identifica el contenedor y no dice nada sobre el contenido. Distinguir un libro BIFF de un paquete OOXML cifrado significa leer el directorio, que tras LoadFromStream es un recorrido sobre EntryCount y Entries, o un par de sondeos 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;

Los nombres de directorio merecen una advertencia propia: los streams de información resumen llevan un carácter de control 0x05 inicial en sus nombres, así que una comparación escrita contra una cadena de visualización plana nunca los encontrará y una línea de log ingenua los muestra como basura. Todo lo que viene después de esta clasificación —derivar la clave, comprobar el verificador de contraseña— es un problema aparte, tratado en las notas sobre por qué Excel rechaza un libro cifrado con el modo de cifrado equivocado. La capa de contenedor solo te dice ante qué puerta estás

Escribir un contenedor que Excel realmente abra

El lado de escritura de TlxCompoundFile es deliberadamente más estrecho que el lado de lectura, y entender por qué te ahorra una discusión con la especificación. [MS-CFB] permite un espacio enorme de contenedores válidos: storages multinivel, árboles de directorio rojo-negro correctamente equilibrados, mini streams, cadenas DIFAT. Excel emite un pequeño rincón de ese espacio y lee uno algo mayor. HotXLS escribe un rincón todavía más pequeño —el mínimo que Excel demostrablemente carga. Cada stream va a la FAT normal sin ruta de mini stream, lo que cuesta espacio en disco y compra corrección: un stream de resumen de 300 bytes que Excel habría empaquetado en cinco mini sectores de 64 bytes ocupa en su lugar un sector completo de 512 bytes, y para un libro eso es ruido comparado con mantener una segunda tabla de asignación, un segundo recorrido de cadena y el stream de entrada raíz que lo respalda en la ruta de escritura. Las entradas de directorio forman una cadena plana de hermanos bajo la raíz con cada nodo coloreado de negro, y el orden de emisión es fijo: marcador de posición de cabecera, sectores de datos de stream, sectores de directorio, sectores de FAT, y luego un salto atrás para reescribir la cabecera con los IDs de sector que solo se conocen al final. La FAT se dimensiona a sí misma mediante un breve bucle de punto fijo, porque añadir sectores de FAT puede empujar el recuento de sectores lo bastante alto como para requerir otro sector 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;

Dónde se detiene la implementación

Merece la pena exponer con claridad tres límites, porque un lector de contenedores que gestiona mal un caso límite en silencio es peor que uno que lanza una excepción. TlxCompoundFile lee las 109 entradas DIFAT residentes en la cabecera y no sigue la cadena DIFAT en 0x44 más allá de ellas, lo que limita un contenedor legible a aproximadamente 6,8 MiB en sectores de 512 bytes —cómodamente por encima de los ficheros .xls reales que HotXLS encuentra sobre el terreno, pero un techo duro de todos modos, y el escritor impone explícitamente el mismo límite en lugar de emitir un contenedor que no puede describir. En segundo lugar, los contenedores versión 4 con sectores de 4096 bytes se acomodan gracias a la aritmética de tamaño de sector pero no son para lo que está afinado el código, y el tamaño de stream de 64 bits no se consulta: HotXLS lee los 32 bits bajos en el offset 0x78 y deja intacta la mitad alta, lo cual es correcto para la versión 3 y solo para la versión 3. En tercer lugar, la búsqueda de entradas es un recorrido plano por nombre a través de la lista de directorio en lugar de un descenso por el árbol rojo-negro desde un storage padre, así que los storages anidados se resuelven por colisión de nombre en lugar de por ruta —cada stream que un fichero .xls necesita está en el nivel superior, que es lo que hace defendible el diseño más simple, pero el código que espera direccionar SomeStorage/SomeStream no lo encontrará

Nada de eso cambia para qué sirve la unidad. Ser dueño de la capa de contenedor convierte el manejo de .xls en Object Pascal ordinario: analizable desde un array de bytes, testeable sin sistema de ficheros, portable a cualquier plataforma que el compilador tenga como destino, y libre de un apartamento COM. También jubila los atajos de husmeo, porque identificar un libro ahora significa leer su directorio en lugar de sus primeros ocho bytes —la misma disciplina detrás de listar nombres de hoja sin abrir el libro completo

TlxCompoundFile se incluye como parte del HotXLS Excel Component para Delphi y C++Builder, junto a las capas BIFF y OOXML que se apoyan en él; la página del producto incluye la referencia completa de la unidad y la matriz de compiladores soportados