Technischer Artikel

BIFF SupBook und XTI in Delphi: externe Links einordnen

Ö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

Die sentinel-getriebene Leiter, mit der HotXLS einen BIFF-SupBook-Record in sieben Arten klassifiziert, eine Zeichenkette nur für Werte im kodierten-Pfad-Bereich dekodiert und auf eine unbekannte Art zurückfällt statt auf einen Default-Zweig
Jede Art wird über einen Sentinel erreicht statt über einen String-Test, und ein Record, der keiner der Formen entspricht, bleibt unbekannt, statt in einen Default-Zweig zu fallen, der diese Arbeitsmappe bedeutet

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 HotXLS den rohen ersten Codepunkt aus einem BIFF-SupBook-Record-Body liest statt der dekodierten Zeichenkette, weil der universelle String-Reader den Same-Sheet-U+0000-Marker in einen leeren Wert verwandelt
Der Same-Sheet-Marker ist eine Zeichenkette mit einem Zeichen, das U+0000 ist, also faltet der universelle String-Reader ihn in einen leeren Wert, und nur der rohe Codepunkt am Options-Byte-Offset bewahrt ihn

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

HotXLS wandelt den nullbasierten XTI-Index eines BIFF-PtgNameX-Tokens an einem einzigen Punkt in seine einsbasierte interne ExternID um, mit begrenzter Auflösung an beiden Enden und der Klassifikations-Map, die ihn konsumiert
Das Off-by-one zwischen dem nullbasierten Plattenindex und der einsbasierten internen ExternID wird einmal angewandt, wenn ein Token in den Syntaxbaum eintritt, und jeder nicht auflösbare Index landet auf der malformed-Klasse

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