技術文章

HotXLS 以顯示精度儲存:Excel 的捨入規則

Excel 的以顯示精度為準,會把每個存放的數字捨入到其數字格式所顯示的小數位:取與數值正負號相符的格式區段,每個 % 加兩位小數,每個千分位縮放逗號減三位,採遠離零捨入。當 TXLSXWorkbook.FullPrecision 或 TXLSWorkbook.UseFullPrecision 為 False 時,HotXLS 在兩個 Delphi 引擎裡套同一條規則。聽起來像一行設定,直到客戶回報您匯出的發票總額跟 Excel 差一分錢,或一整欄 [ss].00 的時長全塌成零。兩件事都發生過,也都追因到其中一條規則寫錯。v2.384.57 起兩個引擎共用同一份實作,預期值全部是在開啟 Workbook.PrecisionAsDisplayed 的 Excel 16 裡實測的

以顯示精度為準實際上改變了活頁簿裡的什麼?

以顯示精度為準是一個活頁簿層級的旗標,要計算引擎把數字照長相存放、而不是照算出來的存放。Excel 介面裡它躲在檔案、選項、進階、「此活頁簿的計算選項」底下,叫「設定顯示精度」。落到磁碟上只有一個位元。BIFF8 檔案把它放在 CalcPrecision 記錄($000E,[MS-XLS] §2.4.35)裡,fFullPrec 欄位為 1 代表正常全精度、0 代表選項開啟。XLSX 套件把它放在 workbook.xml 裡 calcPr 元素的 fullPrecision 屬性,定義於 ECMA-376 第 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?

在 0.00 儲存格裡,Excel 把 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 引擎與傳統引擎各有一份以顯示精度為準的程式碼,各自以不同的方式出錯。若您在選項開啟下產活頁簿,這些就是舊建置產出檔案裡要找的症狀

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] 於是從未把自己的區段標成日期/時間。顯示不受影響——格式化走的是另一條路——但以顯示精度為準靠那個旗標跳過時間值。五秒的時長是一天的 5/86400,約 0.0000579,[ss].00 這種格式看起來就像普通的兩位小數數字,FullPrecision 關閉時,時長便被捨入成 0.00 天。v2.384.9 起,方括號裡單一個 h、m 或 s 字母被剖析成經過時間 token,該區段按日期/時間對待。同一版也修了 h:mm 的分鐘判別——過去 token 之間的冒號會讓剖析器找不到小時

HotXLS 的經過時間誤判示意圖:五秒以極小的天分數存放、儲存格格式帶方括號的 ss token,舊剖析器把它讀成顏色、標成普通兩位小數數字,以顯示精度為準於是把時長捨入成 0.00,直到被剖析成經過時間區段
格式化走自己的路,儲存格看起來正常、存放值卻捨到零;方括號裡單一個 h、m 或 s 是經過時間 token,不是顏色,該區段保留全精度

從 Delphi 在 HotXLS 裡開啟以顯示精度為準

要拿到與 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'); // CalcPrecision 記錄,fFullPrec = 0
  finally
    Wb.Free;
  end;
end;

兩個引擎也尊重隨檔案進來的旗標。開啟一份帶著選項存檔的活頁簿,FullPrecision 或 UseFullPrecision 已經是 False,載入後的 Recalculate 於是與 Excel 捨得一模一樣。若只需要讀 Excel 已存的數字,重算可以整個跳過,見不重算直接讀快取公式值;序號與日期格式怎麼與驅動日期/時間檢查的格式模型互動,見Delphi 裡的 Excel 日期序號、1904 系統與 numFmt

以顯示精度為準什麼時候該開、什麼時候別開?

只有當活頁簿的存放數字必須等於顯示數字、而且您接受永遠失去多出來的位數時,才開以顯示精度為準。經典的正當場景是財務報表:整欄捨入後的金額加起來必須等於螢幕上捨入的總額,不能有藏在暗處的幾厘錢把總額在最後一位頂歪。配合客戶既有的、已開此選項的活頁簿是另一個好理由;HotXLS 往返時保留這個旗標,不會悄悄把它們切回全精度

其他大多數情況請避開:

  • 工程與科學資料。因為有人為報表挑了兩位小數格式,就把量測值捨入,摧毀的是之後換任何格式都救不回的資訊
  • 粗格式的百分比。0% 格式只保留存放比值的兩位小數,0.1234 變成 0.12,下游每條讀那格的公式都在跟 0.12 打交道
  • 縮放顯示。用來顯示千位的 0, 或 0.0, 格式,會把存放值捨到千位或百位,挑那個格式的人多半沒這個意思
  • 共用範本。旗標是活頁簿層級的。之後任何人加一張工作表都繼承這個行為,而且通常不知道它開著

真正想要的若是少數幾格的捨入結果,就在那些公式裡寫 ROUND。ROUND 明確、只影響該格、讀公式的人都看得到,由 HotXLS 公式引擎當普通函式求值,沒有全活頁簿的副作用

以顯示精度為準速查

  • 檔案旗標:BIFF8 是 CalcPrecision $000E 帶 fFullPrec = 0([MS-XLS] §2.4.35),XLSX 是 calcPr fullPrecision="0"(ECMA-376 第 1 部)
  • HotXLS 開關:TXLSXWorkbook.FullPrecision := False 與 TXLSWorkbook.UseFullPrecision := False,皆預設 True
  • 區段:按計算值的正負號挑;第三區段只用於恰好為零
  • 位數:小數預留位置,每個 % 加二、每個縮放逗號減三;位數可以是負的
  • 捨入:遠離零捨入加幾個 ulp 容差,2.5 得 3、-2.5 得 -3、1.005 得 1.01
  • 跳過:General、日期/時間與經過時間、科學記號、分數、文字、布林與錯誤值
  • HotXLS 的作用範圍:公式結果在求值時;常數照指派存放
  • XLSX 引擎:FullPrecision 在第一次 Recalculate 之前設好;傳統引擎的 setter 自己把所有公式重新標記為待重算
  • 版本:兩個引擎自 v2.384.57 起與 Excel 16 對齊;經過時間格式自 v2.384.9 起受保護

HotXLS 讓 Delphi 與 C++Builder 原生讀寫、計算 XLS 與 XLSX 活頁簿,包括本文涵蓋的活頁簿計算選項。細節、版本與試用下載見 HotXLS Delphi spreadsheet component 產品頁