HotXLS escribe valores de celda numéricos ordinarios y cachés de fórmulas en XLSX con 17 dígitos significativos, y al leer corrige el parseo de la RTL de Delphi con una verificación exacta y acotada de decimal a binary64, así que un Double que usted guarda en una celda vuelve bit a bit tras guardar y reabrir. Quince dígitos, el número que Excel muestra en pantalla, no bastan para identificar un valor IEEE 754 binary64, y el hueco aparece justo donde viven las columnas de finanzas y de IDs: enteros por encima 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 archivo, y la caché en disco decía -1E15. Excel mostraría el valor original solo tras un recálculo, y cualquier consumidor que confíe en los valores cacheados, que son la mayoría, recibía silenciosamente un número desfasado en uno. Nada lanzaba excepción, nada avisaba, y el archivo 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 sale a entre 15 y 17 dígitos decimales según dónde esté el valor. Cualquier cadena decimal con a lo sumo 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: abra el archivo en Excel y la celda se ve exactamente como se veía en su programa Delphi, porque Excel también solo muestra 15 dígitos. La diferencia solo se vuelve visible cuando compara el double guardado, resta dos valores cercanos, o alimenta la caché a 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 escritor para una celda numérica. La precisión es enteramente responsabilidad del escritor, razón por la que dos productores pueden escribir ambos XLSX válidos y aun así discrepar sobre qué número hay en la celda
Escribir 17 dígitos significativos desde Delphi
HotXLS ahora usa un solo invariante para celdas numéricas: FloatToStrF con ffGeneral, precisión 17, y configuración de formato invariante. La rama de fechas del escritor de celdas ya usaba exactamente esa proyección, así que los números ordinarios simplemente se sumaron a ella. Desde afuera nada cambia en la API; usted asigna un Double o una fórmula, guarda, y el token que aterriza en <v> es lo 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órmulas 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 descarta los ceros finales, así que 0.5 se sigue escribiendo como 0.5 y los enteros pequeños se quedan cortos; solo los valores que genuinamente necesitan los dígitos extra los reciben. El costo son unos pocos bytes en las celdas que antes se truncaban, que es la corrección de exactitud más barata que jamás enviará
¿Por qué escribir 17 dígitos todavía 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 de la máquina de pruebas leía tanto 9007199254740990.7 como el entero simple 9007199254740991 al valor binary64 adyacente y equivocado. Ambas cadenas deberían caer 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 escritor corregido en aislado habría enviado un archivo que el propio HotXLS no podría leer correctamente
Así que HotXLS mantiene a Val como guardián y corrige su respuesta. Después de que la gramática numérica existente acepta un token que no es fecha, un parser exacto construye el decimal como una fracción de enteros grandes y la compara contra los puntos medios binarios a cada lado 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 a lo sumo 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 magnitud de exponente superior a 324 la saltan y conservan el resultado del parser legado, y cualquier falla interna recae en 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 destino Linux64 de Delphi, y un tipo de limb que silenciosamente duplique su ancho rompe cada acarreo y desplazamiento de la aritmética
Verificar el round-trip bit a bit
Comparar doubles con = está bien para esta prueba, porque la meta es igualdad exacta, pero una comparación de patrones de bits hace el reporte de falla más útil: una deriva de un ulp aparece como una diferencia de 1 en el valor crudo de 64 bits en lugar de dos números que se imprimen idénticos
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é misma es sin pérdida en lugar de que el motor de fórmulas pueda recalcular el valor. Si depende de resultados cacheados en un pipeline, la guía aparte sobre leer valores cacheados de fórmulas sin recálculo cubre los estados de la caché y por qué una caché faltante nunca se inventa
¿Qué otras partes de XLSX llevan cachés numéricas?
Corregir el escritor de celdas cerró una ruta de varias, y cada punto de escritura numérico restante tuvo que revisarse por su cuenta. La primera versión cubrió valores de hoja y cachés de fórmulas, incluidas las cachés de valores de salida de 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 los escritores decacheRecordstanto inline como por flujo ahora usan el mismo invariante de 17 dígitos, y la ruta de lectura del pivote aplica la misma corrección exacta de punto medio. Los atributos descriptivos comorefreshedDateconservan su grafía original - Cachés de Chart: el único punto de escritura que construye valores de puntos cacheados cambió a 17 dígitos, y los seis puntos de lectura, que cubren valores y categorías de trendlines, validación de barras de error y dos rutas de renderizado, todos pasan por un 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 manda los valores no finitos y subnormales a 0, porque una parte serializada nunca debe contener un float imposible de parsear
La verificació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 archivo autoría de Excel, reabierto y vuelto a guardar por HotXLS, y luego abierto de nuevo en Excel, conserva los mismos valores en su caché de Pivot. Reconocimiento mutuo, no grafía idéntica, es la propiedad que hay que probar
Límites y casos de borde que vale conocer
Sin pérdida aquí significa que el valor binary64 se conserva, no que todo 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 sigue obteniendo un valor guardado distinto, y ningún escritor puede arreglar eso. Si su 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 HotXLS aplica el redondeo de precisión como se muestra de Excel
El lector exacto tampoco reemplaza 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 el parser legado produjo. Para archivos 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
¿HotXLS cambia el número que Excel muestra para una celda?
No. HotXLS solo cambia cuántos dígitos entran al 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 su 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 escritor 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 archivos escritos antes de la corrección deben regenerarse?
Solo si contienen valores que necesitaban más de 15 dígitos. Esos tokens ya perdieron sus últimos dígitos en disco y leerlos con más precisión no puede restaurar lo que nunca se escribió. Volver a guardar desde los datos originales, o recalcular fórmulas y guardar de nuevo, produce un archivo exacto
La salida numérica de 17 dígitos, la corrección exacta de lectura binary64 y el manejo correspondiente de cachés de Pivot y Chart 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