Artículo técnico

FontIndex BIFF8 se salta el 4: runs enriquecidos en HotXLS

HotXLS numera cada referencia a fuente BIFF8 como lo define [MS-XLS] §2.5.129 FontIndex: los valores 0 a 3 son base cero, los valores por encima de 4 son base uno, y el 4 nunca aparece, así que el quinto record FONT es ifnt 5 y el mayor ifnt válido equivale al número de records FONT. Desde HotXLS 2.384.4, el escritor XF, el lector XF, los runs de cadenas de texto enriquecido y la migración de runs entre workbooks siguen esa regla, y 2.384.5 y 2.384.6 la extienden a los runs de comentarios y cuadros de texto, también a través de copias e inserciones de filas

La regla parece una errata hasta que te topas con ella. Alguien abre un workbook con ocho records FONT, encuentra un XF que apunta a la fuente 8 y concluye que el escritor produjo un índice fuera de rango. Ese razonamiento exacto se envió en HotXLS 2.384.1 como un «fix», y convirtió una implementación correcta en una donde cada fuente personalizada de un archivo abierto en Excel aterrizaba una ranura antes. Lo interesante no es el off-by-one en sí, sino cuántos sitios de una biblioteca BIFF8 llevan la misma convención, y cómo un enlace a fuente puede sobrevivir a un guardado y romperse en el segundo. Si ya te has peleado con las rarezas de longitud y codificación que cubre decodificar cch y fHigh de XLUnicodeString BIFF8, este bug es de la misma familia: el archivo está bien, la aritmética no

¿Qué dice realmente la regla FontIndex de [MS-XLS]?

[MS-XLS] §2.5.129 dice que un FontIndex por debajo de 4 es una posición de record base cero, un FontIndex por encima de 4 es una posición base uno, y el valor 4 NO DEBE usarse. El mismo tipo FontIndex lo usan los records XF, los runs de formato del SST y los runs de formato TXO, así que una regla mal leída corrompe los tres. La evidencia es fácil de reproducir con archivos autorados por Excel: SOLVSAMP.XLS, que venía con Office, tiene 19 records FONT y un máximo ifnt de XF de 19, un workbook de 43 records se queda en 43, y un archivo guardado por Excel 16 con 30 records FONT apunta sus celdas en Courier New al ifnt 22, el record 22. Ninguno contiene jamás un 4. Si necesitas analizar el mapeo tú mismo en una herramienta de diagnóstico, la conversión son dos funciones cortas

// [MS-XLS] 2.5.129 FontIndex: 0..3 base cero, > 4 base uno, 4 inválido
function FontIndexToRecordNo(Ifnt: Word): Integer;  // record FONT base 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // el 4 no debe aparecer
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Mapeo FontIndex de HotXLS según MS-XLS 2.5.129 donde ifnt 0 a 3 son posiciones base cero de records FONT, ifnt 5 en adelante son base uno y el valor 4 nunca ocurre, con la conversión FontIndexToRecordNo y la evidencia de workbooks autorados por Excel como SOLVSAMP.XLS
El quinto record FONT es ifnt 5, no 4 — un workbook de 19 records se queda en ifnt 19, y ningún archivo autorado por Excel guarda jamás el valor prohibido en medio

Dentro de HotXLS la misma regla vive en dos puntos espejados. TXLSFontList.GetSaveIndex toma la posición base 1 de una fuente en la lista referida y decrementa solo las posiciones 1 a 4, así que la posición 5 se escribe como ifnt 5. TXLSReader.ParseXF hace lo inverso al cargar: cualquier ifnt de 5 en adelante se decrementa a una ranura de lista de fuentes base cero, y todo lo de abajo se queda donde está. El remapeo de runs enriquecidos del SST y CountRichRunFontRefs aplican la misma conversión ifnt >= 5, y ese es el punto: una convención, todos los consumidores

// TXLSFontList.GetSaveIndex (lado escritor)
Result := inherited GetSaveIndex(Index);   // posición referida base 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 pasan a 0..3, 5+ sin cambios

// TXLSReader.ParseXF (lado lector)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 es la ranura 4 de la lista de fuentes

¿Por qué un «fix» base cero desplazó cada fuente personalizada en uno?

La reescritura base cero de HotXLS 2.384.1 desplazó cada fuente personalizada porque leyó un índice base uno como base cero y luego cambió cuatro puntos de llamada para encajar con esa mala lectura: GetSaveIndex, ParseXF, el remapeo de runs del SST y la migración de runs entre workbooks en Sheets.AddCopy. Los round trips de HotXLS seguían viéndose bien, porque escritor y lector estaban de acuerdo entre sí. Excel no estaba de acuerdo. Un archivo escrito por 2.384.1 ponía la primera fuente personalizada en ifnt 4, que Excel trata como la fuente predeterminada, y cada fuente personalizada posterior un record antes; abrir un archivo de Excel iba en el sentido contrario, enlazando cada fuente un record más tarde

Regresión de HotXLS 2.384.1 donde GetSaveIndex y ParseXF leían valores FontIndex base uno como base cero, escribiendo la primera fuente personalizada como el ifnt 4 prohibido que Excel resuelve a la fuente predeterminada y aterrizando cada fuente posterior un record antes mientras los round trips seguían pasando
Escritor y lector coincidían en la misma mala lectura, así que un test de guardar y reabrir seguía en verde mientras cada fuente abierta en Excel aterrizaba una ranura desviada — cuando una convención vive en siete sitios y cambias cuatro, sospecha primero de tu cambio

La pista que habría detenido el cambio estaba en la misma base de código. CountRichRunFontRefs, el remapeo FONTX y FBI de gráficos y la lista de fuentes del motor de estilos nunca se tocaron y seguían usando skip-4, así que la biblioteca se contradijo a sí misma en cuanto aterrizó 2.384.1, y solo la coincidencia de que las fuentes de texto enriquecido solían estar también referenciadas por algún XF mantuvo la contradicción oculta. Cuando una convención aparece en siete sitios y estás cambiando cuatro, sospecha de tu cambio antes que de los otros tres. La versión 2.384.4 restauró la numeración de la especificación en los cuatro sitios, y el viejo test de regresión, que verificaba ifnt < FontCount y por tanto codificaba la mala lectura, fue sustituido por tests que mapean cada ifnt escrito de vuelta a un nombre de record FONT mediante la fórmula de la especificación. Queda una limitación honesta: los archivos guardados por 2.384.1 a 2.384.3 con cinco o más fuentes llevan índices desplazados que un lector no puede distinguir de datos válidos, así que la única cura es regenerarlos

¿Por qué los runs de fuentes de comentarios se rompen solo en el segundo guardado?

Los runs de comentarios y cuadros de texto se rompían en el segundo guardado porque HotXLS conservaba incondicionalmente los primeros N-1 records FONT y solo soltaba el último cuando ningún XF lo referenciaba, mientras los runs de formato TXO ([MS-XLS] §2.4.329) se reescribían byte a byte sin renumerar. Los archivos .xls autorados por Excel siempre terminan con una fuente final sin referencias (una DengXian de 9pt en un sistema con configuración regional china), así que en el primer guardado la fuente usada solo por un run de comentario nunca era la última, y nada se movía a la vista. Ese primer guardado soltó la fuente final, eso sí, y ascendió la fuente solo de comentarios a la última posición. El segundo guardado la descartó como sin referencias, el ifnt del run apuntaba más allá del final, y Excel caía de vuelta a la fuente predeterminada; si el workbook había ganado una fuente nueva entretanto, el run se enlazaba silenciosamente a esa, lo que en pruebas convirtió un run de cuadro de texto con estilo en Arial. Los archivos con muchos comentarios como los que describe montar un flujo de revisión de comentarios e hipervínculos son justo donde esto muerde, porque se abren, anotan y guardan una y otra vez

Tabla de fuentes de HotXLS a lo largo de dos guardados donde el record FONT final sin referencias que Excel siempre escribe se suelta primero, la fuente solo de comentarios pasa a ser la última y luego se descarta porque los runs de formato TXO se reescribían sin contar referencias, hasta que CountRichRunFontRefs arregló el filtro de supervivencia en 2.384.5
El primer guardado parecía limpio porque la fuente final absorbó la pérdida, y la fuente del comentario solo desapareció en el segundo — mapea cada ifnt a un nombre de record FONT a lo largo de los guardados en vez de fiarte de un test en memoria

HotXLS 2.384.5 trata los runs TXO como los runs del SST. CountRichRunFontRefs recorre ahora cada TMSOShapeTextBox de cada hoja, convierte el ifnt skip-4 de cada run a una ranura y lo cuenta como referencia, así que una fuente usada solo por runs sobrevive al filtro de guardado. La tabla de ranura a índice de guardado resultante entra en el FontRunRemap de cada dibujo, y TMSOShapeTextBox.Store reescribe los índices de run sobre una copia privada de los bytes crudos de los runs, dejando en paz el TxOLastRun final porque no lleva fuente. Para el código de aplicación el contrato es simple: TXLSComment.TextRuns.FontIndex y TXLSTextBox.TextRuns.FontIndex usan la numeración del archivo, con el 4 saltado, exactamente como se leyeron; los índices de run son base 1 y CharIndex es el offset de carácter donde empieza el run. Tras un guardado el número almacenado puede diferir del que pusiste, pero sigue apuntando a la misma fuente

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // numeración del archivo, 4 saltado
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

Copias, inserciones de filas y migración de runs entre workbooks

Desde HotXLS 2.384.6, cada camino de copia del motor clásico conserva los runs de formato de comentarios, porque Range.Copy, CopyRange, Sheets.AddCopy y los desplazamientos de celdas detrás de Range.Insert y Range.Delete pasan todos por TXLSRange.CopyCell, y CopyCell solo copiaba el texto del comentario y el autor. Un desplazamiento es una copia más un borrado, así que insertar una sola fila por encima de una nota de dos runs la dejaba con cero runs y una fuente huérfana. El fix copia cada run y mueve su fuente vía TXLSWorkbook.MigrateRunFontIndex, que convierte el índice skip-4 a una ranura, migra la fuente por valor a la tabla de fuentes destino y convierte de vuelta a numeración de archivo; la migración de texto enriquecido del SST en Sheets.AddCopy ahora llama a la misma función en vez de llevar su propia copia de la aritmética. Vinieron dos casos límite: un pegado in situ donde origen y destino son el mismo comentario no debe limpiar sus runs antes de leerlos, y Sheets.AddCopy hace ahora una segunda pasada para comentarios adjuntos a celdas sin record de celda almacenado, que antes saltaba por completo. El lado de tabla de fuentes de la copia entre workbooks sigue la misma lógica por valor que el lado de fórmulas que cubre copia entre workbooks y reenlace de fórmulas. En el motor XLSX los caminos de copia ya clonaban runs por valor; el hueco estaba en la parte de comentarios, donde el lector ignoraba rFont, strike, u y vertAlign y el escritor nunca emitía u ni vertAlign, así que los runs ahora sobreviven a guardar y reabrir de forma simétrica

¿Cómo deberías probar los índices de fuente en archivos BIFF8?

Prueba los índices de fuente guardando y reabriendo, idealmente a lo largo de más de una generación, y mapeando cada ifnt de vuelta a un record FONT en vez de verificar un rango numérico. Cada bug de esta historia pasó un test en memoria: la regresión de 2.384.1 vivía en un par escritor-lector que coincidían, la deriva del TXO necesitaba dos guardados con un cambio de tabla de fuentes en medio, y los runs de comentarios perdidos en XLSX solo se veían tras reabrir. Un arnés útil abre una muestra autorada por Excel, la guarda dos veces vía HotXLS, añade o quita una fuente entre guardados, y luego comprueba las posiciones de los runs y, a nivel de bytes, los nombres de fuente detrás de cada ifnt. No compares valores de FontIndex antes y después de un guardado, porque renumerar es legítimo

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // autorado por Excel, C2 tiene dos runs
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 pasa a C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // reabrir, nunca te fíes de la memoria
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

Si lees y escribes XLS clásico desde Delphi o C++Builder y prefieres no andar siguiendo cuál de los muchos consumidores de fuentes de una biblioteca sigue de acuerdo con [MS-XLS] §2.5.129, la numeración skip-4, la renumeración de runs al guardar y la migración de runs por valor descritas aquí vienen integradas en el componente de hojas de cálculo HotXLS para Delphi, que lee y escribe XLS y XLSX sin Excel ni automatización OLE