Artículo técnico

Clasificar SupBook y XTI de BIFF en Delphi con HotXLS

Abres un xls antiguo, lo guardas de nuevo, y la fórmula de complemento que llamaba a una biblioteca de análisis registrada apunta ahora a una referencia vacía dentro del propio libro. HotXLS rastrea esa corrupción silenciosa hasta una sola suposición errónea: que un registro SupBook de BIFF es o bien self o bien un archivo externo. [MS-XLS] define siete clases, no dos

¿Por qué un libro guardado pierde sus enlaces de complemento?

Porque la prueba de clasificación era estructural en lugar de tipada. El atajo tradicional lee un registro SupBook ($01AE), comprueba si lleva el marcador self y, si no, trata la cadena que sigue como una URL de documento. Cada registro que no es ninguna de esas dos cosas cae en una rama por defecto, y la rama por defecto es casi siempre «este es el propio libro». Un enlace de soporte a complemento, un enlace same-sheet, una ranura sin usar y un registro truncado acaban todos llevando la misma etiqueta equivocada. Nada lanza una excepción mientras esto ocurre: el registro se interpretó, la fórmula se recompiló, el archivo se guardó sin aviso, y el defecto aflora tres semanas después cuando alguien nota una columna de ceros donde antes había una conversión de divisa. [MS-XLS] §2.4.271 describe un registro que puede ser una autorreferencia, una referencia same-sheet, un contenedor de funciones de complemento, un libro externo con ruta virtual y tabla de nombres de hoja, un enlace de datos DDE u OLE, o un marcador de posición sin usar — y un séptimo estado que no está en la especificación pero existe en discos reales, el registro que no interpreta. La solución no es una heurística mejor; es negarse a tener una heurística

Las siete clases que puede llevar un registro SupBook

HotXLS declara la taxonomía de enlaces de soporte como una enumeración cerrada en lxExternSheet.pas, y cada decisión aguas abajo conmuta sobre ella. Nueve valores de enumeración cubren las siete categorías, porque el caso DDE y OLE necesita un estado provisional antes de poder resolverse:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // no se pudo interpretar, o quedaron bytes al final
    slkSelf,              // este libro
    slkSameSheet,         // marcador U+0000
    slkAddIn,             // contenedor de funciones de complemento
    slkExternalWorkbook,  // ruta virtual + tabla de nombres de hoja
    slkDde,               // resuelto desde las banderas ExternName
    slkOle,               // resuelto desde las banderas ExternName
    slkDdeOrOle,          // uno de los dos, aún sin saber cuál
    slkUnused);           // marcador de un solo espacio

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // base cero, tal como se guarda en ExternSheet.rgXTI
    ExternID    : Integer;   // base uno, la convención interna
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

El despacho va por centinelas, no por cadenas. Un valor de campo de $0401 marca el registro self. Un recuento de hojas de uno emparejado con $3A01 marca un contenedor de complemento. Solo un valor en el rango 1 a $00FF significa que sigue una ruta virtual codificada, y solo entonces interpreta HotXLS una cadena. Cualquier cosa fuera de esas tres formas se queda en slkUnknown, y un registro cuya tabla de nombres de hoja no consume el cuerpo del registro exactamente se degrada de vuelta a slkUnknown incluso cuando la cabecera parecía plausible

La escalera guiada por centinelas que HotXLS usa para clasificar un registro SupBook de BIFF en siete clases, decodificando una cadena solo para valores del rango de ruta codificada y recurriendo a una clase desconocida en lugar de a una rama por defecto
Cada clase se alcanza por un centinela en lugar de por una prueba de cadena, y un registro que no encaja con ninguna de las formas se queda desconocido en vez de caer en una rama por defecto que significa este libro

¿Por qué el marcador same-sheet se decodifica como una cadena vacía?

Porque el lector de cadenas BIFF de propósito general destruye el byte del que depende la clasificación. El enlace de soporte same-sheet es una cadena de un carácter cuyo único carácter es U+0000, y TXLSBlob.GetBiffString la devuelve como un WideString vacío, indistinguible de una ruta genuinamente vacía — que es exactamente la entrada ante la que una heurística de autorreferencia responde «self». HotXLS por eso lee el primer punto de código crudo del cuerpo del registro en lugar de fiarse del valor decodificado:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // comprimido, un byte
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // ancho, dos bytes
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

Fíjate en la rama comprimido contra ancho. El byte de opciones está a un desplazamiento fijo de la cabecera de cadena y el primer punto de código es de un byte o de dos según el bit 0, así que leerlo como byte incondicionalmente funciona en la mayoría de los archivos y falla en los escritos por compilaciones localizadas — la peor distribución posible para un bug. El marcador sin usar se detecta igual, por su carga literal de un solo espacio, y el caso DDE u OLE por el separador U+0003 incrustado en la ruta codificada

Por qué HotXLS lee el primer punto de código crudo del cuerpo de un registro SupBook de BIFF en lugar de la cadena decodificada, porque el lector de cadenas de propósito general convierte el marcador same-sheet U+0000 en un valor vacío
El marcador same-sheet es una cadena de un carácter cuyo carácter es U+0000, así que el lector de cadenas general lo pliega en un valor vacío y solo el punto de código crudo en el desplazamiento del byte de opciones lo conserva

¿Por qué DDE y OLE no se pueden separar en el momento del SupBook?

Porque el registro SupBook no lleva los bits distintivos. Te dice que el enlace es uno de los dos; las banderas fOle y fOleLink que deciden cuál viven en el registro ExternName ($0023) que llega más adelante en el flujo. HotXLS registra slkDdeOrOle en el momento del análisis y lo estrecha en ParseExternalName, y si nunca llega ningún ExternName la clase se queda provisional para siempre — lo cual es correcto, porque el archivo genuinamente no lo dice. Cada consumidor aguas abajo trata ese valor provisional como un valor real y no como uno ausente, así que ningún llamador tiene que inventarse un desempate. Adivinar «probablemente DDE» aquí compraría una enumeración más ordenada y una clase de respuestas equivocadas que nadie podría rastrear:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

Los índices XTI son de base cero en disco y de base uno por dentro

HotXLS hace la conversión de desplazamiento de uno exactamente una vez, en el punto en que un token entra en el árbol sintáctico interno, y en ningún otro sitio. PtgNameX.ixti ([MS-XLS] §2.5.198.85) es un índice de base cero en el array rgXTI del registro ExternSheet ($0017, §2.4.106), mientras que la convención interna de ExternID de la biblioteca es de base uno con cero reservado para «sin hoja externa». La ruta de lectura BIFF8 hace FExternID := wValue + 1 cuando decodifica un token tNameX y la ruta de escritura emite StoreExternID - 1, dejando intactas la vista cruda del token y la semántica en disco. Equivocarse aquí es inusualmente difícil de detectar: los nombres definidos externos resuelven a la entrada vecina, y en un archivo con una sola entrada XTI el índice 0 pasa a 1, falla, y el nombre se degrada en silencio. Una regresión que solo ejercite texto de fórmulas recompiladas nunca lo ve, porque la recompilación nunca toca el índice en disco — la misma trampa que hace que los nombres definidos que abarcan hojas y libros merezcan probarse contra flujos de bytes reales. La resolución está acotada por ambos extremos: TlxExternSheetSheet.TryResolveXti devuelve False para un índice negativo o una entrada ausente, TXLSSupBook.TryGetKind devuelve False para un índice de SupBook fuera del array, y ClassifyXti mapea entonces slkSelf y slkSameSheet a frcInternal, slkExternalWorkbook a frcExternalWorkbook, y slkAddIn, slkDde, slkOle y slkDdeOrOle a frcExternalOther. Todo lo demás, incluidas todas las rutas fuera de rango, aterriza en frcUnknownOrMalformed

HotXLS convirtiendo el índice XTI de base cero de un token PtgNameX de BIFF en su ExternID interno de base uno en un único punto, con resolución acotada por ambos extremos y el mapa de clasificación que lo consume
El desplazamiento de uno entre el índice de disco de base cero y el ExternID interno de base uno se aplica una vez, cuando un token entra en el árbol sintáctico, y cada índice irresoluble aterriza en la clase malformada

Clasificar una fórmula antes de congelarla

TXLSCompiledFormula.ClassifyReferences escanea directamente el flujo de tokens BIFF preservado en lugar de descompilar la fórmula y buscar corchetes. Cazar corchetes en el texto de la fórmula es una heurística de texto con abrigo de parser: encuentra literales de cadena, encuentra referencias estructuradas y pasa por alto por completo los nombres definidos externos, ya que estos no llevan corchetes en forma descompilada. El escaneo de tokens mira solo PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d y PtgAreaErr3d, recurriendo a un recorrido del árbol sintáctico cuando no sobrevive ningún flujo BIFF. La fusión es deliberadamente pesimista — la prioridad fija es frcUnknownOrMalformed, luego frcExternalWorkbook, luego frcExternalOther, luego frcInternal — así que un solo token ilegible envenena toda la fórmula. Para un nombre definido externo el índice del nombre también se valida: de base uno, en rango y respaldado por un registro ExternName retenido

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets es de base uno
    begin
      Sheet := Wb.Sheets[i];
      // congela SOLO las fórmulas clasificadas frcExternalWorkbook;
      // las referencias internas, de complemento, DDE/OLE y
      // malformadas siguen siendo fórmulas
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

El parámetro OnlyExternal es donde la taxonomía se paga a sí misma. Congelar una fórmula es irreversible, así que la operación tiene que probar que una referencia es de un libro externo y no meramente sospecharlo. Las llamadas a complemento sobreviven, los enlaces DDE y OLE sobreviven, y todo lo que el parser no pudo entender del todo sobrevive, porque el resultado seguro de la incertidumbre es no cambiar nada. La misma disciplina gobierna re-enlazar fórmulas copiadas entre libros, donde una referencia mal clasificada se re-enlaza al libro equivocado en lugar de fallar a gritos

Los registros que no interpretan se reescriben intactos

HotXLS conserva la carga original del SupBook y la reemite byte a byte cuando el registro nunca se editó. Un fallo de análisis pone slkUnknown y limpia el estado derivado, pero el cuerpo capturado se queda en FRawData y la ruta de guardado lo prefiere sobre cualquier reconstrucción mientras el elemento no esté sucio y no sea el registro self. La alternativa — normalizar un registro sin interpretar en una autorreferencia para que el escritor tenga algo bien formado que emitir — convierte un registro que no entendiste en un registro definitivamente equivocado. Ese principio es el mismo contrato aplicado a los proyectos VBA y sus referencias externas a lo largo de un ciclo de carga y guardado, y es la diferencia entre una biblioteca que hace ida y vuelta con archivos reales y una que la hace con los archivos que su suite de tests contiene. Un libro que ha pasado por quince años de versiones de Excel, un generador de informes y dos herramientas de migración contendrá registros que nadie vivo hoy diseñó. Escríbelos tal como los encontraste

La clasificación tipada de registros SupBook y XTI se envió en HotXLS 2.361.2 a 2.361.4, junto con la resolución XTI acotada y la ruta más segura de ConvertFormulasToValues descrita aquí. Si mantienes código Delphi o C++Builder que lee archivos xls heredados con llamadas a complementos, enlaces DDE u OLE, o nombres definidos externos, el componente de hoja de cálculo HotXLS para Delphi maneja toda la taxonomía de forma nativa, sin instalación de Excel y sin automatización OLE en la máquina que hace el trabajo