Artículo técnico

FontIndex de BIFF8 se salta el 4: rich text runs en HotXLS

HotXLS numera cada referencia a fuentes BIFF8 como lo define [MS-XLS] §2.5.129 FontIndex: los valores 0 a 3 son base cero, los valores sobre 4 son base uno, y 4 nunca aparece, así que el quinto registro FONT es ifnt 5 y el mayor ifnt válido equivale al número de registros FONT. Desde HotXLS 2.384.4 el escritor XF, el lector XF, los runs de rich text en strings y la migración de runs entre libros siguen esa regla, y 2.384.5 y 2.384.6 la extienden a los runs de comentarios y cuadros de texto, incluidos copias e inserciones de filas

La regla parece un typo hasta que uno la topa. Alguien abre un libro con ocho registros FONT, encuentra un XF apuntando a la fuente 8, y concluye que el escritor produjo un índice fuera de rango. Ese razonamiento exacto se publicó en HotXLS 2.384.1 como un "fix", y convirtió una implementación correcta en una donde cada fuente personalizada de un archivo abierto por Excel caía un slot antes. Lo interesante no es el off-by-one en sí, sino cuántos lugares de una librería BIFF8 cargan la misma convención, y cómo un enlace a fuente puede sobrevivir un guardado y romperse en el segundo. Si ya peleó contra las rarezas de longitud y codificación que cubre decodificar cch y fHigh de XLUnicodeString en BIFF8, este es el mismo tipo de bug: 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 menor que 4 es una posición de registro base cero, un FontIndex mayor que 4 es una posición base uno, y el valor 4 NO DEBE usarse. El mismo tipo FontIndex lo usan los registros XF, los runs de formato 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 hechos por Excel: SOLVSAMP.XLS que viene con Office tiene 19 registros FONT y un máximo ifnt de XF de 19, un libro de 43 registros llega hasta 43, y un archivo guardado por Excel 16 con 30 registros FONT apunta sus celdas Courier New al ifnt 22, el registro 22. Ninguno contiene jamás un 4. Si necesita analizar el mapeo usted 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;  // registro FONT base 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 no debe ocurrir
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 de registro FONT base cero, ifnt 5 en adelante son base uno y el valor 4 nunca ocurre, con la conversión FontIndexToRecordNo y evidencia de libros hechos por Excel como SOLVSAMP.XLS
El quinto registro FONT es ifnt 5, no 4 — un libro de 19 registros llega como máximo a ifnt 19, y ningún archivo hecho por Excel guarda jamás el valor prohibido en el 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 el inverso al cargar: cualquier ifnt de 5 o más se decrementa a un slot base cero de la lista de fuentes, y cualquier cosa por debajo se queda como está. El remapeo de rich-runs del SST y CountRichRunFontRefs aplican la misma conversión ifnt >= 5, que 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 se vuelven 0..3, 5+ sin cambios

// TXLSReader.ParseXF (lado lector)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 es el slot 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 leía un índice base uno como si fuera base cero y después cambió cuatro sitios de llamada para ajustarse a esa lectura errada: GetSaveIndex, ParseXF, el remapeo de runs del SST y la migración de runs entre libros en Sheets.AddCopy. Los round trips internos de HotXLS seguían viéndose bien, porque escritor y lector coincidían entre sí. Excel no coincidía. Un archivo escrito por 2.384.1 ponía la primera fuente personalizada en ifnt 4, que Excel trata como la fuente por defecto, y cada fuente posterior un registro antes; abrir un archivo de Excel iba en la dirección contraria, atando cada fuente un registro 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 prohibido ifnt 4 que Excel resuelve a la fuente por defecto y aterrizando cada fuente posterior un registro antes mientras los round trips seguían pasando
Escritor y lector coincidían en la misma lectura errada, así que un test de guardar y reabrir seguía verde mientras cada fuente abierta por Excel caía un slot corrida — cuando una convención vive en siete lugares y cambia cuatro, sospeche primero de su cambio

La pista que debió detener el cambio estaba sentada en el mismo 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 librería se contradecía a sí misma en el momento en que 2.384.1 aterrizó, y solo la coincidencia de que las fuentes de rich text usualmente también estaban referidas por algún XF mantuvo la contradicción escondida. Cuando una convención aparece en siete lugares y está cambiando cuatro, sospeche de su cambio antes que de los otros tres. La versión 2.384.4 restauró la numeración de la especificación en los cuatro lugares, y el viejo test de regresión, que afirmaba ifnt < FontCount y por tanto codificaba la lectura errada, fue reemplazado por tests que mapean cada ifnt escrito de vuelta a un nombre de registro FONT vía 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 mantenía los primeros N-1 registros FONT incondicionalmente y soltaba solo el último cuando ningún XF lo refería, mientras los runs de formato TXO ([MS-XLS] §2.4.329) se reescribían byte a byte sin renumerar. Los archivos .xls hechos por Excel siempre terminan con una fuente final sin referencias (una DengXian de 9pt en un sistema con locale chino), así que en el primer guardado la fuente usada solo por un run de comentario nunca era la última, y nada se movió visiblemente. Ese primer guardado soltó la fuente final, eso sí, y promovió la fuente solo-de-comentario a la última posición. El segundo guardado entonces la descartó como sin referencias, el ifnt del run apuntó más allá del final, y Excel cayó a la fuente por defecto; si el libro había ganado una fuente nueva en el medio, el run se ató calladamente a esa otra, que en las pruebas convirtió un run de cuadro de texto con estilo en Arial. Los archivos cargados de comentarios como los que describe construir un flujo de revisión con comentarios e hipervínculos son justo donde esto muerde, porque se abren, anotan y guardan repetidamente

Tabla de fuentes de HotXLS a lo largo de dos guardados donde el registro FONT final sin referencias que Excel siempre escribe cae 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 se veía limpio porque la fuente final absorbió la pérdida, y la fuente del comentario solo desapareció en el segundo — mapee cada ifnt a un nombre de registro FONT entre guardados en lugar de confiar en un test en memoria

HotXLS 2.384.5 trata los runs TXO como los runs SST. CountRichRunFontRefs ahora recorre cada TMSOShapeTextBox de cada hoja, convierte el ifnt skip-4 de cada run a un slot, y lo cuenta como referencia, así que una fuente usada solo por runs sobrevive el filtro de guardado. La tabla resultante de slot a índice de guardado entra al FontRunRemap de cada dibujo, y TMSOShapeTextBox.Store reescribe los índices de run sobre una copia privada de los bytes crudos de los runs, dejando el TxOLastRun final tranquilo porque no lleva fuente. Para el código de la aplicación el contrato es simple: TXLSComment.TextRuns.FontIndex y TXLSTextBox.TextRuns.FontIndex usan la numeración del archivo, saltándose el 4, exactamente como se leyeron; los índices de run son base 1 y CharIndex es el offset de carácter donde arranca el run. Después de un guardado el número almacenado puede diferir del que usted fijó, 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 de archivo, se salta el 4
      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 libros

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 antes solo copiaba el texto del comentario y el autor. Un shift es una copia más un limpiado, así que insertar una fila sobre 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 un slot, migra la fuente por valor a la tabla de fuentes destino, y convierte de vuelta a numeración de archivo; la migración de rich text del SST en Sheets.AddCopy ahora llama la misma función en lugar de cargar su propia copia de la aritmética. Vinieron dos casos de borde: un pegado in situ donde origen y destino son el mismo comentario no debe limpiar sus runs antes de leerlos, y Sheets.AddCopy ahora hace una segunda pasada para comentarios atados a celdas sin registro de celda almacenado, que antes saltaba por completo. El lado de la tabla de fuentes de la copia entre libros sigue la misma lógica por valor que el lado de fórmulas que cubre copia entre libros y re-enlace 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 ahora los runs sobreviven guardado y reapertura de forma simétrica

¿Cómo se deben probar los índices de fuentes en archivos BIFF8?

Pruebe los índices de fuentes guardando y reabriendo, idealmente a través de más de una generación, y mapeando cada ifnt de vuelta a un registro FONT en lugar de afirmar 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 aparejado, la deriva del TXO necesitó dos guardados con un cambio de tabla de fuentes en el medio, y los runs de comentarios perdidos en XLSX solo aparecieron tras una reapertura. Un harness útil abre una muestra hecha por Excel, la guarda dos veces vía HotXLS, agrega o quita una fuente entre guardados, y después chequea posiciones de run más, a nivel de bytes, los nombres de fuente detrás de cada ifnt. No compare 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);          // hecho 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 confíe en 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 usted lee y escribe XLS clásico desde Delphi o C++Builder y prefiere no rastrear cuál de los muchos consumidores de fuentes de una librería sigue aún 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 de fábrica en el componente de hojas de cálculo HotXLS para Delphi, que lee y escribe XLS y XLSX sin Excel ni automatización OLE