Excel 的 precision as displayed 把每个存储的数字舍入到它的数字格式所显示的位数:取与值正负号匹配的格式节,每个 % 加两位,每个千位缩放逗号减三位,半程远离零进位。当 TXLSXWorkbook.FullPrecision 或 TXLSWorkbook.UseFullPrecision 为 False 时,HotXLS 在它的两个 Delphi 引擎里应用同一条规则。这话听着像一行就能说完,直到客户报告你导出的发票合计与 Excel 差一分钱,或者一列 [ss].00 格式的时长塌成了零。两件事都发生过,也都源于搞错了其中一条规则。从 v2.384.57 起,两个引擎共用一份实现,其期望值是在 Excel 16 里开着 Workbook.PrecisionAsDisplayed 实测出来的
precision as displayed 到底改了工作簿里的什么?
precision as displayed 是一个工作簿级开关,告诉计算引擎按数字的模样存储,而不是按算出来的样子。Excel UI 里它待在「文件、选项、高级、此工作簿的计算选项」下,叫「将精度设为所显示的精度」。落盘时它就是一个位。BIFF8 文件在 CalcPrecision 记录里携带它($000E,[MS-XLS] §2.4.35),其 fFullPrec 字段为 1 表示正常的全精度,选项打开时为 0。XLSX 包里它是 workbook.xml 中 calcPr 元素的 fullPrecision 属性,定义在 ECMA-376 Part 1,默认 true,fullPrecision="0" 打开舍入
这个开关不是显示偏好。勾上它时,Excel 会警告数据将永久损失精度,而且是认真的:值被改写成显示精度,被截掉的数位就没了。之后再取消勾选也找不回旧数位。显示为 12.3% 的 0.1234 就永远成了 0.123
HotXLS 在两种格式里都读写这个开关,并在两个引擎里暴露它:
- XLSX 引擎上的
TXLSXWorkbook.FullPrecision: Boolean,从calcPr/@fullPrecision加载并保存回去 - 经典引擎上的
TXLSWorkbook.UseFullPrecision: Boolean(IXLSWorkbook上也有),从 CalcPrecision 记录加载并保存回去 - 两者默认 True,这是安全、非破坏的模式,也是 Excel 的默认
HotXLS 在哪一步应用舍入是有讲究的。HotXLS 在算出值的那一刻舍入:每条公式结果在存成单元格缓存值之前,先舍入到自己的显示精度,Recalculate 时如此,按需求值时亦然。你经 Value 赋进去的常量按原样存储。如果输出必须复现 Excel 勾选之后存储的东西,写之前自己把那些常量舍入掉,比如用后文给出的那个辅助函数
Excel 怎么决定保留几位小数?
Excel 从显示该值的那个具体格式节推导保留的小数位数,而不是从整条格式串。下面的规则在 Excel 16 里实测,是 lxNumFormat 里 XlsApplyDisplayedPrecision 为两个 HotXLS 引擎实现的
- 按正负号挑节。两节格式对负值用第二节。三节以上的格式对负值用第二节、对精确的零用第三节。其余一切用第一节
- 数小数占位符。该节中小数点之后的每个
0、#或?加一位保留小数 - 每个百分号加两位。
0.0%把 0.1234 显示成 12.3%,所以存储值是你看到值的百分之一,保留三位小数,不是一位 - 每个缩放逗号减三位。最后一个整数占位符之后的逗号(
0,、0.0,、0,.0)把显示值除以 1000。0.0,把 12345.678 显示成 12.3,所以 Excel 保留一位减三位——是个负数:值被舍到百位、存成 12300。整数占位符之间的逗号,如#,##0,只是数字分组,什么也不改 - 非数字节一概不动。General、日期和时间节(含流逝时间
[h]、[mm]和[ss])、科学计数、分数和文本节,以及没有任何数字占位符的节,都保持全精度
对 Excel 16 实测,这是两个 HotXLS 引擎现在对每个格式下公式结果存储的值:
| 数字格式 | 计算值 | 存储值 | 适用的规则 |
|---|---|---|---|
0.0% | 0.1234 | 0.123 | 一位小数加百分号的两位 |
0 | 2.5 | 3 | 远离零取半,不是取偶 |
0 | -2.5 | -3 | 负数一侧同样远离零取半 |
0.00;(0.0) | -1.2345 | -1.2 | 负数节显示一位小数 |
0.00;(0.0) | 1.2345 | 1.23 | 正数节显示两位小数 |
#,##0.0 | 1234.5678 | 1234.6 | 分组逗号,无缩放 |
0.0, | 12345.678 | 12300 | 一位减三位:舍到百位 |
0.0%;(0.00%) | -0.0125 | -0.0125 | 负数节保留二加二位小数 |
0.00 | 1.005 | 1.01 | 对二进制表示误差的容差 |
0;-0;0.0 | 0.5 | 1 | 非零,由正数节说了算 |
最后一行是个漂亮的陷阱。0.5 舍入成整数,零那节从头到尾没出场,因为 Excel 在舍入之前就按计算值挑好了节。HotXLS 这边有一处如实交代的限制:节只按正负号挑,带自定义方括号条件(如 [>=1000])的节仍按正负号切分。这类格式若对你的场景要紧,请对着 Excel 核一遍
1.005 为什么舍成 1.01 而不是 1.00?
Excel 把 0.00 单元格里的 1.005 舍成 1.01,尽管最接近 1.005 的 double 略低于半程点,HotXLS 用一个小 ulp 容差对齐了这一点。字面量 1.005 在二进制浮点里表示不出来。最接近的 IEEE 754 double 是 1.00499999999999989341858963598497211933135986328125,乘以 100 得 100.49999999999999。教科书式的 Floor(x * 100 + 0.5) / 100 于是返回 1.00,与用户敲的数、与 Excel 显示的、与 Excel 存的都对不上
Delphi 又添了一层。System.Round 把平局舍到偶,所以 Round(2.5) 是 2、Round(3.5) 是 4。那是银行家舍入,统计上是合理的默认,在这里却是错的规则:Excel 把 0 单元格里的 2.5 存成 3,-2.5 存成 -3。HotXLS 的实现在绝对值上做文章,加 0.5 再加一个 2-51 乘缩放值的相对容差(该量级下的几个 ulp,绝不低于 1.0 的两个 ulp),截断、缩放回去、恢复符号。下面这个函数是这一原理的自足示意,不是库代码本身,它以同样方式处理缩放逗号带来的负位数:
// 原理示意:按 ADigits 位小数做远离零的半进舍入,
// 带几个 ulp 的容差,让 1.005 够到 1.01。
// ADigits < 0 时舍到十位、百位……("0.0," 给出 -2)
function RoundAsDisplayed(AValue: Double; ADigits: Integer): Double;
const
Tolerance = 4.440892098500626E-16; // 2^-51,1.0 的两个 ulp
var
I: Integer;
Scale, Scaled, Eps: Double;
begin
Result := AValue;
if (ADigits < -15) or (ADigits > 14) then
Exit; // 超出 double 精度:值原样不动
Scale := 1;
for I := 1 to Abs(ADigits) do
Scale := Scale * 10;
if ADigits >= 0 then
begin
if Abs(AValue) > 1E300 / Scale then
Exit; // 缩放会溢出
Scaled := Abs(AValue) * Scale;
end
else
Scaled := Abs(AValue) / Scale;
Eps := Scaled * Tolerance;
if Eps < Tolerance then
Eps := Tolerance;
Scaled := Int(Scaled + 0.5 + Eps); // 远离零取半,不是 Round()
if ADigits >= 0 then
Result := Scaled / Scale
else
Result := Scaled * Scale;
if AValue < 0 then
Result := -Result;
end;
// RoundAsDisplayed(1.005, 2) = 1.01 (Floor 做法:1.00)
// RoundAsDisplayed(2.5, 0) = 3 (Round:2)
// RoundAsDisplayed(-2.5, 0) = -3
// RoundAsDisplayed(0.1234, 3) = 0.123 ("0.0%":1 + 2 位)
// RoundAsDisplayed(12345.678, -2) = 12300 ("0.0,":1 - 3 位)
这个容差是刻意的取舍。真的比半步低两个 ulp 的值也会被舍上去,但在那个距离上,差异与表示误差无法区分,而把它当半步处理,正是让手敲小数按用户预期表现的原因
v2.384.57 之前哪里出了错?
v2.384.57 之前,XLSX 引擎和经典引擎各有一份 precision-as-displayed 代码,而且各错各的。如果你开着这个选项产工作簿,老构建生成的文件就要找这些症状
XLSX 引擎:只看第一节、不管百分号、银行家舍入
旧 XLSX 路径问的是整条格式串的小数位数,于是只看第一节、无视 %,再用 Round 舍入。0.0% 里的 0.1234 被存成 0.1,即 10%,屏幕上却是 12.3%。0 里的 2.5 被存成 2 而不是 3。0.00;(0.0) 这类格式里的负值被按正数节的两位小数舍入。从 v2.384.57 起,XLSX 引擎调与经典引擎同一份共享例程——后者也在那个版本里补了缩放逗号支持
经典引擎:TRUE 变成了 -1
经典引擎用 VarIsNumeric 守着它的舍入,而 VarIsNumeric 对 varBoolean Variant 也返回 True。用 Double(V) 转换那个 Variant 得到 -1,因为 COM 风格的布尔 True 存的是 -1。0.00 格式单元格里的 =A1>0 这类公式,重算出来就成了数字 -1。从 v2.384.57 起,布尔结果在任何数值测试之前被排除,逻辑结果在两个引擎里都保持为逻辑结果
流逝时间格式被读成颜色(v2.384.9)
第三个 bug 藏在数字格式模型里,与舍入无关。解析器把每个不是条件的方括号 token 都归类为颜色,[h]、[mm] 和 [ss] 因此从未把自己的节标成日期/时间。显示没受影响,因为格式化走的是另一条路径,但 precision as displayed 靠那个标志跳过时间值。五秒的时长是一天的 5/86400,约 0.0000579,而 [ss].00 这样的格式看起来就是个普通两位小数,于是 FullPrecision 关着时,时长被舍成 0.00 天。从 v2.384.9 起,单个 h、m 或 s 字母组成的方括号串被解析成流逝时间 token,节按日期/时间对待。同一版本还修了 h:mm 里的分钟识别——token 之间的冒号过去会让解析器看不见小时
在 Delphi 里为 HotXLS 打开 precision as displayed
要拿到与 Excel 等价的存储值,在应当生效的那次重算之前设好开关,然后读缓存结果或保存。XLSX 引擎上 FullPrecision 是个朴素开关:改它不会让之前 Recalculate 已存储的结果失效,所以在 Create 或 Open 之后、第一次 Recalculate 之前设好。示例用公式,因为那正是 HotXLS 应用舍入的地方:
var
Wb: TXLSXWorkbook;
Sh: TXLSXWorksheet;
begin
Wb := TXLSXWorkbook.Create;
try
Sh := Wb.Sheets.Add('Totals');
Sh.Cells[1, 1].Value := 0.1234;
Sh.Cells[2, 1].Value := 2.5;
Sh.Cells[3, 1].Value := 12345.678;
Sh.Cells[1, 2].Formula := '=A1';
Sh.Cells[1, 2].NumberFormat := '0.0%'; // 显示 12.3%
Sh.Cells[2, 2].Formula := '=A2';
Sh.Cells[2, 2].NumberFormat := '0'; // 显示 3
Sh.Cells[3, 2].Formula := '=A3';
Sh.Cells[3, 2].NumberFormat := '0.0,'; // 显示 12.3(千位)
// XLSX 引擎上必须在第一次 Recalculate 之前设置
Wb.FullPrecision := False;
Wb.Recalculate;
// 缓存结果现在与 Excel 16 一致:0.123、3 和 12300。
// A 列的常量保持全精度。
Assert(Abs(Double(Sh.Cells[1, 2].Value) - 0.123) < 1E-12);
Assert(Double(Sh.Cells[2, 2].Value) = 3);
Assert(Double(Sh.Cells[3, 2].Value) = 12300);
Wb.SaveAs('totals.xlsx'); // 写出 <calcPr fullPrecision="0"/>
finally
Wb.Free;
end;
end;
经典引擎行为相同,外加一个便利:赋值 TXLSWorkbook.UseFullPrecision 会把依赖图里的每条公式标脏,下一次 Recalculate 就在新规则下重算整个工作簿。选项开着时改 NumberFormat 同样把受影响的公式单元格标脏,因为格式现在决定存储值。注意经典的 Recalculate 返回它没能求值的公式单元格数,零才是成功:
var
Wb: TXLSWorkbook;
Sh: TXLSWorksheet;
begin
Wb := TXLSWorkbook.Create;
try
Sh := Wb.Sheets.Add;
Sh.Range['A1', 'A1'].Value := -1.2345;
Sh.Range['B1', 'B1'].Formula := '=A1';
Sh.Range['B1', 'B1'].NumberFormat := '0.00;(0.0)';
Sh.Range['C1', 'C1'].Formula := '=A1<0';
Sh.Range['C1', 'C1'].NumberFormat := '0.00';
Wb.UseFullPrecision := False; // 把每条公式标脏
if Wb.Recalculate <> 0 then
raise Exception.Create('Some formulas could not be evaluated');
// B1 = -1.2:负数节 "(0.0)" 显示一位小数
// C1 保持布尔 True(v2.384.57 之前的构建存 -1)
Wb.SaveAs('report.xls'); // fFullPrec = 0 的 CalcPrecision 记录
finally
Wb.Free;
end;
end;
两个引擎也尊重随文件进来的开关。打开一个带此选项保存的工作簿,FullPrecision 或 UseFullPrecision 已经是 False,加载后一次 Recalculate 就会按 Excel 的方式舍入。如果只是要读 Excel 已经存好的数字,可以完全跳过重算,见不重算读取缓存的公式值;序列号和日期格式如何与驱动日期/时间检查的格式模型互动,见Delphi 里的 Excel 日期序列号、1904 系统与 numFmt
什么时候该开 precision as displayed,什么时候不该?
只有当工作簿的存储数字必须等于显示数字、且你接受永远失去多余的数位时,才打开 precision as displayed。经典的正当场景是财务表:几列舍入后的金额必须在屏幕上加总等于舍入后的合计,不能有隐藏的零头把最后一位弄差一。对齐客户已有的、开着该选项的工作簿是另一个充分理由,HotXLS 在往返中保留这个开关,不会悄悄把他们切回全精度
其余大多数情况请避开:
- 工程与科学数据。因为有人给报告选了个两位小数格式就把测量值舍掉,毁掉的信息任何后续的格式变更都救不回来
- 配粗格式的百分比。
0%格式只保留存储比值的两位小数,0.1234 变成 0.12,下游每条读这个单元格的公式都在跟 0.12 打交道 - 缩放显示。用来显示千位的
0,或0.0,格式会把存储值舍到千位或百位,选格式的人多半没打算这样 - 共享模板。开关是工作簿级的。后来谁加一张表就继承这套行为,而且通常不知道它开着
如果你真正想要的只是几个特定单元格里的舍入结果,那就往那些公式里写 ROUND。ROUND 显式、局部于单元格、读公式的人看得见,由 HotXLS 公式引擎像其他函数一样求值,没有工作簿级的副作用
Precision as displayed 速查
- 文件开关:BIFF8 里是
fFullPrec= 0 的 CalcPrecision$000E([MS-XLS] §2.4.35),XLSX 里是calcPr fullPrecision="0"(ECMA-376 Part 1) - HotXLS 开关:
TXLSXWorkbook.FullPrecision := False和TXLSWorkbook.UseFullPrecision := False,默认都是 True - 节:按计算值的正负号挑;只有精确的零才用第三节
- 位数:小数占位符,每个
%加二,每个缩放逗号减三;计数可以是负的 - 舍入:远离零取半加几个 ulp 的容差,于是 2.5 得 3、-2.5 得 -3、1.005 得 1.01
- 跳过:General、日期/时间与流逝时间、科学计数、分数、文本、布尔和错误值
- HotXLS 范围:公式结果在计算时舍入;常量按赋值存储
- XLSX 引擎:第一次
Recalculate之前设FullPrecision;经典引擎的 setter 自己把全部公式重新标脏 - 版本:自 v2.384.57 起两个引擎都对齐 Excel 16;流逝时间格式自 v2.384.9 起受保护
HotXLS 在 Delphi 和 C++Builder 上原生读写并计算 XLS 与 XLSX 工作簿,包括这里讲的工作簿计算选项。详情、版本与试用下载见 HotXLS Delphi 电子表格组件页