Öffnet man eine alte xls-Datei und speichert sie erneut, zeigt die Add-in-Formel, die eine registrierte Analysebibliothek aufrief, plötzlich auf eine leere Referenz innerhalb der Arbeitsmappe selbst. HotXLS führt diese stille Beschädigung auf eine einzige falsche Annahme zurück: dass ein BIFF-SupBook-Record entweder self oder eine externe Datei sei. [MS-XLS] definiert sieben Arten, nicht zwei
Warum verliert eine gespeicherte Arbeitsmappe ihre Add-in-Links?
Weil der Klassifikationstest strukturell statt typisiert war. Der traditionelle Kurzweg liest einen SupBook-Record ($01AE), prüft, ob er den self-Marker trägt, und behandelt andernfalls die folgende Zeichenkette als Dokument-URL. Jeder Record, der von beidem nichts ist, fällt in einen Default-Zweig, und der Default-Zweig ist fast immer „das ist die Arbeitsmappe selbst“. Ein Add-in-Supporting-Link, ein Same-Sheet-Link, ein ungenutzter Slot und ein abgeschnittener Record tragen am Ende alle dasselbe falsche Etikett. Nichts wirft dabei eine Exception: Der Record wurde geparst, die Formel neu kompiliert, die Datei ohne Warnung gespeichert, und der Defekt taucht drei Wochen später auf, wenn jemand eine Spalte voller Nullen bemerkt, wo früher eine Währungsumrechnung stand. [MS-XLS] §2.4.271 beschreibt einen Record, der eine Selbstreferenz, eine Same-Sheet-Referenz, ein Add-in-Funktionscontainer, eine externe Arbeitsmappe mit virtuellem Pfad und Sheet-Name-Tabelle, ein DDE- oder OLE-Datenlink oder ein ungenutzter Platzhalter sein kann — plus ein siebter Zustand, der nicht in der Spezifikation steht, aber auf echten Datenträgern existiert: der Record, der sich nicht parsen lässt. Die Reparatur ist keine bessere Heuristik; es ist die Weigerung, überhaupt eine Heuristik zu haben
Die sieben Arten, die ein SupBook-Record tragen kann
HotXLS deklariert die Supporting-Link-Taxonomie als abgeschlossene Aufzählung in lxExternSheet.pas, und jede nachgelagerte Entscheidung schaltet darauf um. Neun Aufzählungswerte decken die sieben Kategorien ab, denn der DDE- und OLE-Fall braucht einen vorläufigen Zustand, bevor er aufgelöst werden kann:
type
TXLSSupportingLinkKind = (
slkUnknown, // Parsing fehlgeschlagen, oder Restbytes blieben übrig
slkSelf, // diese Arbeitsmappe
slkSameSheet, // U+0000-Marker
slkAddIn, // Add-in-Funktionscontainer
slkExternalWorkbook, // virtueller Pfad + Sheet-Name-Tabelle
slkDde, // aus ExternName-Flags aufgelöst
slkOle, // aus ExternName-Flags aufgelöst
slkDdeOrOle, // einer von beiden, noch unbekannt welcher
slkUnused); // Platzhalter aus einem einzigen Leerzeichen
TXLSFormulaReferenceClass = (
frcInternal,
frcExternalWorkbook,
frcExternalOther,
frcUnknownOrMalformed);
TXLSXtiInfo = record
XtiIndex : Integer; // nullbasiert, wie in ExternSheet.rgXTI gespeichert
ExternID : Integer; // einsbasiert, die interne Konvention
SupBookIndex: Integer;
Sheet1Index : Integer;
Sheet2Index : Integer;
LinkKind : TXLSSupportingLinkKind;
end;
Der Dispatch ist sentinel-getrieben, nicht string-getrieben. Ein Feldwert von $0401 markiert den self-Record. Eine Sheet-Anzahl von eins gepaart mit $3A01 markiert einen Add-in-Container. Nur ein Wert im Bereich 1 bis $00FF bedeutet, dass ein kodierter virtueller Pfad folgt, und nur dann dekodiert HotXLS überhaupt eine Zeichenkette. Alles außerhalb dieser drei Formen bleibt slkUnknown, und ein Record, dessen Sheet-Name-Tabelle den Record-Body nicht exakt konsumiert, wird zurück auf slkUnknown herabgestuft, selbst wenn der Kopf plausibel aussah
Warum dekodiert sich der Same-Sheet-Marker als leere Zeichenkette?
Weil der universelle BIFF-String-Reader das Byte zerstört, von dem die Klassifikation abhängt. Der Same-Sheet-Supporting-Link ist eine Zeichenkette mit einem Zeichen, dessen einziges Zeichen U+0000 ist, und TXLSBlob.GetBiffString gibt das als leeren WideString zurück, ununterscheidbar von einem wirklich leeren Pfad — was genau die Eingabe ist, auf die eine Selbstreferenz-Heuristik mit „self“ antwortet. HotXLS liest deshalb den rohen ersten Codepunkt aus dem Record-Body, statt dem dekodierten Wert zu vertrauen:
StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
StringOptions := Data.GetByte(StringOffset + 2);
if (StringOptions and $01) = 0 then
FirstChar := Data.GetByte(StringOffset + 3) // komprimiert, ein Byte
else
FirstChar := Data.GetWord(StringOffset + 3); // wide, zwei Bytes
end;
if FirstChar = 0 then
FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
FKind := slkDdeOrOle
else if FDocUrl <> '' then
FKind := slkExternalWorkbook;
Beachten Sie den komprimiert-gegen-wide-Zweig. Das Options-Byte sitzt an einem festen Offset vom String-Header, und der erste Codepunkt ist ein oder zwei Bytes, je nach Bit 0 — ihn bedingungslos als Byte zu lesen funktioniert bei den meisten Dateien und scheitert bei den von lokalisierten Builds geschriebenen, die schlechtestmögliche Verteilung für einen Bug. Der ungenutzte Platzhalter wird auf dieselbe Weise erwischt, an seinem wörtlichen Einzel-Leerzeichen-Payload, und der DDE- oder OLE-Fall am U+0003-Trenner, der im kodierten Pfad eingebettet ist
Warum lassen sich DDE und OLE zum SupBook-Zeitpunkt nicht trennen?
Weil der SupBook-Record die unterscheidenden Bits nicht trägt. Er sagt Ihnen, dass der Link einer von beiden ist; die fOle- und fOleLink-Flags, die entscheiden, welcher, leben im ExternName-Record ($0023), der später im Stream ankommt. HotXLS notiert slkDdeOrOle zum Parse-Zeitpunkt und verengt ihn in ParseExternalName, und wenn nie ein ExternName ankommt, bleibt die Art für immer vorläufig — was korrekt ist, denn die Datei sagt es schlicht nicht. Jeder nachgelagerte Konsument behandelt diesen vorläufigen Wert als echten Wert statt als fehlenden, sodass kein Aufrufer einen Tiebreak erfinden muss. Hier „vermutlich DDE“ zu raten würde eine aufgeräumtere Aufzählung kaufen und eine Klasse falscher Antworten, die niemand zurückverfolgen könnte:
if FKind = slkDdeOrOle then
begin
if Data.DataLength < 2 then
Exit;
Flags := Data.GetWord(0);
if (Flags and $0010) <> 0 then
FKind := slkOle
else if (Flags and $0008) <> 0 then
FKind := slkDde;
end;
XTI-Indizes sind auf der Platte nullbasiert und innen einsbasiert
HotXLS führt die Off-by-one-Konvertierung genau einmal aus, an der Stelle, an der ein Token in den internen Syntaxbaum eintritt, und nirgendwo sonst. PtgNameX.ixti ([MS-XLS] §2.5.198.85) ist ein nullbasierter Index in das rgXTI-Array des ExternSheet-Records ($0017, §2.4.106), während die interne ExternID-Konvention der Bibliothek einsbasiert ist mit null reserviert für „kein externes Sheet“. Der BIFF8-Lesepfad macht FExternID := wValue + 1, wenn er ein tNameX-Token dekodiert, und der Schreibpfad gibt StoreExternID - 1 aus, wobei die rohe Token-Sicht und die On-Disk-Semantik unangetastet bleiben. Sich hier zu vertun ist ungewöhnlich schwer zu erwischen: Externe definierte Namen lösen zum Nachbareintrag auf, und in einer Datei mit einem einzigen XTI-Eintrag wird aus Index 0 Index 1, verfehlt, und der Name degradiert stillschweigend. Eine Regression, die nur neu kompilierten Formeltext ausübt, sieht das nie, denn Rekompilierung berührt den Plattenindex überhaupt nicht — dieselbe Falle, die definierte Namen über Sheets und Arbeitsmappen hinweg zu einem Test gegen echte Bytestreams macht. Die Auflösung ist an beiden Enden begrenzt: TlxExternSheetSheet.TryResolveXti liefert False für einen negativen Index oder einen fehlenden Eintrag, TXLSSupBook.TryGetKind liefert False für einen SupBook-Index außerhalb des Arrays, und ClassifyXti mappt dann slkSelf und slkSameSheet auf frcInternal, slkExternalWorkbook auf frcExternalWorkbook sowie slkAddIn, slkDde, slkOle und slkDdeOrOle auf frcExternalOther. Alles andere, jeder Out-of-Range-Pfad eingeschlossen, landet auf frcUnknownOrMalformed
Eine Formel klassifizieren, bevor sie eingefroren wird
TXLSCompiledFormula.ClassifyReferences scannt den bewahrten BIFF-Token-Stream direkt, statt die Formel zu dekompilieren und nach eckigen Klammern zu suchen. Klammerjagd im Formeltext ist eine Textheuristik im Parsermantel: Sie matcht String-Literale, sie matcht strukturierte Referenzen, und sie verpasst externe definierte Namen vollständig, denn die tragen in dekompilierter Form keine Klammern. Der Token-Scan sieht nur PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d und PtgAreaErr3d an und fällt auf einen Syntaxbaum-Durchlauf zurück, wenn kein BIFF-Stream überlebt. Das Zusammenführen ist bewusst pessimistisch — die feste Priorität ist frcUnknownOrMalformed, dann frcExternalWorkbook, dann frcExternalOther, dann frcInternal — sodass ein einzelnes unlesbares Token die ganze Formel vergiftet. Bei einem externen definierten Namen wird auch der Namensindex validiert: einsbasiert, in Reichweite und durch einen zurückgehaltenen ExternName-Record gedeckt
var
Wb : TXLSWorkbook;
Sheet: TXLSWorksheet;
i : Integer;
begin
Wb := TXLSWorkbook.Create;
try
Wb.Open('quarterly.xls');
for i := 1 to Wb.Sheets.Count do // Sheets ist einsbasiert
begin
Sheet := Wb.Sheets[i];
// friert NUR Formeln ein, die als frcExternalWorkbook klassifiziert sind;
// interne, Add-in-, DDE/OLE- und malformed-Referenzen bleiben Formeln
Sheet.ConvertFormulasToValues(True);
end;
Wb.SaveAs('quarterly-detached.xls');
finally
Wb.Free;
end;
end;
Der OnlyExternal-Parameter ist die Stelle, an der sich die Taxonomie bezahlt macht. Eine Formel einzufrieren ist irreversibel, also muss die Operation beweisen, dass eine Referenz eine externe Arbeitsmappe ist, statt sie bloß zu vermuten. Add-in-Aufrufe überleben, DDE- und OLE-Links überleben, und alles, was der Parser nicht vollständig verstehen konnte, überlebt, denn der sichere Ausgang von Unsicherheit ist, nichts zu ändern. Dieselbe Disziplin regiert das Neubinden von zwischen Arbeitsmappen kopierten Formeln, wo eine falsch klassifizierte Referenz ins falsche Buch neu gebunden wird, statt laut zu scheitern
Records, die sich nicht parsen lassen, werden unangetastet zurückgeschrieben
HotXLS bewahrt das ursprüngliche SupBook-Payload und gibt es Byte für Byte erneut aus, wenn der Record nie bearbeitet wurde. Ein Parse-Fehler setzt slkUnknown und räumt den abgeleiteten Zustand auf, aber der erfasste Body bleibt in FRawData, und der Speicherpfad bevorzugt ihn vor jeder Rekonstruktion, solange das Element nicht dirty und nicht der self-Record ist. Die Alternative — einen ungeparsten Record in eine Selbstreferenz zu normalisieren, damit der Writer etwas Wohlgeformtes ausgeben hat — verwandelt einen Record, den man nicht verstanden hat, in einen Record, der definitiv falsch ist. Dieses Prinzip ist derselbe Vertrag, der über einen Lade-und-Speicher-Zyklus auf VBA-Projekte und ihre externen Referenzen angewandt wird, und es ist der Unterschied zwischen einer Bibliothek, die reale Dateien round-tript, und einer, die die Dateien round-tript, die ihre Testsuite zufällig enthält. Eine Arbeitsmappe, die durch fünfzehn Jahre Excel-Versionen, einen Report-Generator und zwei Migrationstools gegangen ist, wird Records enthalten, die niemand heute Lebender entworfen hat. Schreiben Sie sie so zurück, wie Sie sie vorgefunden haben
Die typisierte Klassifikation von SupBook- und XTI-Records kam in HotXLS 2.361.2 bis 2.361.4, zusammen mit der begrenzten XTI-Auflösung und dem hier beschriebenen sichereren ConvertFormulasToValues-Pfad. Wenn Sie Delphi- oder C++Builder-Code pflegen, der Legacy-xls-Dateien mit Add-in-Aufrufen, DDE- oder OLE-Links oder externen definierten Namen liest, behandelt die HotXLS Delphi spreadsheet component die ganze Taxonomie nativ, ohne Excel-Installation und ohne OLE-Automatisierung auf der Maschine, die die Arbeit macht