HotXLS schreibt gewöhnliche numerische Zellwerte und Formel-Caches mit 17 signifikanten Stellen in XLSX und korrigiert beim Lesen den Delphi-RTL-Parse mit einer begrenzten exakten Dezimal-zu-Binary64-Prüfung, sodass ein Double, den Sie in einer Zelle ablegen, nach einem Speichern und Wiederöffnen Bit für Bit zurückkommt. Fünfzehn Stellen — die Zahl, die Excel auf dem Bildschirm zeigt — reichen nicht, um einen IEEE-754-Binary64-Wert zu identifizieren, und die Lücke zeigt sich genau dort, wo Finanz- und ID-Spalten leben: bei Ganzzahlen jenseits von 1015
Der Bug, der die Änderung erzwang, sah harmlos aus. Eine Worksheet-Formel INT(-1E15-0.1) ergibt -1000000000000001, ein vollkommen darstellbares Double. HotXLS berechnete es korrekt, cache-te es, speicherte die Datei — und der Cache auf der Platte las sich -1E15. Excel hätte den Originalwert erst nach einer Neuberechnung gezeigt, und jeder Konsument, der gecachten Werten vertraut — das sind die meisten —, bekam still eine Zahl daneben. Nichts warf, nichts warnte, und die Datei validierte sauber
Warum reichen 15 signifikante Stellen für ein Double nicht?
Fünfzehn Dezimalstellen überstehen garantiert eine Reise in Binary64 und zurück, aber die umgekehrte Richtung braucht 17. IEEE 754 Binary64 hat eine 53-Bit-Mantisse, was je nach Lage des Werts zwischen 15 und 17 Dezimalstellen ergibt. Jeder Dezimalstring mit höchstens 15 signifikanten Stellen wandelt in ein Double und druckt unverändert zurück. Ein Double braucht jedoch bis zu 17 Stellen, bevor seine Dezimalschreibweise es eindeutig identifiziert, und im Bereich über 253 liegen benachbarte Doubles 2 auseinander, während sie zwischen 1015 und 253 um 0,125 bis 1 auseinanderliegen
Delphis FloatToStr formatiert mit 15 signifikanten Stellen — derselben Präzision, die Excel für die Anzeige verwendet. Genau deshalb versteckt sich der Defekt so gut: Öffnet man die Datei in Excel, sieht die Zelle exakt so aus wie im Delphi-Programm, denn Excel zeigt ebenfalls nur 15 Stellen. Der Unterschied wird erst sichtbar, wenn man das gespeicherte Double vergleicht, zwei benachbarte Werte subtrahiert oder den Cache in etwas speist, das Arithmetik treibt. Der Wert -1000000000000001 hat 16 signifikante Stellen, also produziert das Runden auf 15 die -1000000000000000, und die nachlaufende Einheit ist für immer weg
ECMA-376 Part 1 §18.3.1.96 definiert das <v>-Element, das einen Zellwert als Text trägt, und nichts im Schema schreibt vor, wie viele Stellen ein Writer für eine Zahlzelle emittieren muss. Präzision ist ganz allein die Verantwortung des Writers, weshalb zwei Produzenten beide gültiges XLSX schreiben können und trotzdem darüber streiten, welche Zahl in der Zelle steckt
17 signifikante Stellen aus Delphi schreiben
HotXLS verwendet jetzt eine Invariante für numerische Zellen: FloatToStrF mit ffGeneral, Präzision 17 und invarianten Formateinstellungen. Der Datumszweig des Zell-Writers verwendete bereits exakt diese Projektion, also schlossen sich die gewöhnlichen Zahlen einfach an. Von außen ändert sich an der API nichts; Sie weisen ein Double oder eine Formel zu, speichern, und das Token, das in <v> landet, ist präzise genug, um das Double zu reproduzieren
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');
// Werte und der Formel-Cache werden mit 17 signifikanten Stellen geschrieben
if Book.SaveAs('ledger.xlsx') <> lxOk then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
Siebzehn Stellen sind eine Obergrenze, keine Auffüllregel. ffGeneral wirft nachlaufende Nullen weg, also wird 0,5 weiterhin als 0.5 geschrieben und kleine Ganzzahlen bleiben kurz; nur Werte, die die Extrastellen wirklich brauchen, bekommen sie. Die Kosten sind ein paar Bytes auf den Zellen, die vorher abgeschnitten wurden — die billigste Korrektheitsreparatur, die Sie je ausliefern werden
Warum verlieren 17 Stellen beim Lesen trotzdem Werte?
Genug Stellen zu schreiben hilft nur, wenn der Reader sie in das nächstliegende Double wandelt, und das Val der RTL tut das nicht immer. Während der Verifikation las die RTL auf der Testmaschine sowohl 9007199254740990.7 als auch die schlichte Ganzzahl 9007199254740991 in den benachbarten, falschen Binary64-Wert ein. Beide Strings sollten auf 253-1 landen; das eine ist eine 17-stellige Schreibweise, die HotXLS produzieren kann, das andere ist die, mit der Excel denselben Wert schreibt. Ein Writer, der isoliert gefixt worden wäre, hätte eine Datei ausgeliefert, die HotXLS selbst nicht korrekt hätte zurücklesen können
HotXLS behält Val also als Torwächter und korrigiert seine Antwort. Nachdem die bestehende numerische Grammatik ein Nicht-Datum-Token akzeptiert hat, baut ein exakter Parser die Dezimalzahl als Big-Integer-Bruch und vergleicht sie mit den Binärmittelpunkten zu beiden Seiten des RTL-Kandidaten. Liegt die Dezimalzahl unter dem unteren Mittelpunkt, steigt der Kandidat um ein ulp ab; über dem oberen, steigt er auf; bei exaktem Gleichstand gewinnt die gerade Mantisse — die IEEE-754-Round-to-Nearest-Even-Regel. Dem Kandidaten sind höchstens acht Nachbaranpassungen erlaubt, was reichlich ist, denn Val liegt um eine Einheit daneben, nicht um Meilen
Die Korrektur ist bewusst begrenzt. Token länger als 48 Zeichen, mit mehr als 17 signifikanten Stellen oder mit einer Exponentenmagnitude über 324 überspringen sie und behalten das Legacy-Parser-Ergebnis, und jeder interne Fehlschlag fällt auf dasselbe zurück. Die Big Integers verwenden 48 Limbs à 32 Bits, genug für den breitesten Zähler und Nenner innerhalb dieser Grenzen. Ein Portabilitätsdetail zählt hier: Die Limbs sind explizit als UInt32 deklariert. LongWord sieht nach der naheliegenden Wahl aus, ist aber 8 Bytes auf Delphis Linux64-Ziel, und ein Limb-Typ, der still die doppelte Breite annimmt, bricht jeden Carry und Shift in der Arithmetik
Den Round-Trip Bit für Bit verifizieren
Doubles mit = zu vergleichen ist für diesen Test in Ordnung, denn das Ziel ist exakte Gleichheit, aber ein Bitmuster-Vergleich macht den Fehlerreport nützlicher: Eine Ein-ulp-Abweichung zeigt sich als Differenz von 1 im rohen 64-Bit-Wert statt als zwei Zahlen, die identisch drucken
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;
Zeile 3 ist die interessante. Sie liest das gecachte Ergebnis von INT(-1E15-0.1) ohne Neuberechnung, beweist also, dass der Cache selbst verlustfrei ist und nicht etwa die Formel-Engine den Wert neu berechnen könnte. Wenn Sie in einer Pipeline von gecachten Ergebnissen abhängen, behandelt der separate Leitfaden zum Lesen gecachter Formelwerte ohne Neuberechnung die Cache-Zustände und warum ein fehlender Cache nie erfunden wird
Welche anderen XLSX-Parts tragen numerische Caches?
Den Zell-Writer zu reparieren schloss einen von mehreren Pfaden, und jeder verbleibende numerische Schreibpunkt musste einzeln geprüft werden. Das erste Release deckte Worksheet-Werte und Formel-Caches ab, eingeschlossen die Wert-Caches von Pivot-Output. Pivot-Quell-Caches und Chart-Caches liefen weiterhin durch den 15-Stellen-Trichter und wurden in zwei Nachfolge-Releases geschlossen statt per Annahme
- Pivot-Quellcache: Die
sharedItems-Werte und sowohl der Inline- als auch der gestreamtecacheRecords-Writer verwenden jetzt dieselbe 17-Stellen-Invariante, und der Pivot-Lesepfad wendet dieselbe exakte Mittelpunktkorrektur an. Beschreibende Attribute wierefreshedDatebehalten ihre ursprüngliche Schreibweise - Chart-Caches: Der einzelne Schreibpunkt, der gecachte Punktwerte aufbaut, wechselte auf 17 Stellen, und die sechs Lesepunkte — Trendline-Werte und Kategorien, Error-Bar-Validierung und zwei Renderpfade — laufen alle durch einen exakten Parse-Helfer
- Alles andere Numerische, etwa Spaltenbreiten und Achsgrenzen, läuft weiterhin durch den defensiven Formatter, der nicht endliche und subnormale Werte auf 0 wirft, denn ein serialisierter Part darf niemals einen nicht parsbaren Float enthalten
Die wichtigste Interoperabilitätsprüfung war die mit Excel selbst. Excel schreibt 253-1 als die Ganzzahl 9007199254740991; HotXLS schreibt möglicherweise die äquivalente 17-stellige Schreibweise 9007199254740990.7. Beide Tokens parsen auf beiden Seiten zu demselben Double, also behält eine in Excel erzeugte Datei, von HotXLS wieder geöffnet und neu gespeichert, dann erneut in Excel geöffnet, dieselben Werte in ihrem Pivot-Cache. Gegenseitige Erkennung, nicht identische Schreibweise, ist die zu prüfende Eigenschaft
Grenzen und Randfälle, die man kennen sollte
Verlustfrei heißt hier: Der Binary64-Wert bleibt erhalten, nicht dass jeder Wert darstellbar ist. Nicht endliche Ergebnisse werden weiterhin als #NUM!-Fehler gespeichert und Subnormale weiterhin als null, exakt wie zuvor. Die Anzeige ist ein separates Anliegen: Excel zeigt auf dem Bildschirm weiterhin 15 signifikante Stellen, ein Nutzer, der 9007199254740993 in Excel tippt, bekommt also weiterhin einen anderen gespeicherten Wert, und kein Writer kann das beheben. Verwendet Ihre Arbeitsmappe "Genauigkeit wie dargestellt", werden Werte absichtlich auf ihr Zahlenformat gerundet — das Gegenteil dessen, worum es in diesem Artikel geht; die Regeln dieses Modus behandelt wie HotXLS Excels Genauigkeit-wie-dargestellt-Rundung anwendet
Der exakte Reader ersetzt Val auch nicht; er korrigiert nur Werte, die Val bereits akzeptiert hat. Ein Token, das die RTL ablehnt, wird weiterhin abgelehnt, und ein Token außerhalb der 48-Zeichen-, 17-Stellen-, Exponenten-324-Grenzen behält, was der Legacy-Parser produzierte. Für Dateien von HotXLS oder Excel spielt das nie eine Rolle, denn keines von beiden produziert solche Tokens für Zellwerte, aber ein handgefertigter XML-Part mit 40 signifikanten Stellen wird mit der RTL-Genauigkeit gelesen, nicht mit der exakten Korrektur
FAQ
Ändert HotXLS die Zahl, die Excel für eine Zelle anzeigt?
Nein. HotXLS ändert nur, wie viele Stellen in das gespeicherte <v>-Token gehen. Excel zeigt weiterhin 15 signifikante Stellen, Zellen sehen also gleich aus; der Unterschied ist, dass das zugrunde liegende Double jetzt dem entspricht, was Ihr Delphi-Code zugewiesen hat
Warum nicht einfach 16 statt 17 Stellen schreiben?
Sechzehn Stellen reichen nicht für jeden Binary64-Wert. Manche Doubles brauchen 17 signifikante Stellen, bevor ihre Dezimalschreibweise eindeutig ist, also würde ein 16-Stellen-Writer in seltenen Fällen weiterhin zwei verschiedene Doubles auf denselben Text mappen — exakt die Klasse stiller Abweichung, die die Änderung entfernt
Müssen Dateien, die vor dem Fix geschrieben wurden, neu erzeugt werden?
Nur wenn sie Werte enthalten, die mehr als 15 Stellen brauchten. Diese Tokens haben ihre letzten Stellen bereits auf der Platte verloren, und sie genauer zu lesen kann nicht wiederherstellen, was nie geschrieben wurde. Erneutes Speichern aus den Originaldaten oder Neuberechnen der Formeln und erneutes Speichern erzeugt eine exakte Datei
Siebzehnstellige numerische Ausgabe, die exakte Binary64-Lesekorrektur und die passende Pivot- und Chart-Cache-Behandlung sind Teil der HotXLS Delphi Spreadsheet Component für Delphi, C++Builder und Lazarus, die XLSX ohne installiertes Excel oder OLE-Automatisierung liest und schreibt