HotXLS 把普通数值单元格值和公式缓存以 17 位有效数字写进 XLSX,读取时用一个有界的精确十进制转 binary64 检查校正 Delphi RTL 的解析结果,于是你存进单元格的 Double 在保存并重开之后逐位回来。15 位——Excel 屏幕上显示的位数——不足以唯一指认一个 IEEE 754 binary64 值,而这个缺口恰恰出现在财务与 ID 列的地盘:超过 1015 的整数
逼出这次修改的 bug 看着人畜无害。工作表公式 INT(-1E15-0.1) 求值为 -1000000000000001,一个完全可表示的 double。HotXLS 算对了、缓存了、保存了文件,而磁盘上的缓存读作 -1E15。Excel 要等一次重算才会显示原值,任何信任缓存值的消费方——大多数都是——无声拿到一个差 1 的数。什么都不抛、什么都不警告,文件校验还挺正常
为什么 15 位有效数字对 double 不够?
15 位十进制数字保证能活着进出 binary64 一趟,但反方向需要 17 位。IEEE 754 binary64 有 53 位尾数,折算成十进制是 15 到 17 位不等,取决于值落在哪。任何不超过 15 位有效数字的十进制字符串转成 double 再打回原样不变。可一个 double 要到 17 位才保证它的十进制写法唯一指认它,而且在 253 以上相邻 double 相距 2,在 1015 与 253 之间相距 0.125 到 1
Delphi 的 FloatToStr 用 15 位有效数字格式化,与 Excel 显示用的精度相同。这个巧合正是缺陷藏得好的原因:在 Excel 里打开文件,单元格看起来与 Delphi 程序里一模一样——因为 Excel 也只显示 15 位。差异只在比较存储的 double、两个相邻值相减、或把缓存喂给做算术的东西时才现形。-1000000000000001 有 16 位有效数字,舍到 15 位就成了 -1000000000000000,末尾那个单位永远没了
ECMA-376 Part 1 §18.3.1.96 定义承载单元格值的 <v> 元素为文本,模式里没有任何条款规定写入器必须为数字单元格发多少位。精度完全是写入器的责任——这正是为什么两个生产者都能写出合法的 XLSX、却对单元格里是哪个数各执一词
从 Delphi 写出 17 位有效数字
HotXLS 现在对数值单元格只用一条不变式:配 ffGeneral、精度 17 和 invariant 格式设置的 FloatToStrF。单元格写入器的日期分支早就用的正是这个投影,普通数字只是加入了它。从外面看 API 没有任何变化:赋一个 Double 或公式、保存,落进 <v> 的 token 就足以复现那个 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');
// 值与公式缓存以 17 位有效数字写出
if Book.SaveAs('ledger.xlsx') <> lxOk then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
17 位是上限,不是补零规则。ffGeneral 掉尾零,所以 0.5 还是写成 0.5,小整数保持短小;只有真正需要额外位数的值才拿到。代价是之前被截断的那些单元格多几个字节——这是你能发布的最便宜的正确性修复
为什么写了 17 位,读取时还会丢值?
写出足够位数只有在读取器把它转成最近 double 时才有用,而 RTL 的 Val 不总这么干。验证期间,测试机上的 RTL 把 9007199254740990.7 和纯整数 9007199254740991 都读进了相邻的错误 binary64 值。两个字符串都应落在 253-1 上;其一是 HotXLS 能产出的 17 位写法,另一个是 Excel 写同一值的方式。只修写入侧的话,HotXLS 会发布一个自己都读不回来的文件
所以 HotXLS 保留 Val 当门卫,再校正它的答案。既有数字文法接受一个非日期 token 之后,精确解析器把十进制构建成大整数分数,与 RTL 候选值两侧的二进制中点比较。十进制低于下中点,候选下调一个 ulp;高于上中点,上调;恰好打平则偶数尾数胜出——IEEE 754 的 round-to-nearest-even 规则。候选最多允许八次邻居调整,绰绰有余,因为 Val 只错一个单位,不是错一英里
校正是刻意有界的。超过 48 字符、超过 17 位有效数字或指数绝对值超过 324 的 token 跳过校正、保留旧解析器结果,任何内部失败也回退到同样结果。大整数用 48 个 32 位肢,足够容纳这些边界内最宽的分子分母。这里有一个可移植性细节要紧:肢类型显式声明为 UInt32。LongWord 看似当然之选,但在 Delphi 的 Linux64 目标上是 8 字节,悄悄翻倍的肢类型会破坏算术里每一次进位和移位
逐位验证往返
这个测试里用 = 比较 double 没问题,因为目标就是精确相等,但按位型比较能让失败报告更有用:一个 ulp 的漂移显示为原始 64 位值相差 1,而不是两个打印出来一模一样的数
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;
第 3 行是关键。它不重算就读 INT(-1E15-0.1) 的缓存结果,所以证明的是缓存本身无损,而不是公式引擎能重算出该值。管线里依赖缓存结果的话,不重算读取缓存公式值的单独指南讲了缓存状态,以及为什么缺失的缓存绝不会被编造
还有哪些 XLSX 部件带数值缓存?
修好单元格写入器只关掉了几条路径之一,其余每个数值写出点都得各自核查。首发覆盖工作表值与公式缓存,包括 Pivot 输出的值缓存。Pivot 源缓存与 Chart 缓存当时仍走 15 位漏斗,在后续两个版本里关闭,而不是想当然
- Pivot 源缓存:
sharedItems值与内联、流式两版cacheRecords写入器现在都用同一条 17 位不变式,pivot 读取路径应用同样的精确中点校正。refreshedDate这类描述性属性保留原拼写 - Chart 缓存:构建缓存点值的单一写出点切换到 17 位;六个读取点——趋势线值与类别、误差线校验、两条渲染路径——全部经过同一个精确解析辅助函数
- 其余数值,如列宽和轴边界,仍走防御性格式化器,把非有限与次正规值冲成 0,因为序列化部件绝不能装着解析不了的浮点数
最重要的互操作检查是与 Excel 本身。Excel 把 253-1 写成整数 9007199254740991;HotXLS 可能写出等价的 17 位写法 9007199254740990.7。两侧两个 token 都解析到同一个 double,所以 Excel 制作、HotXLS 重开再保存、再回 Excel 打开的文件,Pivot 缓存里的值保持不变。要测的性质是相互认可,不是拼写一致
值得知道的限制与边界情况
这里的无损指保住 binary64 值,不是每个值都可表示。非有限结果仍存成 #NUM! 错误,次正规数仍存成零,与之前完全一样。显示也是另一回事:Excel 屏幕上仍只显示 15 位有效数字,所以用户在 Excel 里敲 9007199254740993 得到的存储值照样不同,任何写入器都救不了。工作簿开了 Set precision as displayed 的话,值被刻意舍入到其数字格式——与本文讲的正好相反;那个模式的规则见 HotXLS 如何应用 Excel 的 precision as displayed 舍入
精确读取器也不替换 Val;它只校正 Val 已经接受的值。RTL 拒绝的 token 照样被拒,超出 48 字符、17 位、指数 324 边界的 token 保留旧解析器的产出。HotXLS 或 Excel 写的文件永远碰不到这些,因为两者都不会为单元格值产出这种 token;但一个带 40 位有效数字的手工 XML 部件,读到的是 RTL 的精度,不是精确校正
FAQ
HotXLS 会改变 Excel 为单元格显示的数吗?
不会。HotXLS 只改写进存储 <v> token 的位数。Excel 仍显示 15 位有效数字,单元格看起来一样;变化在于底层的 double 现在与你 Delphi 代码赋的值一致
为什么不写 16 位而要 17 位?
16 位对某些 binary64 值不够。一些 double 要 17 位有效数字十进制写法才唯一,16 位写入器在罕见情况下仍会把两个不同的 double 映到同一文本——这正是本次修改要消灭的那类无声漂移
修复之前写的文件需要重新生成吗?
只有含需要超过 15 位的值的文件才需要。那些 token 在磁盘上早已丢了末尾几位,读得再准也变不回没写进去的东西。从原始数据重存,或重算公式再保存一次,即可得到精确文件
17 位数字输出、精确的 binary64 读取校正和配套的 Pivot 与 Chart 缓存处理,都是面向 Delphi、C++Builder 与 Lazarus 的 HotXLS Delphi Spreadsheet Component 的一部分,不装 Excel 或 OLE 自动化即可读写 XLSX