Artículo técnico

SupBook y XTI en Delphi: clasificación de enlaces externos

Abra un xls antiguo, guárdelo 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 suposición errónea: que un registro BIFF SupBook es self o un archivo externo. [MS-XLS] define siete tipos, no dos

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

Porque la prueba de clasificación era estructural y no de tipos. 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. Todo registro que no sea ninguna de esas dos cosas cae en una rama por defecto, y la rama por defecto casi siempre dice "este es el propio libro". Un enlace de soporte a complemento, un enlace de la misma hoja, una ranura sin usar y un registro truncado acaban todos con la misma etiqueta equivocada. Nada lanza una excepción mientras ocurre: el registro se parseó, la fórmula se recompiló, el archivo se guardó sin advertencia, y el defecto aparece tres semanas después cuando alguien nota una columna de ceros donde había una conversión de moneda. [MS-XLS] §2.4.271 describe un registro que puede ser una autorreferencia, una referencia de la misma hoja, 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 se parsea. La solución no es una heurística mejor; es negarse a tener una heurística

Los siete tipos que puede llevar un registro SupBook

HotXLS declara la taxonomía de enlaces de soporte como una enumeración cerrada en lxExternSheet.pas, y toda 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 parseó, 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,               // se resuelve con los flags de ExternName
    slkOle,               // se resuelve con los flags de ExternName
    slkDdeOrOle,          // uno de los dos, aún no se sabe 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 se guía por centinelas, no por cadenas. Un valor de campo $0401 marca el registro self. Un conteo de hojas de uno emparejado con $3A01 marca un contenedor de complemento. Solo un valor en el rango de 1 a $00FF significa que sigue una ruta virtual codificada, y solo entonces HotXLS decodifica una cadena. Cualquier cosa fuera de esas tres formas permanece slkUnknown, y un registro cuya tabla de nombres de hoja no consume el cuerpo del registro exactamente se degrada de nuevo a slkUnknown incluso cuando el encabezado parecía plausible

La escalera guiada por centinelas que HotXLS usa para clasificar un registro BIFF SupBook en siete tipos, decodificando una cadena solo para valores del rango de ruta codificada y cayendo en un tipo desconocido en lugar de en una rama por defecto
Cada tipo se alcanza por un centinela y no por una prueba de cadena, y un registro que no encaja con ninguna forma permanece desconocido en lugar de caer en una rama por defecto que significa este libro

¿Por qué el marcador de la misma hoja se decodifica como 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 de la misma hoja es una cadena de un carácter cuyo único carácter es U+0000, y TXLSBlob.GetBiffString lo 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 lee por eso 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íjese en la rama comprimido contra ancho. El byte de opciones está a un offset fijo del encabezado de la cadena y el primer punto de código ocupa uno o dos bytes según el bit 0, así que leerlo como un byte sin condiciones funciona en la mayoría de los archivos y falla en los escritos por builds localizadas — la peor distribución posible para un bug. El marcador de posición sin usar se detecta igual, por su payload 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 BIFF SupBook en lugar de la cadena decodificada, porque el lector de cadenas de propósito general convierte el marcador U+0000 de la misma hoja en un valor vacío
El marcador de la misma hoja 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 offset 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. Le dice que el enlace es uno de los dos; los flags fOle y fOleLink que deciden cuál viven en el registro ExternName ($0023) que llega después en el flujo. HotXLS registra slkDdeOrOle al parsear y lo acota en ParseExternalName, y si ningún ExternName llega nunca, el tipo permanece provisional para siempre — lo cual es correcto, porque el archivo genuinamente no lo dice. Todo consumidor aguas abajo trata ese valor provisional como un valor real y no como uno faltante, así que ningún llamador tiene que inventar un desempate. Adivinar "probablemente DDE" aquí compraría una enumeración más ordenada y una clase de respuestas incorrectas 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 base cero en disco y base uno por dentro

HotXLS realiza la conversión del desplazamiento de uno exactamente una vez, en el punto donde un token entra al árbol de sintaxis interno, y en ningún otro lugar. PtgNameX.ixti ([MS-XLS] §2.5.198.85) es un índice base cero dentro del arreglo rgXTI del registro ExternSheet ($0017, §2.4.106), mientras que la convención interna de ExternID de la biblioteca es base uno con cero reservado para "sin hoja externa". La ruta de lectura BIFF8 hace FExternID := wValue + 1 al decodificar un token tNameX y la ruta de escritura emite StoreExternID - 1, dejando intactas la vista cruda de tokens 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 se vuelve 1, falla, y el nombre se degrada en silencio. Una regresión que solo ejercita texto de fórmulas recompilado jamás lo ve, porque la recompilación nunca toca el índice en disco — la misma trampa que hace que los nombres definidos que cruzan hojas y libros valgan la pena probarse contra flujos de bytes reales. La resolución está acotada en ambos extremos: TlxExternSheetSheet.TryResolveXti devuelve False para un índice negativo o una entrada faltante, TXLSSupBook.TryGetKind devuelve False para un índice de SupBook fuera del arreglo, y ClassifyXti mapea luego slkSelf y slkSameSheet a frcInternal, slkExternalWorkbook a frcExternalWorkbook, y slkAddIn, slkDde, slkOle y slkDdeOrOle a frcExternalOther. Todo lo demás, incluida cada ruta fuera de rango, cae en frcUnknownOrMalformed

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

Clasificar una fórmula antes de congelarla

TXLSCompiledFormula.ClassifyReferences escanea directamente el flujo de tokens BIFF preservado en lugar de decompilar 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: coincide con literales de cadena, coincide con referencias estructuradas, y pasa por alto por completo los nombres definidos externos, ya que estos no llevan corchetes en forma decompilada. El escaneo de tokens mira solo PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d y PtgAreaErr3d, con repliegue a un recorrido del árbol de sintaxis 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 también se valida el índice del nombre: base uno, dentro del 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 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 un libro externo en lugar de solo sospecharlo. Las llamadas a complemento sobreviven, los enlaces DDE y OLE sobreviven, y cualquier cosa que el parser no pudo entender del todo sobrevive, porque el desenlace seguro de la incertidumbre es no cambiar nada. La misma disciplina gobierna re-vincular fórmulas copiadas entre libros, donde una referencia mal clasificada se re-vincula al libro equivocado en lugar de fallar estridentemente

Los registros que no se parsean se reescriben intactos

HotXLS conserva el payload original del SupBook y lo reemite byte por byte cuando el registro nunca fue editado. Un fallo de parseo establece slkUnknown y limpia el estado derivado, pero el cuerpo capturado permanece 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 parsear en una autorreferencia para que el escritor tenga algo bien formado que emitir — convierte un registro que usted no entendió en un registro definitivamente incorrecto. Ese principio es el mismo contrato aplicado a los proyectos VBA y sus referencias externas a lo largo de un ciclo de cargar y guardar, y es la diferencia entre una biblioteca que hace round-trip de archivos reales y una que hace round-trip de los archivos que su suite de pruebas casualmente contiene. Un libro que ha pasado por quince años de versiones de Excel, un generador de reportes y dos herramientas de migración contendrá registros que nadie vivo hoy diseñó. Escríbalos de vuelta tal como los encontró

La clasificación con tipos de los registros SupBook y XTI llegó en HotXLS 2.361.2 a 2.361.4, junto con la resolución acotada de XTI y la ruta más segura de ConvertFormulasToValues descrita aquí. Si mantiene código Delphi o C++Builder que lee archivos xls antiguos con llamadas a complemento, enlaces DDE u OLE, o nombres definidos externos, el componente de hojas 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