技術文章

Delphi 的無損 XLSX Double 往返:17 位有效數字

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,末尾的單位永遠消失

HotXLS 以 17 位有效數字寫出 XLSX 儲存格值與公式快取,因為從 INT(-1E15-0.1) 快取的合法 double -1000000000000001,以 15 位存成 -1E15 時丟掉了末尾單位
53 位 binary64 尾數橫跨 15 到 17 個十進位位數,所以 -1000000000000001 這類值必須給足 17 位有效數字,十進位拼寫才唯一

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 字元的 token、有效數字超過 17 位的 token、指數數量級超過 324 的 token,跳過修正、保留舊解析器的結果,任何內部失敗也退回同樣的東西。大整數用每個 32 位元的 48 個 limb,足夠這些邊界內最寬的分子與分母。這裡有一個可攜性細節要緊:limb 顯式宣告為 UInt32。LongWord 看似顯而易見的選擇,但它在 Delphi 的 Linux64 目標上是 8 位元組,悄悄變寬一倍的 limb 型別會弄壞算術裡的每一次進位與位移

HotXLS 對 17 位數的 XLSX 數值修正 Delphi RTL 的 Val 結果:把十進位建成大整數分數與二進位中點比較,朝 IEEE 754 round-to-nearest-even 移動一個 ulp
9007199254740990.7 與 Excel 寫的純整數都必須落在 2^53 - 1,所以精確讀取器最多調整八個鄰居,48 字元與指數 324 的邊界之外保留舊解析

逐位元驗證往返

這個測試裡用 = 比較 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 部件帶數值快取?

修好儲存格寫入器只關掉了好幾條路裡的一條,其餘每個數值寫入點都得各自檢查。第一版涵蓋工作表值與公式快取,包括樞紐輸出的值快取。樞紐來源快取與圖表快取當時仍走 15 位漏斗,在兩個後續版本裡關閉,而不是憑假設

  • 樞紐來源快取:sharedItems 值與內嵌、流式兩種 cacheRecords 寫入器現在都用同一條 17 位不變式,樞紐讀取路徑套用同樣的精確中點修正。refreshedDate 這類描述性屬性保持原拼寫
  • 圖表快取:組快取點值的那個寫入點換成 17 位,六個讀取點——涵蓋趨勢線值與類別、誤差線驗證與兩條渲染路徑——全部經過同一個精確解析輔助
  • 其餘一切數值,如欄寬與軸邊界,仍走防禦性格式化器,把非有限值與次正規值沖成 0,因為序列化的部件裡絕不能出現解析不了的浮點數

最重要的互通檢查是對 Excel 本尊。Excel 把 253-1 寫成整數 9007199254740991;HotXLS 可能寫出等價的 17 位拼寫 9007199254740990.7。兩個 token 在兩邊都解析成同一個 double,所以 Excel 寫的檔案、被 HotXLS 重開再存、再回到 Excel 打開,樞紐快取裡的值保持不變。要測的性質是互相認得,不是拼寫相同

Excel 把 2^53 - 1 寫成整數 9007199254740991,HotXLS 則可能寫出 17 位拼寫 9007199254740990.7,兩個 XLSX token 在 Delphi 與 Excel 裡都解析成同一個 double
活頁簿從 Excel 出發、經 HotXLS 重存再回去,樞紐快取值逐位元保持,因為兩家讀取器都接受任一拼寫

值得知道的限制與邊界情況

這裡的無損指 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 讀取修正,以及配套的樞紐與圖表快取處理,都是 HotXLS Delphi Spreadsheet Component 的一部分,支援 Delphi、C++Builder 與 Lazarus,不需要安裝 Excel 或 OLE 自動化即可讀寫 XLSX