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;
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
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
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