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 引擎實作的內容
- 按正負號挑區段。兩區段格式對負值用第二區段。三個以上區段的格式,負值用第二區段、恰好為零用第三區段。其餘一律用第一區段
- 數小數預留位置。該區段小數點後的每個
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?
在 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),截斷、縮回、再還原正負號。下面的函式是該原理的自足示意,不是函式庫本身的程式碼;縮放逗號造成的負位數,它也照樣處理:
// 原理示意:遠離零捨入到 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 之間的冒號會讓剖析器找不到小時
從 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 產品頁