Artículo técnico

Doubles XLSX sin pérdida en Delphi: 17 dígitos

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

HotXLS escribe valores de celda XLSX y cachés de fórmulas con 17 dígitos significativos porque el double representable -1000000000000001 cacheado de INT(-1E15-0.1) perdía su unidad final al guardarse con 15 dígitos como -1E15
Un significando binary64 de 53 bits abarca de 15 a 17 dígitos decimales, así que cualquier valor como -1000000000000001 necesita los 17 dígitos significativos completos antes de que su grafía decimal sea única

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

HotXLS corrige el resultado de Val de la RTL de Delphi para números XLSX de 17 dígitos comparando el decimal como fracción de enteros grandes contra los puntos medios binarios y avanzando un ulp hacia el round-to-nearest-even de IEEE 754
Tanto 9007199254740990.7 como el entero simple que Excel escribe deben caer en 2^53 - 1, así que el lector exacto ajusta a lo sumo ocho vecinos y conserva el parseo legado más allá de sus límites de 48 caracteres y exponente 324

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 sharedItems y los escritores de cacheRecords tanto 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 como refreshedDate conservan 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

Excel escribe 2^53 - 1 como el entero 9007199254740991 mientras HotXLS puede escribir la grafía de 17 dígitos 9007199254740990.7, y ambos tokens XLSX parsean al mismo double en Delphi y Excel
Un libro que viaja de Excel por un reguardado de HotXLS y de vuelta conserva sus valores de caché de Pivot bit a bit, porque ambos lectores aceptan cualquiera de las dos grafías del valor

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