HotXLS escribe los valores numéricos ordinarios de celdas y las cachés de fórmula en XLSX con 17 dígitos significativos, y al leer corrige el parseo de la RTL de Delphi con una comprobación exacta acotada de decimal a binary64, así que un Double que guardéis en una celda vuelve bit a bit después de guardar y reabrir. Quince dígitos, el número que Excel muestra en pantalla, no bastan para identificar un valor IEEE 754 binary64, y la brecha aparece exactamente donde viven las columnas de finanzas e IDs: enteros más allá de 1015
El bug que forzó el cambio parecía inofensivo. Una fórmula de hoja INT(-1E15-0.1) evalúa a -1000000000000001, un double perfectamente representable. HotXLS lo calculaba correctamente, lo cacheaba, guardaba el fichero, y la caché en el disco leía -1E15. Excel mostraría el valor original solo tras un recálculo, y cualquier consumidor que se fíe de los valores cacheados, que son la mayoría, recibía silenciosamente un número desviado en uno. Nada lanzaba, nada avisaba, y el fichero validaba bien
Por qué 15 dígitos significativos no bastan para un double
Quince dígitos decimales tienen garantizado sobrevivir un viaje a binary64 y de vuelta, pero la dirección inversa necesita 17. IEEE 754 binary64 tiene un significando de 53 bits, que se traduce en entre 15 y 17 dígitos decimales según dónde esté el valor. Cualquier cadena decimal con como máximo 15 dígitos significativos se convierte a un double y se imprime de vuelta sin cambios. Un double, sin embargo, necesita hasta 17 dígitos antes de que su grafía decimal lo identifique de forma única, y en el rango por encima de 253 los doubles adyacentes distan 2, mientras que entre 1015 y 253 distan de 0,125 a 1
El FloatToStr de Delphi formatea con 15 dígitos significativos, que es la misma precisión que Excel usa para mostrar. Esa coincidencia es precisamente por lo que el defecto se esconde tan bien: abrid el fichero en Excel y la celda se ve exactamente como en vuestro programa Delphi, porque Excel también solo muestra 15 dígitos. La diferencia solo se vuelve visible cuando comparáis el double guardado, restáis dos valores cercanos, o metéis la caché en algo que hace aritmética. El valor -1000000000000001 tiene 16 dígitos significativos, así que redondear a 15 produce -1000000000000000 y la unidad final se pierde para siempre
ECMA-376 Part 1 §18.3.1.96 define el elemento <v> que lleva el valor de una celda como texto, y nada en el esquema dicta cuántos dígitos debe emitir un writer para una celda numérica. La precisión es enteramente responsabilidad del writer, que es por lo que dos productores pueden escribir ambos XLSX válido y aun así discrepar sobre qué número hay en la celda
Escribir 17 dígitos significativos desde Delphi
HotXLS usa ahora un invariante para celdas numéricas: FloatToStrF con ffGeneral, precisión 17 y configuración de formato invariante. La rama de fechas del writer de celdas ya usaba exactamente esa proyección, así que los números ordinarios simplemente se unieron. Desde fuera nada cambia en la API; asignáis un Double o una fórmula, guardáis, y el token que aterriza en <v> es bastante preciso para reproducir el double
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Ledger');
Sheet.Cells[1, 1].Value := 9007199254740991.0; // 2^53 - 1
Sheet.Cells[2, 1].Value := -1000000000000001.0;
Sheet.Cells[3, 1].Formula := 'INT(-1E15-0.1)';
if Book.Recalculate <> lxOk then
raise Exception.Create('Recalculation failed');
// Los valores y la caché de fórmula se escriben con 17 dígitos significativos
if Book.SaveAs('ledger.xlsx') <> lxOk then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
Diecisiete dígitos es un techo, no una regla de relleno. ffGeneral suelta los ceros finales, así que 0,5 sigue escribiéndose como 0.5 y los enteros pequeños se quedan cortos; solo los valores que de verdad necesitan los dígitos extra los reciben. El coste son unos bytes en las celdas que antes se truncaban, que es el arreglo de corrección más barato que enviaréis jamás
Por qué escribir 17 dígitos aún pierde valores al leer
Escribir suficientes dígitos solo ayuda si el lector los convierte al double más cercano, y el Val de la RTL no siempre hace eso. Durante la verificación, la RTL en la máquina de prueba leía tanto 9007199254740990.7 como el entero pelado 9007199254740991 al valor binary64 adyacente, equivocado. Ambas cadenas deberían aterrizar en 253-1; una es una grafía de 17 dígitos que HotXLS puede producir, la otra es como Excel escribe el mismo valor. Un writer arreglado en aislado habría enviado un fichero que el propio HotXLS no podía leer correctamente
Así que HotXLS mantiene Val como portero y corrige su respuesta. Después de que la gramática numérica existente acepte un token que no es fecha, un parser exacto construye el decimal como una fracción de enteros grandes y lo compara contra los puntos medios binarios a ambos lados del candidato de la RTL. Si el decimal cae por debajo del punto medio inferior, el candidato baja un ulp; por encima del punto medio superior, sube; en empate exacto gana el significando par, que es la regla round-to-nearest-even de IEEE 754. Al candidato se le permiten como máximo ocho ajustes de vecino, que es de sobra porque Val se equivoca por una unidad, no por un kilómetro
La corrección es deliberadamente acotada. Los tokens de más de 48 caracteres, con más de 17 dígitos significativos, o con una magnitud de exponente por encima de 324 se la saltan y conservan el resultado del parser heredado, y cualquier fallo interno vuelve a lo mismo. Los enteros grandes usan 48 limbs de 32 bits cada uno, suficiente para el numerador y denominador más anchos dentro de esos límites. Un detalle de portabilidad importa aquí: los limbs están declarados explícitamente como UInt32. LongWord parece la elección obvia, pero son 8 bytes en el objetivo Linux64 de Delphi, y un tipo de limb que se dobla silenciosamente de anchura rompe cada acarreo y desplazamiento de la aritmética
Verificar el round-trip bit a bit
Comparar doubles con = va bien para esta prueba, porque el objetivo es igualdad exacta, pero una comparación de patrones de bits hace el informe de fallo más útil: una deriva de un ulp se muestra como una diferencia de 1 en el valor crudo de 64 bits en lugar de dos números que se imprimen igual
function SameBits(A, B: Double): Boolean;
var
BitsA, BitsB: UInt64;
begin
Move(A, BitsA, SizeOf(BitsA));
Move(B, BitsB, SizeOf(BitsB));
Result := BitsA = BitsB;
end;
procedure CheckReopen(const FileName: string);
const
Expected: array[1..3] of Double =
(9007199254740991.0, -1000000000000001.0, -1000000000000001.0);
var
Book: TXLSXWorkbook;
Row: Integer;
Stored: Double;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open(FileName) <> lxOk then
raise Exception.Create('Open failed');
for Row := 1 to 3 do
begin
Stored := Book.Sheets[1].Cells[Row, 1].Value;
if not SameBits(Stored, Expected[Row]) then
Writeln('row ', Row, ' drifted: ', FloatToStrF(Stored, ffGeneral, 17, 0));
end;
finally
Book.Free;
end;
end;
La fila 3 es la interesante. Lee el resultado cacheado de INT(-1E15-0.1) sin recalcular, así que prueba que la caché en sí es sin pérdidas y no que el motor de fórmulas pueda recalcular el valor. Si dependéis de resultados cacheados en un pipeline, la guía aparte sobre leer valores de fórmula cacheados sin recálculo cubre los estados de caché y por qué una caché ausente nunca se inventa
Qué otras partes XLSX llevan cachés numéricas
Arreglar el writer de celdas cerró una ruta de varias, y cada punto de escritura numérico restante tuvo que comprobarse por su cuenta. La primera versión cubrió valores de hoja y cachés de fórmula, incluidas las cachés de valores de la salida Pivot. Las cachés de origen de Pivot y las cachés de Chart todavía pasaban por el embudo de 15 dígitos y se cerraron en dos versiones de seguimiento en lugar de por suposición
- Caché de origen de Pivot: los valores de
sharedItemsy ambos writers decacheRecords, inline y en stream, usan ahora el mismo invariante de 17 dígitos, y la ruta de lectura del pivot aplica la misma corrección exacta de punto medio. Atributos descriptivos comorefreshedDateconservan su grafía original - Cachés de Chart: el único punto de escritura que construye valores de punto cacheados pasó a 17 dígitos, y los seis puntos de lectura, que cubren valores de líneas de tendencia y categorías, validación de barras de error y dos rutas de renderizado, pasan todos por un único helper de parseo exacto
- Todo lo demás numérico, como anchos de columna y límites de ejes, sigue pasando por el formateador defensivo que aplasta los valores no finitos y subnormales a 0, porque una parte serializada jamás debe contener un float imposible de parsear
La comprobación de interoperabilidad que más importaba era con el propio Excel. Excel escribe 253-1 como el entero 9007199254740991; HotXLS puede escribir la grafía equivalente de 17 dígitos 9007199254740990.7. Ambos tokens parsean al mismo double en ambos lados, así que un fichero escrito en Excel, reabierto y reguardado por HotXLS, y abierto de nuevo en Excel, conserva los mismos valores en su caché Pivot. Reconocimiento mutuo, no grafía idéntica, es la propiedad que hay que probar
Límites y casos de borde que conviene conocer
Sin pérdidas aquí significa que el valor binary64 se preserva, no que cada valor sea representable. Los resultados no finitos siguen guardándose como errores #NUM! y los subnormales siguen guardándose como cero, exactamente como antes. La visualización también es un asunto aparte: Excel seguirá mostrando 15 dígitos significativos en pantalla, así que un usuario que teclee 9007199254740993 en Excel recibe igualmente un valor guardado distinto, y ningún writer puede arreglarlo. Si vuestro libro usa Set precision as displayed, los valores se redondean deliberadamente a su formato numérico, que es lo contrario de lo que trata este artículo; las reglas de ese modo están cubiertas en cómo aplica HotXLS el redondeo precision as displayed de Excel
El lector exacto tampoco sustituye a Val; solo corrige valores que Val ya aceptó. Un token que la RTL rechaza sigue rechazándose, y un token fuera de los límites de 48 caracteres, 17 dígitos y exponente 324 conserva lo que produjera el parser heredado. Para ficheros escritos por HotXLS o Excel eso nunca importa, porque ninguno produce tales tokens para valores de celda, pero una parte XML hecha a mano con 40 dígitos significativos se lee con la precisión de la RTL, no con la corrección exacta
FAQ
¿Cambia HotXLS el número que Excel muestra para una celda?
No. HotXLS solo cambia cuántos dígitos entran en el token <v> guardado. Excel sigue mostrando 15 dígitos significativos, así que las celdas se ven igual; la diferencia es que el double subyacente ahora coincide con lo que vuestro código Delphi asignó
¿Por qué no escribir simplemente 16 dígitos en lugar de 17?
Dieciséis dígitos no bastan para todo valor binary64. Algunos doubles necesitan 17 dígitos significativos antes de que su grafía decimal sea única, así que un writer de 16 dígitos seguiría mapeando dos doubles distintos al mismo texto en casos raros, que es exactamente la clase de deriva silenciosa que el cambio elimina
¿Los ficheros escritos antes del arreglo hay que regenerarlos?
Solo si contienen valores que necesitaban más de 15 dígitos. Esos tokens ya perdieron sus últimos dígitos en el disco y leerlos con más precisión no puede restaurar lo que nunca se escribió. Reguardar desde los datos originales, o recalcular las fórmulas y guardar otra vez, produce un fichero exacto
La salida numérica de 17 dígitos, la corrección exacta de lectura binary64 y el manejo de cachés Pivot y Chart equivalente son parte del HotXLS Delphi Spreadsheet Component para Delphi, C++Builder y Lazarus, que lee y escribe XLSX sin Excel ni automatización OLE instalados