HotXLS は通常の数値セル値と数式キャッシュを、17 桁の有効数字で XLSX に書き込み、読み取りでは上限付きの正確な 10 進→binary64 チェックで Delphi RTL のパースを補正します。セルに格納した Double が、保存して開き直した後もビット単位で戻ってくるためです。15 桁、つまり Excel が画面に表示する桁数では、IEEE 754 binary64 値を一意に特定できず、その隙間はまさに財務と ID 列が住む場所、1015 を超える整数に現れます
変更を迫ったバグは無害に見えました。ワークシート数式 INT(-1E15-0.1) は -1000000000000001 に評価され、完全に表現可能な double です。HotXLS は正しく計算し、キャッシュし、ファイルを保存しましたが、ディスク上のキャッシュは -1E15 と読めました。Excel が元の値を表示するのは再計算の後だけで、キャッシュ値を信頼する消費者、つまりほとんどの消費者は、1 ずれた数を静かに受け取ります。何も投げず、何も警告せず、ファイルは検証も通ります
15 桁の有効数字では double に足りない理由
15 桁の 10 進数は、binary64 への往復を生き延びる保証がありますが、逆方向には 17 桁が要ります。IEEE 754 binary64 の仮数は 53 ビットで、値の位置に応じて 10 進 15 から 17 桁に相当します。最大 15 桁の有効数字を持つ 10 進文字列は double へ変換しても元のまま印字されます。しかし double の 10 進綴りが自分を一意に特定するまでには最大 17 桁が要り、253 を超える範囲では隣接 double は 2 離れ、1015 から 253 の間では 0.125 から 1 離れます
Delphi の FloatToStr は 15 桁の有効数字で書式化します。Excel が表示に使うのと同じ精度です。この一致こそが欠陥をうまく隠す理由です。ファイルを Excel で開けば、セルは Delphi プログラムでの見た目とそっくり同じに見えます。Excel も 15 桁しか表示しないからです。違いが見えるのは、格納された double を比較するとき、近い 2 値を引き算するとき、キャッシュを演算する何かに流し込むときだけです。-1000000000000001 の値は 16 桁の有効数字を持つため、15 桁への丸めは -1000000000000000 を生み、末尾の単位は永遠に失われます
ECMA-376 Part 1 §18.3.1.96 はセル値をテキストとして運ぶ <v> 要素を定義しますが、スキーマのどこにも、数値セルに対してライターが何桁出力すべきかは定められていません。精度は完全にライターの責任であり、だからこそ 2 つのプロデューサがどちらも有効な XLSX を書きながら、セルの中の数が何であるかについて食い違い得るのです
Delphi から 17 桁の有効数字を書く
HotXLS は今、数値セルに 1 つの不変条件を使います。精度 17 の ffGeneral で FloatToStrF、不変の書式設定です。セルライターの日付分岐はすでに正確にその投影を使っていたため、普通の数値は単純にそれに合流しました。外から見た API の変化はありません。Double か数式を代入し、保存すると、<v> に着地するトークンは 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 に着地すべきもので、1 つは HotXLS が出力し得る 17 桁の綴り、もう 1 つは Excel が同じ値を書くやり方です。単独で直したライターは、HotXLS 自身が正しく読み戻せないファイルを出荷したことになります
そこで HotXLS は Val を門番として保ち、その答えを補正します。既存の数値文法が日付でないトークンを受け入れた後、正確なパーサが 10 進を多倍長整数の分数として組み立て、RTL の候補の両側のバイナリ中点と比較します。10 進が下の中点より下なら、候補は 1 ulp 下がり、上の中点より上なら上がり、正確な同着では偶数の仮数が勝ちます。これが IEEE 754 の round-to-nearest-even 規則です。候補に許される近傍調整は最大 8 回で、Val は 1 マイルではなく 1 単位しか間違えないため十分です
補正は意図的に上限付きです。48 文字を超えるトークン、17 桁を超える有効数字、324 を超える指数の絶対値を持つトークンはスキップしてレガシーパーサの結果を保ち、内部の失敗はどれも同じものへフォールバックします。多倍長整数は 32 ビットのリム 48 個を使い、この範囲内で最も広い分子と分母に足ります。ここで重要な移植性の細部が 1 つあります。リムは明示的に UInt32 として宣言すること。LongWord が自明な選択に見えますが、Delphi の Linux64 ターゲットでは 8 バイトで、幅が密かに倍になるリム型は演算のすべての繰り上がりとシフトを壊します
往復をビット単位で検証する
このテストでは double を = で比較するので十分です。目標は正確な一致だからです。ただしビットパターン比較の方が失敗レポートは有用になります。1 ulp のずれは、まったく同じに印字される 2 つの数としてではなく、生の 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 パーツが数値キャッシュを運ぶのか
セルライターの修正は複数の経路のうち 1 つを閉じたにすぎず、残りの数値書き込みポイントはそれぞれ個別に確認する必要がありました。最初のリリースはワークシート値と数式キャッシュを、Pivot 出力の値キャッシュを含めてカバーしました。Pivot ソースキャッシュと Chart キャッシュは依然として 15 桁の漏斗を通っており、想定ではなく 2 つの後続リリースで閉じられました
- Pivot ソースキャッシュ:
sharedItemsの値と、インラインとストリームの両方のcacheRecordsライターが同じ 17 桁の不変条件を使うようになり、ピボットの読み取り経路も同じ正確な中点補正を適用します。refreshedDateのような説明的な属性は元の綴りを保ちます - Chart キャッシュ: キャッシュされた点値を組み立てる単一の書き込みポイントが 17 桁に切り替わり、近似曲線の値とカテゴリ、エラーバー検証、2 つの描画経路をカバーする 6 つの読み取りポイントがすべて 1 つの正確パースヘルパーを通ります
- 列幅や軸範囲のようなその他の数値はすべて、非有限とサブノーマルを 0 へ流す防御フォーマッタを通り続けます。シリアライズされたパーツがパース不能な float を含んではならないためです
最も重要だった相互運用のチェックは Excel 自身とのものでした。Excel は 253-1 を整数 9007199254740991 として書き、HotXLS は等価な 17 桁の綴り 9007199254740990.7 を書くことがあります。両方のトークンは両側で同じ double にパースされるため、Excel で作られ、HotXLS が開き直して再保存し、再び Excel で開かれたファイルは、Pivot キャッシュの中の同じ値を保ちます。テストすべき性質は、同一の綴りではなく相互認識です
知っておくべき限界とエッジケース
ここでの損失なしは、binary64 値が保たれることであって、すべての値が表現可能という意味ではありません。非有限の結果は今も #NUM! エラーとして保存され、サブノーマルは今もゼロとして保存されます。以前と同じです。表示も別の関心事です。Excel は画面に 15 桁の有効数字を表示し続けるため、Excel に 9007199254740993 を打ち込むユーザーは今も異なる格納値を得ますし、どんなライターにもそれは直せません。ブックが「表示桁数に応じて精度を設定」を使っているなら、値は意図的に数値書式へ丸められ、本記事の主題の正反対です。そのモードの規則はHotXLS が Excel の表示桁数に応じた丸めを適用する方法で扱います
正確なリーダーは Val を置き換えません。補正するのは Val がすでに受け入れた値だけです。RTL が拒否するトークンは今も拒否され、48 文字、17 桁、指数 324 の境界の外のトークンはレガシーパーサの産物を保ちます。HotXLS か Excel が書いたファイルではそれは問題になりません。どちらもセル値としてそのようなトークンを出さないためですが、40 桁の有効数字を持つ手作り XML パーツは、正確な補正ではなく RTL の精度で読まれます
FAQ
HotXLS は Excel がセルに表示する数を変えるのか
いいえ。HotXLS が変えるのは、格納される <v> トークンに何桁入るかだけです。Excel は今も 15 桁の有効数字を表示するため、セルの見た目は同じままです。変わったのは、下の double が Delphi コードが代入したものと一致するようになったことです
17 ではなく 16 桁を書けばよいのでは
16 桁ではすべての binary64 値に足りません。10 進綴りが一意になるまで 17 桁の有効数字を要する double もあり、16 桁のライターはまれなケースで 2 つの異なる double を同じテキストに写像し続けます。これこそこの変更が取り除く無言のずれのクラスです
修正前のファイルは再生成が要るか
15 桁を超える桁を必要とする値を含む場合だけです。それらのトークンはすでにディスク上で末尾の桁を失っており、より正確に読んでも書かれなかったものは復元できません。元のデータから再保存するか、数式を再計算して再度保存すれば、正確なファイルが得られます
17 桁の数値出力、正確な binary64 読み取り補正、対応する Pivot と Chart キャッシュの処理は、Excel や OLE 自動化をインストールせずに XLSX を読み書きする、Delphi、C++Builder、Lazarus 向け HotXLS Delphi Spreadsheet Component の一部です