Artículo técnico

Leer archivos compuestos OLE2 en Delphi sin IStorage COM

HotXLS Excel Library para Delphi y C++Builder lee y escribe el contenedor Compound File Binary detrás de cada archivo .xls heredado en Object Pascal puro. La clase TlxCompoundFile implementa directamente el diseño de [MS-CFB] versión 3 contra un TStream: encabezado, DIFAT, cadenas FAT, MiniFAT y el árbol de directorio, sin ole32.dll y sin ningún IStorage de COM en ninguna parte de la ruta

Eso suena a plomería, y durante veinte años fue plomería que le pertenecía a alguien más. Cada base de código Delphi que tocaba un archivo .xls recurría a StgOpenStorage, obtenía de vuelta un IStorage, y extraía el flujo Workbook de ahí. Tres líneas, funcionaba bien, nadie volvía a pensarlo, hasta el día en que ese mismo código tenía que correr en algún lugar donde no había Windows

Por qué StgOpenStorage deja de funcionar en un servidor

La API de almacenamiento estructurado de COM falla exactamente en las formas de implementación en las que vive el Delphi moderno, por razones que no tienen nada que ver con el formato de archivo. StgOpenStorage es un punto de entrada Win32 en ole32.dll: quiere una ruta en un sistema de archivos, quiere COM inicializado en el hilo que hace la llamada, 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 buffer, no en disco, así que escribes el buffer en un archivo temporal, lo abres, lo vuelves a leer, lo borras, y ahora eres dueño de un ciclo de vida de archivo temporal que se puede arruinar bajo carga. ILockBytes es la escotilla de escape documentada, pero conectar una implementación personalizada sobre un TMemoryStream es más interoperabilidad COM de la que la mayoría de los equipos quiere. 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 objetivo es Linux bajo FPC, una imagen de contenedor, o macOS. HotXLS, por lo tanto, mantiene la ruta clásica lxOLE construida sobre StgOpenStorage como valor por defecto, ya que está probada en batalla y quienes ya la invocan no deberían tener que cambiar; TlxCompoundFile es la alternativa opcional para todos los demás

Lo que realmente dicen el encabezado y las cadenas FAT

Los primeros 512 bytes de un archivo compuesto responden cada pregunta estructural que necesitas antes de leer un solo byte de la carga útil. [MS-CFB] §2.2 fija la firma del encabezado en el desplazamiento 0 como los ocho bytes D0 CF 11 E0 A1 B1 1A E1, y lxIsCompoundStream comprueba exactamente eso, restaurando la posición del flujo después para que quien invoca pueda husmear sin alterar nada. Cuatro campos más deciden la geometría: el orden de bytes en 0x1C debe ser 0xFFFE, que funciona 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 un desplazamiento de 9 para sectores de 512 bytes y la versión 4 usa un desplazamiento de 12 para 4096; el desplazamiento de minisector en 0x20 es 6, haciendo los minisectores de 64 bytes; y el corte de miniflujo en 0x38 es 4096. La aritmética de direcciones que sigue es el lugar más común para equivocarse. El sector 0 comienza inmediatamente después del encabezado, así que el sector N comienza en el desplazamiento de bytes 512 + N * SectorSize, nota el 512 literal, no SectorSize. En un archivo de versión 3 ambos son idénticos y el error se oculta para siempre; en un archivo de versión 4 lee en silencio el sector equivocado, que es por qué HotXLS mantiene esto en una sola función, SidToOffset

Un archivo compuesto es un sistema de archivos FAT dentro de un archivo, así que leerlo significa recorrer listas enlazadas de ID 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 de 32 bits con signo negativos, lo que mantiene simples las condiciones del bucle. Encontrar la FAT necesita una indirección más: el DIFAT es el arreglo de ID de sector que dice dónde viven los sectores de la FAT, y sus primeras 109 entradas se ubican en el encabezado en el desplazamiento 0x4C. TlxCompoundFile recorre esas 109, se detiene en la primera entrada negativa, y concatena cada sector de la FAT en un solo arreglo 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 derramarse 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 flujos pequeños. Cualquier flujo por debajo del corte de 4096 bytes no se almacena en sectores en absoluto: vive dentro del miniflujo, que es en sí mismo un flujo ordinario colgado de la entrada de directorio raíz, subdividido en minisectores de 64 bytes y encadenado a través de un MiniFAT paralelo con raíz en el desplazamiento de encabezado 0x3C. Abre un .xls real y el flujo Workbook se encuentra en la FAT normal mientras los flujos de información de resumen se encuentran abajo en el espacio de minisectores, que es por qué una implementación que cubre solo la ruta FAT parece funcionar hasta el momento en que necesita metadatos del documento. El directorio es la tercera estructura y la que hace navegable al contenedor: cada entrada mide exactamente 128 bytes, cuatro por sector de 512 bytes, y lleva un nombre UTF-16 en los primeros 64 bytes, su longitud en bytes en 0x40, el tipo de objeto en 0x42 (1 = almacenamiento, 2 = flujo, 5 = raíz), enlaces del árbol en 0x44, 0x48 y 0x4C, el sector inicial en 0x74 y el tamaño de flujo de 32 bits en 0x78. Esa longitud de nombre cuenta bytes incluyendo el nulo terminador, así que la cantidad de caracteres es NameLen div 2 - 1, y equivocarse por uno ahí es cómo terminas con un flujo llamado Workboo

Extraer un flujo Workbook de un buffer de memoria

TlxCompoundFile.OpenStream oculta todo lo anterior detrás de una sola llamada que recibe un nombre de flujo y devuelve un TlxCfbStream que contiene los bytes ya completamente materializados. Toda la secuencia (husmear, cargar, extraer) se ejecuta contra un TBytesStream sin que nada toque nunca el 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 la pena señalar dos detalles ahí. LoadFromStream recibe un indicador AOwnsStream cuyo valor por defecto es False, así que quien invoca conserva la responsabilidad sobre el flujo de origen, algo deliberado, porque el caso común es un flujo que la aplicación ya posee. Y OpenStream devuelve un TlxCfbStream propietario de su propia copia de los bytes, expuesta mediante Data, Size, Read, Seek y CopyTo. Esa copia es un costo real en un libro grande, y es el precio honesto de un diseño donde el objeto devuelto sigue siendo válido después de que el contenedor se libera. Cuando un libro es lo bastante grande como para que una copia completa en memoria sea la forma equivocada por completo, el lector directo de streaming para hojas de cálculo de gran tamaño es el mejor punto de entrada

Por qué un XLSX cifrado se ve como un archivo XLS

Porque lo es, a nivel de contenedor, y esta es la ganancia 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 por byte a un .xls de la década de 1997, porque el cifrado de [MS-OFFCRYPTO] no cifra el paquete ZIP en su lugar: envuelve todo el paquete dentro de un contenedor CFB como un flujo llamado EncryptedPackage, junto a un flujo EncryptionInfo que describe el algoritmo. La firma, por lo tanto, identifica al contenedor y no dice nada sobre la carga útil. Distinguir un libro BIFF de un paquete OOXML cifrado significa leer el directorio, que después de LoadFromStream es un recorrido sobre EntryCount y Entries, o un 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;

Los nombres de directorio merecen su propia advertencia: los flujos de información de resumen llevan un carácter de control 0x05 inicial en sus nombres, así que una comparación escrita contra una cadena de visualización simple nunca coincidirá con ellos y una línea de registro ingenua los renderiza 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, cubierto en las notas sobre por qué Excel rechaza un libro cifrado con el modo de cifrado equivocado. La capa de contenedor solo te dice frente a qué puerta estás parado

Escribir un contenedor que Excel realmente abrirá

El lado de escritura de TlxCompoundFile es deliberadamente más estrecho que el lado de lectura, y entender por qué evita una discusión con la especificación. [MS-CFB] permite un espacio enorme de contenedores válidos: almacenamientos multinivel, árboles de directorio rojo-negro correctamente balanceados, miniflujos, cadenas DIFAT. Excel emite un pequeño rincón de ese espacio y lee uno algo más grande. HotXLS escribe un rincón todavía más pequeño: el mínimo que Excel demostrablemente carga. Cada flujo va en la FAT normal sin ninguna ruta de miniflujo, lo que cuesta espacio en disco y compra corrección: un flujo de resumen de 300 bytes que Excel habría empaquetado en cinco minisectores de 64 bytes en su lugar ocupa 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 flujo de la 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 del encabezado, sectores de datos de flujo, sectores de directorio, sectores de FAT, y luego un retroceso para reescribir el encabezado con los ID de sector que solo se conocen al final. La FAT se dimensiona a sí misma mediante un breve bucle de punto fijo, porque agregar sectores de FAT puede empujar la cantidad de sectores lo suficientemente 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

Vale la pena establecer tres límites con claridad, porque un lector de contenedores que maneja mal un caso límite en silencio es peor que uno que lanza una excepción. TlxCompoundFile lee las 109 entradas DIFAT residentes en el encabezado y no sigue la cadena DIFAT en 0x44 más allá de ellas, limitando un contenedor legible a aproximadamente 6.8 MiB en sectores de 512 bytes, cómodamente por encima de los archivos .xls reales que HotXLS encuentra en el campo, pero un techo duro de todas formas, y el escritor impone el mismo límite explícitamente en lugar de emitir un contenedor que no puede describir. Segundo, los contenedores de versión 4 con sectores de 4096 bytes se acomodan mediante la aritmética de tamaño de sector pero no son para lo que está afinado el código, y el tamaño de flujo de 64 bits no se consulta: HotXLS lee los 32 bits bajos en el desplazamiento 0x78 y deja la mitad alta sin tocar, lo cual es correcto para la versión 3 y solo para la versión 3. Tercero, la búsqueda de entradas es un recorrido plano por nombre a través de la lista de directorio en lugar de un recorrido hacia abajo por el árbol rojo-negro desde un almacenamiento padre, así que los almacenamientos anidados se resuelven por colisión de nombre en lugar de por ruta; cada flujo que necesita un archivo .xls se encuentra en el nivel superior, que es lo que hace defendible el diseño más simple, pero 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 arreglo de bytes, comprobable sin un sistema de archivos, portable a cualquier plataforma que el compilador tenga como objetivo, y libre de un apartamento COM. También retira 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 hojas sin abrir todo el libro

TlxCompoundFile se incluye como parte del HotXLS Excel Component para Delphi y C++Builder, junto con las capas BIFF y OOXML que se apoyan sobre ella; la página de producto incluye la referencia completa de la unidad y la matriz de compiladores compatibles