HotXLS, die native Delphi- und C++Builder-Excel-Bibliothek, speichert eine klassische BIFF8-.xls-Mappe Cache-first: TXLSWorksheet.WriteFormula fragt bei TXLSWorkbook.TryGetCachedFormulaValue nach dem Wert, den Excel neben jeder Formel abgelegt hat, und ruft den Evaluator nur auf, wenn dieser Cache fehlt oder invalidiert ist. Eine geöffnete und nie angefasste Mappe schreibt dieselben Zahlen zurück, und frische Ergebnisse kosten einen expliziten Recalculate-Aufruf, statt versteckter Nebeneffekt von SaveAs zu sein
Der Bug, der diesen Vertrag ans Licht zwang, war peinlich klein. Eine Korpus-Datei namens nested-subtotals.xls hält eine Gesamtsumme in R2C4, deren Cache-Wert 37 lautet. Mit HotXLS öffnen, für die Zelle TryGetCachedFormulaValue fragen, 37 bekommen. Speichern, ohne eine einzige Zelle anzufassen, die gespeicherte Kopie öffnen, dieselbe Frage stellen, 67 bekommen. Niemand hatte die API gebeten, irgendetwas zu berechnen, trotzdem war eine Zahl in der Datei um exakt 30 gewandert – und 30 ist zufällig die Summe der beiden Gruppenzwischensummen, 10 und 20, die im Bereich liegen, den die Gesamtsumme abdeckt
Warum ändert das Speichern einer XLS-Datei einen Formelwert?
Damit aus diesen 37 die 67 wurden, mussten sich zwei unabhängige Defekte die Hand reichen, und allein einer davon zu beheben hätte den anderen versteckt. Der erste war strukturell: Der klassische Writer hat bei jedem Speichern jede Formel neu berechnet. Der zweite war eine Typprüfung, die für eine von der Platte geladene Formel niemals wahr sein konnte und den Evaluator dadurch verschachtelte SUBTOTAL-Zellen doppelt zählen ließ. Die Korpus-Datei war schlicht der erste Input, bei dem eine Speicherzeit-Neuberechnung ein anderes Ergebnis als Excel lieferte und jemand beide verglich. Der strukturelle Defekt lässt sich leicht benennen: Vor v2.382.3 holten TXLSWorksheet.WriteFormula und ihr Shared-Formula-Geschwister WriteFormulaWithTExp das acht Byte lange FormulaValue-Feld jedes Formula-Records über einen Aufruf von TXLSWorkbook.GetFormulaValue – und das ist der Evaluator. Der Cache, den ParseFormula beim Laden sorgfältig aus der Quelldatei dekodiert hatte, wurde auf dem Weg raus nie konsultiert. Effektiv war jedes Speichern eine komplette Neuberechnung unter Umgehung der Workbook-weiten Recalc-API, also hätte nichts, was Sie an der Mappe setzen konnten, es gestoppt. Jede Stelle, an der der HotXLS-Evaluator mit Excel uneins war – ob legitimerweise nicht unterstützte Funktion oder schlichter Bug –, wurde zu einer stillen Datenänderung beim Speichern
Der zweite Defekt steckte im Nested-Subtotal-Callback, das der Evaluator benutzt. Excel definiert jede SUBTOTAL-Form als ignorierend gegenüber Zellen, deren eigene Formel ein weiteres SUBTOTAL ist, also bewaffnet der Rechner in lxCalc.pas während der Aggregation FIgnoreSubtotalCells und fragt die Mappe über TXLSWorkbook.GetClassicIsSubtotalCell, ob jede Zelle im Bereich eine solche ist. Dieser Callback holte den Formeltext als Variant und prüfte ihn mit VarType(f) = varOleStr. Der Text kommt aber von GetUnCompiledFormula als Delphi-String zurück, und ein einem Variant zugewiesener String ist varUString, nie varOleStr. Das Prädikat war für jede Zelle in jeder geladenen Datei falsch, die Gruppenzwischensummen flossen ein zweites Mal in die Gesamtsumme ein, und bei einem Speichern, das alles neu berechnete, wurden aus 10 + 20 + 7 die 67
// HotXLS 2.381 und älter: ein aus einem String gebauter
// Formula-Variant ist varUString, dieser Vergleich ging nie auf
Result := (VarType(f) = varOleStr) and
(SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
SameText(Copy(f, 1, 10), '=SUBTOTAL('));
// HotXLS 2.382.0: VarIsStr akzeptiert varString, varOleStr und varUString,
// und AGGREGATE wird wie in Excel von umschließenden Subtotals ausgeschlossen
if VarIsStr(f) then
Result := SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
SameText(Copy(f, 1, 10), '=SUBTOTAL(') or
SameText(Copy(f, 1, 10), 'AGGREGATE(') or
SameText(Copy(f, 1, 11), '=AGGREGATE(');
v2.382.0 lieferte den VarIsStr-Fix aus und lehrte denselben Callback nebenbei, dass AGGREGATE-Zellen ebenfalls von umschließenden Subtotals ausgeschlossen werden. Das allein brachte die Korpus-Assertion auf Grün, weil die neu berechneten 37 jetzt zu den geladenen 37 passten. Ehrlich gemacht hat das die Bibliothek damit noch lange nicht: Das Speichern berechnete weiter alles neu, und der Test war nur grün, weil der Evaluator in dieser einen Datei zufällig mit Excel übereinstimmte. Die Regeln, welche Zellen SUBTOTAL und AGGREGATE überspringen, ausgeblendete Zeilen eingeschlossen, behandelt der SUBTOTAL-und-AGGREGATE-Artikel zu ausgeblendeten Zeilen; was hier zählt, ist, dass kein Evaluator mitentscheiden darf, was in eine Datei geschrieben wird, die Sie ihn nie hat rechnen lassen
Was garantiert Excel über Cache-Werte beim Speichern?
Excel behandelt ein Speichern als Schnappschuss, nicht als Berechnungsereignis. Der Wert, der ins FormulaValue-Feld eines Formula-Records geschrieben wird ([MS-XLS] §2.4.127, Layout in §2.5.133), ist genau das, was die Zelle aktuell anzeigt – im manuellen Berechnungsmodus womöglich Jahre alt –, und Excel schreibt ihn trotzdem treu heraus. Neuberechnung ist eine eigene Operation mit eigenem Trigger. HotXLS folgt für klassische Speicherungen jetzt derselben Regel: WriteFormula und WriteFormulaWithTExp rufen zuerst TryGetCachedFormulaValue auf, nehmen CacheInfo.Value, wenn der Zustand xlfcsLoaded oder xlfcsCalculated ist, und fallen nur bei xlfcsMissing und xlfcsInvalidated auf GetFormulaValue durch. Die Lese-Hälfte dieses Vertrags – was jeder Zustand bedeutet und warum ein gecachter Blank oder False trotzdem als Wert zählt – beschreibt Read Excel Cached Formula Values in Delphi Without Recalc
Der Fallback-Pfad bleibt bewusst drin, statt entfernt zu werden. Eine Formel, die Sie in dieser Sitzung über Cells[Row, Col].Formula zugewiesen haben, kommt ohne Cache an, und eine Formel, die Sie in einer geladenen Zelle ersetzt haben, wird von _SetCompiledFormula auf xlfcsInvalidated gesetzt; beide werden beim Speichern genauso ausgewertet wie vorher, damit eine generierte Mappe weiterhin mit Zahlen in Excel öffnet. Kann selbst der Evaluator keinen Wert liefern, schreibt der Writer eine Null-Payload und setzt fAlwaysCalc (grbit Bit 0 von §2.4.127), sodass Excel die Zelle beim Öffnen neu berechnet, statt dem Platzhalter zu vertrauen
procedure RoundTripWithoutRecalc(const Source, Target: string);
var
Book: TXLSWorkbook;
Before, After: TXLSFormulaCacheInfo;
begin
Book := TXLSWorkbook.Create;
try
Book.Open(Source);
// 1-basiertes Sheet, Zeile und Spalte: R2C4 auf dem ersten Sheet
if not Book.TryGetCachedFormulaValue(1, 2, 4, Before) then
raise Exception.Create('R2C4 carries no usable cache');
Book.SaveAs(Target); // kein Evaluator im Spiel für gecachte Zellen
finally
Book.Free;
end;
Book := TXLSWorkbook.Create;
try
Book.Open(Target);
Book.TryGetCachedFormulaValue(1, 2, 4, After);
// Before.Value = After.Value = 37 für nested-subtotals.xls
// Ein Speichern, das neu berechnet hätte, hätte hier 67 geschrieben
finally
Book.Free;
end;
end;
Wo bewahrt die Wurzel einer BIFF Shared Formula ihren Cache-Wert?
In seinem eigenen Formula-Record, wie jede andere Formelzelle auch – und genau das machte die Wurzelzelle einer Shared Group zu der einen Stelle, an der Cache-first-Speichern weiterhin verlor. Eine Shared Formula liegt in BIFF8 als ShrFmla-Record ([MS-XLS] §2.4.260) hinter dem Formula-Record der Zelle oben links, und jede Mitgliedszelle, Wurzel eingeschlossen, trägt ein rgce, das aus einem einzelnen PtgExp-Token besteht (§2.5.198): Das erste Byte des geparsten Ausdrucks ist $01, gefolgt von Zeile und Spalte der Wurzelzelle. Die Follower-Zellen sind in sich geschlossen – HotXLS liest ihre jeweiligen FormulaValue und löst den Ausdruck über die kompilierte Formel der Wurzel auf. Die Wurzelzelle ist anders, denn wenn ihr Formula-Record geparst wird, existiert der Ausdruck noch nicht; er kommt einen Record später
Diese eine-Record-Lücke ist, wo der Cache verloren ging. TXLSReader.ParseFormula dekodiert den Cache-Wert und, sobald es ein PtgExp sieht, dessen Koordinaten der Zelle selbst entsprechen, merkt es sich die Zelle in FSharedFormulaRow und FSharedFormulaCol und übergibt den Cache an die Zelle. Kommt dann der ShrFmla-Record ($04BC), kompiliert ParseSharedFormula den Ausdruck und installiert ihn mit _SetCompiledFormula, und _SetCompiledFormula tut, was es bei jeder Formeländerung tun muss: Es räumt FCachedFormulaValue weg und setzt den Zustand auf xlfcsMissing zurück. Die geladenen 37 der Wurzel waren also weggeworfen, bevor sie jemand lesen konnte, TryGetCachedFormulaValue meldete die Wurzel als ungecacht, und der Cache-first-Writer fiel pflichtschuldig auf den Evaluator zurück – ausgerechnet bei der Zelle, auf die alle schauten. Der Array-Record (§2.4.4) folgt derselben Ordnung und hatte dasselbe Loch
Der Fix in v2.382.3 ergänzt ein drittes Feld, FSharedFormulaCachedValue, neben den ausstehenden Wurzel-Koordinaten. ParseFormula legt den dekodierten Cache dort ab, wenn es eine Wurzel erkennt, und ParseSharedFormula wie ParseArrayFormula spielen ihn unmittelbar nach dem Installieren des kompilierten Ausdrucks über _SetCellCachedFormulaValue wieder ein und setzen den Stash danach auf Unassigned zurück. Die String-Variante des Caches ist von all dem unberührt, weil ihre Payload in einem separaten String-Record ankommt und über Zellkoordinaten statt über die Record-Reihenfolge geroutet wird. Wer mit der OOXML-Seite desselben Konzepts arbeitet, dem erklärt der XLSX-Artikel zur Shared-Formula-si-Expansion, warum das Paketformat kein äquivalentes Ordnungsproblem kennt, aber eigene Expansions-Fallen hat
Warum brauchen Shared-Formula-Follower eine relative Verschiebung?
Weil der in ShrFmla gespeicherte Ausdruck relativ zur Wurzelzelle geschrieben ist, und ein Follower, der ihn wortgetreu wiederverwendet, die Referenzen der Wurzel statt der eigenen auswertet. Der alte Reader installierte auf jedem Follower Value.GetCopy(), eine tiefe Kopie ohne Versatz, sodass eine Gruppe mit Wurzel B1 und =A1*3 jedem Follower ebenfalls =A1*3 gab. Cache-first-Speichern hat das bei geladenen Dateien sogar maskiert, denn Follower hatten ihr eigenes FormulaValue und brauchten den Ausdruck für ein korrektes Speichern nie; sichtbar wurde es in dem Moment, in dem irgendetwas neu berechnete. Der Reader installiert jetzt TXLSCompiledFormula.GetCopy(row - srow, col - scol), das den Syntaxbaum durchläuft und jede relative Referenz um den Abstand des Followers zur Wurzel verschiebt, sodass der Follower in B2 ein echtes =A2*3 besitzt
Den Regressionstest, der beide Verhaltensweisen festnagelt, lohnt es zu lesen, denn er lässt keinen Zufall durch. Er baut eine Mappe mit =A1*3 und =A2*3 über die Inputs 2 und 4, spritzt dann die absichtlich falschen Caches 999 und 888 über _SetCellCachedFormulaValue ein, einmal mit UseSharedFormulas an und einmal aus. Nach einem Speichern und Neuladen müssen beide Zellen weiterhin 999 und 888 melden – der Beweis, dass das Speichern weder den Wurzel- noch den Follower-Cache angefasst hat. Erst nach einem expliziten Recalculate dürfen daraus 6 und 12 werden, der Beweis, dass der verschobene Ausdruck des Followers korrekt ist. Ein Test, der die wahren Werte eingesät hätte, wäre auch unter dem alten Writer durchgegangen – genau deshalb sät man die falschen ein
var
Book: TXLSWorkbook;
Info: TXLSFormulaCacheInfo;
begin
Book := TXLSWorkbook.Create;
try
Book.Open('quarterly-model.xls');
Book.Sheets[1].Cells[1, 1].Value := 5; // einen Input ändern
// Geladene Caches abhängiger Formeln werden durch eine
// Literal-Änderung NICHT invalidiert; ein schlichtes SaveAs
// behält also die alten Zahlen. Neuberechnung explizit anfordern:
Book.Recalculate;
if Book.TryGetCachedFormulaValue(1, 1, 2, Info) then
Writeln('B1 now ', VarToStr(Info.Value),
', state ordinal ', Ord(Info.State)); // xlfcsCalculated
Book.SaveAs('quarterly-model-updated.xls');
finally
Book.Free;
end;
end;
Was der Cache-first-Vertrag nicht für Sie tut
Cache-first-Speichern bewahrt, was geladen wurde; es verfolgt nicht, ob das Geladene noch stimmt. Ändern Sie ein Literal, von dem eine Formel abhängt, markiert das den Abhängigkeitsgraphen für den Evaluator als dirty, aber es lässt den xlfcsLoaded-Cache der abhängigen Zelle unangetastet, und der klassische Writer schreibt diesen veralteten Wert gern heraus, solange Sie nicht Recalculate aufrufen oder vorher die Value der Zelle lesen – was sie berechnet und den Zustand auf xlfcsCalculated setzt. Das ist derselbe Trade, den Excel im manuellen Berechnungsmodus macht, und für eine Pipeline, die Fremddateien öffnet, ein paar Labels ändert und speichert, ist er der richtige – aber eine Mappe, die Inputs ändert, muss ihren Neuberechnungsschritt explizit besitzen. Die RecalcBeforeSave-Policy des XLSX-Writers bleibt von dieser Arbeit unberührt und hat ihren eigenen manuellen Modus, der Caches im selben Geist bewahrt. Zwei kleinere Grenzen folgen daraus: Der Cache-first-Pfad hilft nur Zellen, deren Zustand xlfcsLoaded oder xlfcsCalculated ist; ein Generator, der Formeln schreibt und sie nie auswertet, zahlt beim Speichern weiterhin eine Auswertung pro Zelle, genau wie vorher. Und der Nested-Subtotal-Fix korrigiert, welche Zellen der Evaluator überspringt, nicht jede Funktion, die der Evaluator implementiert – eine Datei, deren Formeln HotXLS nicht identisch zu Excel berechnen kann, übersteht jetzt einen unveränderten Round Trip gefahrlos, aber ein absichtliches Recalculate auf dieser Datei liefert weiterhin die Antwort der Bibliothek statt der von Excel, und Sie sollten beide vergleichen, bevor Sie einer neu berechneten Speicherung vertrauen
Cache-first-klassische Speicherungen, die wiederhergestellten Shared- und Array-Formula-Wurzelcaches, die Verschiebung relativer Referenzen für Shared-Follower und die korrigierten SUBTOTAL- und AGGREGATE-Verschachtelungsregeln stecken alle im regulären HotXLS Delphi Spreadsheet Component für Delphi und C++Builder, ohne Abhängigkeit von Excel oder irgendeinem OLE-Automation-Server; die Produktseite führt die vollständige API-Referenz für die hier benutzten Workbook-, Cache-Reader- und Recalculation-Einstiegspunkte