技术文章

HotXLS 的 Precision as displayed:Excel 舍入规则

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 引擎实现的

  1. 按正负号挑节。两节格式对负值用第二节。三节以上的格式对负值用第二节、对精确的零用第三节。其余一切用第一节
  2. 数小数占位符。该节中小数点之后的每个 0、# 或 ? 加一位保留小数
  3. 每个百分号加两位。0.0% 把 0.1234 显示成 12.3%,所以存储值是你看到值的百分之一,保留三位小数,不是一位
  4. 每个缩放逗号减三位。最后一个整数占位符之后的逗号(0,、0.0,、0,.0)把显示值除以 1000。0.0, 把 12345.678 显示成 12.3,所以 Excel 保留一位减三位——是个负数:值被舍到百位、存成 12300。整数占位符之间的逗号,如 #,##0,只是数字分组,什么也不改
  5. 非数字节一概不动。General、日期和时间节(含流逝时间 [h]、[mm] 和 [ss])、科学计数、分数和文本节,以及没有任何数字占位符的节,都保持全精度
HotXLS 的显示精度规则示意图:按值的正负号挑格式节,数小数点后的数字占位符,每个百分号加两位,每个千位缩放逗号减三位以至计数可以为负,General 与日期时间节整个跳过,最后按远离零取半舍入
位数来自与正负号匹配的节,每个百分号加二、每个缩放逗号减三,负的计数舍到十位或百位;General 与日期节一概不动

对 Excel 16 实测,这是两个 HotXLS 引擎现在对每个格式下公式结果存储的值:

数字格式计算值存储值适用的规则
0.0%0.12340.123一位小数加百分号的两位
02.53远离零取半,不是取偶
0-2.5-3负数一侧同样远离零取半
0.00;(0.0)-1.2345-1.2负数节显示一位小数
0.00;(0.0)1.23451.23正数节显示两位小数
#,##0.01234.56781234.6分组逗号,无缩放
0.0,12345.67812300一位减三位:舍到百位
0.0%;(0.00%)-0.0125-0.0125负数节保留二加二位小数
0.001.0051.01对二进制表示误差的容差
0;-0;0.00.51非零,由正数节说了算

最后一行是个漂亮的陷阱。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),截断、缩放回去、恢复符号。下面这个函数是这一原理的自足示意,不是库代码本身,它以同样方式处理缩放逗号带来的负位数:

HotXLS 舍入示意图:2.5 按远离零取半舍成 3、-2.5 舍成 -3,而 Delphi 的 System.Round 给出银行家答案 2 和 -2;由于最接近 1.005 的 double 略低于半程点,几个 ulp 的容差正是把基于 floor 的 1.00 变成 Excel 答案 1.01 的关键
Excel 把平局舍离零,并用小容差原谅二进制表示误差;两个细节都可实测,漏掉任何一个就会把 2.5 存成 2 或把 1.005 存成 1.00,与 Excel 差一分钱
// 原理示意:按 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 之间的冒号过去会让解析器看不见小时

HotXLS 的流逝时间误解析示意图:五秒以极小的天分数存进带方括号 ss token 的单元格,旧解析器把它读成颜色、标成普通两位小数,precision as displayed 于是把时长舍成 0.00,直到它被解析成流逝时间节
格式化自成一路,单元格看着是对的、存储值却舍成了零;方括号的单个字母 h、m 或 s 是流逝时间 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 电子表格组件页