O HotXLS grava valores de células numéricas comuns e caches de fórmula no XLSX com 17 dígitos significativos, e na leitura corrige o parse da RTL Delphi com uma checagem exata limitada de decimal para binary64, então um Double que você guarda em uma célula volta bit a bit depois de um salvar e reabrir. Quinze dígitos, o número que o Excel mostra na tela, não bastam para identificar um valor IEEE 754 binary64, e o buraco aparece exatamente onde colunas de finanças e de IDs vivem: inteiros acima de 1015
O bug que forçou a mudança parecia inofensivo. Uma fórmula de planilha INT(-1E15-0.1) avalia para -1000000000000001, um double perfeitamente representável. O HotXLS o computava corretamente, cacheava, salvava o arquivo, e o cache no disco lia -1E15. O Excel mostraria o valor original só depois de um recálculo, e qualquer consumidor que confia em valores em cache, que é a maioria, recebia silenciosamente um número fora por um. Nada lançava exceção, nada avisava, e o arquivo validava bem
Por que 15 dígitos significativos não bastam para um double?
Quinze dígitos decimais têm garantia de sobreviver a uma ida ao binary64 e volta, mas a direção reversa precisa de 17. O IEEE 754 binary64 tem um significando de 53 bits, que dá entre 15 e 17 dígitos decimais dependendo de onde o valor senta. Qualquer string decimal com no máximo 15 dígitos significativos converte para um double e imprime de volta inalterada. Um double, porém, precisa de até 17 dígitos antes que sua grafia decimal o identifique de forma única, e na faixa acima de 253 doubles adjacentes estão 2 apartados, enquanto entre 1015 e 253 estão de 0.125 a 1 apartados
O FloatToStr do Delphi formata com 15 dígitos significativos, que é a mesma precisão que o Excel usa para exibir. Essa coincidência é precisamente por que o defeito se esconde tão bem: abra o arquivo no Excel e a célula parece exatamente como estava no seu programa Delphi, porque o Excel também só mostra 15 dígitos. A diferença só fica visível quando você compara o double armazenado, subtrai dois valores próximos, ou alimenta o cache em algo que faz aritmética. O valor -1000000000000001 tem 16 dígitos significativos, então arredondar para 15 produz -1000000000000000 e a unidade final se vai para sempre
O ECMA-376 Part 1 §18.3.1.96 define o elemento <v> que carrega o valor de uma célula como texto, e nada no esquema dita quantos dígitos um writer precisa emitir para uma célula numérica. A precisão é inteiramente responsabilidade do writer, e é por isso que dois produtores podem ambos gravar XLSX válido e ainda discordar sobre que número está na célula
Gravando 17 dígitos significativos do Delphi
O HotXLS agora usa um único invariante para células numéricas: FloatToStrF com ffGeneral, precisão 17, e configurações de formato invariantes. O ramo de datas do writer de células já usava exatamente essa projeção, então números comuns simplesmente se juntaram a ele. De fora, nada muda na API; você atribui um Double ou uma fórmula, salva, e o token que cai no <v> é preciso o bastante para reproduzir o 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');
// Valores e o cache de fórmula são gravados com 17 dígitos significativos
if Book.SaveAs('ledger.xlsx') <> lxOk then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
Dezessete dígitos é um teto, não uma regra de preenchimento. O ffGeneral descarta zeros à direita, então 0.5 ainda é gravado como 0.5 e inteiros pequenos continuam curtos; apenas valores que genuinamente precisam dos dígitos extras os recebem. O custo é de poucos bytes nas células que antes estavam sendo truncadas, que é a correção de correção mais barata que você vai publicar
Por que gravar 17 dígitos ainda perde valores na leitura?
Gravar dígitos suficientes só ajuda se o leitor os converter para o double mais próximo, e o Val da RTL nem sempre faz isso. Durante a verificação, a RTL na máquina de teste lia tanto 9007199254740990.7 quanto o inteiro simples 9007199254740991 para o valor binary64 adjacente e errado. Ambas as strings deveriam cair em 253-1; uma delas é uma grafia de 17 dígitos que o HotXLS pode produzir, a outra é como o Excel grava o mesmo valor. Um writer consertado isoladamente teria publicado um arquivo que o próprio HotXLS não conseguiria ler de volta corretamente
Então o HotXLS mantém o Val como porteiro e corrige a resposta dele. Depois que a gramática numérica existente aceita um token não-data, um parser exato constrói o decimal como uma fração de inteiro grande e o compara com os pontos médios binários dos dois lados do candidato da RTL. Se o decimal cai abaixo do ponto médio inferior, o candidato desce um ulp; acima do ponto médio superior, sobe; em um empate exato o significando par vence, que é a regra IEEE 754 de arredondar para o par mais próximo. O candidato pode receber no máximo oito ajustes de vizinho, o que dá e sobra porque o Val erra por uma unidade, não por um quilômetro
A correção é deliberadamente limitada. Tokens com mais de 48 caracteres, com mais de 17 dígitos significativos, ou com magnitude de expoente acima de 324 pulam a correção e mantêm o resultado do parser legado, e qualquer falha interna recua para a mesma coisa. Os inteiros grandes usam 48 limbs de 32 bits cada, o suficiente para o numerador e o denominador mais largos dentro desses limites. Um detalhe de portabilidade importa aqui: os limbs são declarados explicitamente como UInt32. O LongWord parece a escolha óbvia, mas tem 8 bytes no alvo Linux64 do Delphi, e um tipo de limb que silenciosamente dobra de largura quebra todo carry e shift da aritmética
Verificando o round-trip bit a bit
Comparar doubles com = serve para este teste, porque o objetivo é igualdade exata, mas uma comparação de padrão de bits torna o relatório de falha mais útil: uma deriva de um ulp aparece como diferença de 1 no valor bruto de 64 bits em vez de dois números que imprimem 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;
A linha 3 é a interessante. Ela lê o resultado em cache de INT(-1E15-0.1) sem recalcular, então prova que o cache em si é sem perdas em vez de o engine de fórmulas conseguir recomputar o valor. Se você depende de resultados em cache em um pipeline, o guia separado sobre ler valores de fórmula em cache sem recálculo cobre os estados de cache e por que um cache ausente nunca é inventado
Quais outras partes XLSX carregam caches numéricos?
Consertar o writer de células fechou um caminho entre vários, e cada ponto de escrita numérico restante precisou ser checado por conta própria. A primeira versão cobriu valores de worksheet e caches de fórmula, incluindo os caches de valor de saída de Pivot. Caches de origem de Pivot e caches de Chart ainda passavam pelo funil de 15 dígitos e foram fechados em duas versões de acompanhamento em vez de por suposição
- Cache de origem de Pivot: os valores de
sharedItemse ambos os writers decacheRecords, inline e em stream, agora usam o mesmo invariante de 17 dígitos, e o caminho de leitura do pivô aplica a mesma correção exata de ponto médio. Atributos descritivos comorefreshedDatemantêm sua grafia original - Caches de Chart: o único ponto de escrita que constrói valores de pontos em cache trocou para 17 dígitos, e os seis pontos de leitura, cobrindo valores e categorias de trendline, validação de barras de erro e dois caminhos de renderização, todos passam por um único helper de parse exato
- Todo o resto numérico, como larguras de coluna e limites de eixo, ainda passa pelo formatador defensivo que despeja valores não finitos e subnormais em 0, porque uma parte serializada jamais deve conter um float não interpretável
A checagem de interoperabilidade que mais importava era com o próprio Excel. O Excel grava 253-1 como o inteiro 9007199254740991; o HotXLS pode gravar a grafia equivalente de 17 dígitos 9007199254740990.7. Ambos os tokens interpretam para o mesmo double dos dois lados, então um arquivo autorado no Excel, reaberto e ressalvo pelo HotXLS, e aberto de novo no Excel, mantém os mesmos valores em seu cache de Pivot. Reconhecimento mútuo, não grafia idêntica, é a propriedade a testar
Limites e casos de borda que valem conhecer
Sem perdas aqui significa que o valor binary64 é preservado, não que todo valor é representável. Resultados não finitos ainda são salvos como erros #NUM! e subnormais ainda são salvos como zero, exatamente como antes. A exibição também é uma preocupação separada: o Excel vai continuar mostrando 15 dígitos significativos na tela, então um usuário digitando 9007199254740993 no Excel ainda recebe um valor armazenado diferente, e nenhum writer pode consertar isso. Se sua pasta usa Set precision as displayed, os valores são deliberadamente arredondados ao seu formato de número, que é o oposto do que este artigo trata; as regras desse modo estão cobertas em como o HotXLS aplica o arredondamento precision as displayed do Excel
O leitor exato também não substitui o Val; ele só corrige valores que o Val já aceitou. Um token que a RTL rejeita continua rejeitado, e um token fora dos limites de 48 caracteres, 17 dígitos e expoente 324 mantém o que o parser legado produziu. Para arquivos gravados pelo HotXLS ou pelo Excel isso nunca importa, porque nenhum dos dois produz tais tokens para valores de célula, mas uma parte XML artesanal com 40 dígitos significativos é lida com a precisão da RTL, não com a correção exata
FAQ
O HotXLS muda o número que o Excel exibe para uma célula?
Não. O HotXLS só muda quantos dígitos entram no token <v> armazenado. O Excel continua exibindo 15 dígitos significativos, então as células parecem iguais; a diferença é que o double subjacente agora casa com o que seu código Delphi atribuiu
Por que não gravar 16 dígitos em vez de 17?
Dezesseis dígitos não bastam para todo valor binary64. Alguns doubles precisam de 17 dígitos significativos antes que sua grafia decimal seja única, então um writer de 16 dígitos ainda mapearia dois doubles distintos para o mesmo texto em casos raros, que é exatamente a classe de deriva silenciosa que a mudança remove
Arquivos gravados antes da correção precisam ser regenerados?
Apenas se contêm valores que precisavam de mais que 15 dígitos. Esses tokens já perderam seus últimos dígitos no disco e lê-los com mais precisão não pode restaurar o que nunca foi gravado. Ressalvar a partir dos dados originais, ou recalcular as fórmulas e salvar de novo, produz um arquivo exato
Saída numérica de 17 dígitos, a correção exata de leitura binary64 e o tratamento correspondente de caches de Pivot e Chart fazem parte do HotXLS Delphi Spreadsheet Component para Delphi, C++Builder e Lazarus, que lê e grava XLSX sem Excel nem automação OLE instalados