Technischer Artikel

BIFF8-Font-Index überspringt 4: Rich-Text-Runs mit HotXLS

HotXLS nummeriert jede BIFF8-Font-Referenz so, wie [MS-XLS] §2.5.129 FontIndex es definiert: Die Werte 0 bis 3 sind nullbasiert, Werte über 4 sind einsbasiert, und 4 kommt nie vor, der fünfte FONT-Record ist also ifnt 5, und das größte gültige ifnt entspricht der Anzahl der FONT-Records. Seit HotXLS 2.384.4 folgen der XF-Writer, der XF-Reader, Rich-Text-String-Runs und die Migration von Runs zwischen Arbeitsmappen dieser Regel, und 2.384.5 und 2.384.6 erstrecken sie auf Kommentar- und Textbox-Runs, auch über Kopien und Zeileneinfügungen hinweg

Die Regel wirkt wie ein Tippfehler, bis man auf sie stößt. Jemand öffnet eine Arbeitsmappe mit acht FONT-Records, findet einen XF, der auf Font 8 zeigt, und schließt, der Writer habe einen Index außerhalb des gültigen Bereichs produziert. Genau dieser Gedankengang wurde in HotXLS 2.384.1 als „Fix“ ausgeliefert, und er machte aus einer korrekten Implementierung eine, in der jeder Custom-Font in einer von Excel geöffneten Datei einen Slot zu früh landete. Der interessante Teil ist nicht das Off-by-one selbst, sondern wie viele Stellen in einer BIFF8-Bibliothek dieselbe Konvention tragen und wie eine Font-Bindung ein Speichern überleben und beim zweiten brechen kann. Wenn Sie bereits mit den Längen- und Encoding-Eigenheiten gekämpft haben, die in Decodieren von BIFF8 XLUnicodeString cch und fHigh behandelt werden – das ist dieselbe Bugfamilie: Die Datei ist in Ordnung, die Arithmetik ist es nicht

Was besagt die [MS-XLS]-FontIndex-Regel wirklich?

[MS-XLS] §2.5.129 besagt: Ein FontIndex unter 4 ist eine nullbasierte Record-Position, ein FontIndex über 4 ist eine einsbasierte Record-Position, und der Wert 4 DARF NICHT verwendet werden. Denselben FontIndex-Typ nutzen XF-Records, SST-Formatierungs-Runs und TXO-Formatierungs-Runs – eine missverstandene Regel verdirbt also alle drei. Der Beleg lässt sich mit von Excel erzeugten Dateien leicht nachstellen: Das bei Office mitgelieferte SOLVSAMP.XLS hat 19 FONT-Records und ein maximales XF-ifnt von 19, eine Arbeitsmappe mit 43 Records endet bei 43, und eine von Excel 16 gespeicherte Datei mit 30 FONT-Records zeigt ihre Courier-New-Zellen auf ifnt 22, den 22. Record. Keine einzige enthält je eine 4. Wenn Sie die Zuordnung in einem Diagnose-Tool selbst analysieren müssen, besteht die Umrechnung aus zwei kurzen Funktionen

// [MS-XLS] 2.5.129 FontIndex: 0..3 nullbasiert, > 4 einsbasiert, 4 ungültig
function FontIndexToRecordNo(Ifnt: Word): Integer;  // 1-basierter FONT-Record
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 darf nicht vorkommen
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
HotXLS-FontIndex-Zuordnung nach MS-XLS 2.5.129, wo ifnt 0 bis 3 nullbasierte FONT-Record-Positionen sind, ifnt 5 aufwärts einsbasiert und der Wert 4 nie vorkommt, mit der FontIndexToRecordNo-Umrechnung und Belegen aus von Excel erzeugten Arbeitsmappen wie SOLVSAMP.XLS
Der fünfte FONT-Record ist ifnt 5, nicht 4 – eine Arbeitsmappe mit 19 Records endet bei ifnt 19, und keine von Excel erzeugte Datei speichert je den verbotenen Wert dazwischen

Innerhalb von HotXLS steckt dieselbe Regel an zwei gespiegelten Stellen. TXLSFontList.GetSaveIndex nimmt die 1-basierte Position eines Fonts in der verwiesenen Liste und dekrementiert nur die Positionen 1 bis 4, sodass Position 5 als ifnt 5 geschrieben wird. TXLSReader.ParseXF macht beim Laden das Gegenteil: Jedes ifnt ab 5 wird auf einen nullbasierten Font-Listen-Slot dekrementiert, alles darunter bleibt, wie es ist. Das SST-Rich-Run-Remapping und CountRichRunFontRefs wenden dieselbe ifnt >= 5-Umrechnung an – das ist der Punkt: eine Konvention, jeder Konsument

// TXLSFontList.GetSaveIndex (Writer-Seite)
Result := inherited GetSaveIndex(Index);   // 1-basierte verwiesene Position
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 werden 0..3, 5+ unverändert

// TXLSReader.ParseXF (Reader-Seite)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 ist Font-Listen-Slot 4

Warum verschob ein nullbasierter „Fix“ jeden Custom-Font um eins?

Die nullbasierte Neufassung in HotXLS 2.384.1 verschob jeden Custom-Font, weil sie einen einsbasierten Index als nullbasierten las und dann vier Aufrufstellen an diese Fehllesung anpasste: GetSaveIndex, ParseXF, das SST-Run-Remapping und die Migration von Runs zwischen Arbeitsmappen in Sheets.AddCopy. HotXLS-Roundtrips sahen weiterhin sauber aus, weil Writer und Reader sich einig waren. Excel war es nicht. Eine von 2.384.1 geschriebene Datei setzte den ersten Custom-Font auf ifnt 4, was Excel als Standardfont behandelt, und jeden späteren Custom-Font einen Record zu früh; beim Öffnen einer Excel-Datei lief es umgekehrt und band jeden Font einen Record zu spät

HotXLS-2.384.1-Regression, bei der GetSaveIndex und ParseXF einsbasierte FontIndex-Werte als nullbasiert lasen, den ersten Custom-Font als verbotenes ifnt 4 schrieben, das Excel zum Standardfont auflöst, und jeden späteren Font einen Record zu früh landeten, während Roundtrips weiterhin bestanden
Writer und Reader teilten dieselbe Fehllesung, also blieb ein Speichern-und-wieder-öffnen-Test grün, während jeder von Excel geöffnete Font einen Slot daneben landete – wenn eine Konvention an sieben Stellen wohnt und Sie vier ändern, verdächtigen Sie zuerst Ihre Änderung

Der Hinweis, der die Änderung hätte stoppen müssen, lag im selben Codebestand. CountRichRunFontRefs, das Chart-FONTX- und FBI-Remapping und die Font-Liste der Style-Engine wurden nie angefasst und nutzten weiterhin Skip-4, also widersprach sich die Bibliothek in dem Moment, in dem 2.384.1 landete, und nur der Zufall, dass Rich-Text-Fonts meist auch von irgendeinem XF referenziert wurden, hielt den Widerspruch versteckt. Wenn eine Konvention an sieben Stellen auftaucht und Sie ändern vier, verdächtigen Sie Ihre Änderung, bevor Sie die anderen drei verdächtigen. Version 2.384.4 stellte die Spezifikationsnummerierung an allen vier Stellen wieder her, und der alte Regressionstest, der ifnt < FontCount behauptete und damit die Fehllesung festschrieb, wurde durch Tests ersetzt, die jedes geschriebene ifnt über die Spezifikationsformel zurück auf einen FONT-Record-Namen abbilden. Eine ehrliche Einschränkung bleibt: Dateien, die 2.384.1 bis 2.384.3 mit fünf oder mehr Fonts gespeichert haben, tragen verschobene Indizes, die ein Reader nicht von gültigen Daten unterscheiden kann – das einzige Heilmittel ist, sie neu zu erzeugen

Warum brechen Kommentar-Font-Runs erst beim zweiten Speichern?

Kommentar- und Textbox-Runs brachen beim zweiten Speichern, weil HotXLS die ersten N-1 FONT-Records bedingungslos behielt und nur den letzten fallen ließ, wenn kein XF auf ihn verwies, während die TXO-Formatierungs-Runs ([MS-XLS] §2.4.329) byteweise ohne Neunummerierung zurückgeschrieben wurden. Von Excel erzeugte .xls-Dateien enden immer mit einem unreferenzierten hinteren Font (einem 9-pt-DengXian auf einem System mit chinesischem Locale), also war der Font, den nur ein Kommentar-Run nutzte, beim ersten Speichern nie der letzte, und nichts bewegte sich sichtbar. Dieses erste Speichern ließ den hinteren Font fallen und beförderte den nur vom Kommentar genutzten Font auf die letzte Position. Das zweite Speichern warf ihn dann als unreferenziert weg, das ifnt des Runs zeigte hinter das Ende, und Excel fiel auf den Standardfont zurück; hatte die Arbeitsmappe zwischendurch einen neuen Font bekommen, band sich der Run stattdessen stumm an diesen – im Test wurde so ein formatierter Textbox-Run zu Arial. Kommentarlastige Dateien wie die in Aufbau eines Kommentar- und Hyperlink-Review-Workflows beschriebenen sind genau der Ort, an dem das beißt, denn sie werden wiederholt geöffnet, annotiert und gespeichert

HotXLS-Font-Tabelle über zwei Speicherungen, wo der unreferenzierte hintere FONT-Record, den Excel immer schreibt, zuerst fällt, der nur vom Kommentar genutzte Font letzter wird und dann verworfen wird, weil TXO-Formatierungs-Runs ohne Referenzzählung zurückgeschrieben wurden, bis CountRichRunFontRefs den Überlebensfilter in 2.384.5 reparierte
Die erste Speicherung sah sauber aus, weil der hintere Font den Verlust trug, und der Kommentar-Font verschwand erst beim zweiten – bilden Sie jedes ifnt über die Speicherungen hinweg auf einen FONT-Record-Namen ab, statt einem In-Memory-Test zu vertrauen

HotXLS 2.384.5 behandelt TXO-Runs wie SST-Runs. CountRichRunFontRefs geht jetzt jede TMSOShapeTextBox auf jedem Worksheet durch, rechnet das Skip-4-ifnt jedes Runs in einen Slot um und zählt es als Referenz, sodass ein nur von Runs genutzter Font den Speicherfilter überlebt. Die entstehende Slot-zu-Save-Index-Tabelle wandert in das FontRunRemap jeder Zeichnung, und TMSOShapeTextBox.Store schreibt die Run-Indizes auf einer privaten Kopie der rohen Run-Bytes um, lässt den hinteren TxOLastRun aber in Ruhe, weil er keinen Font trägt. Für Anwendungscode ist der Vertrag simpel: TXLSComment.TextRuns.FontIndex und TXLSTextBox.TextRuns.FontIndex nutzen die Datei-Nummerierung mit übersprungener 4, exakt wie gelesen; Run-Indizes sind 1-basiert, und CharIndex ist der Zeichen-Offset, an dem der Run beginnt. Nach dem Speichern darf die gespeicherte Zahl von der von Ihnen gesetzten abweichen, aber sie zeigt weiterhin auf denselben Font

var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  I: Integer;
  Ifnt: Word;
begin
  Book := TXLSWorkbook.Create;
  if Book.Open('review-notes.xls') <> 1 then
    raise Exception.Create('Cannot open review-notes.xls');
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  if Note <> nil then
    for I := 1 to Note.TextRuns.Count do
    begin
      Ifnt := Note.TextRuns.FontIndex[I];   // Datei-Nummerierung, 4 übersprungen
      if Ifnt = 4 then
        raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
      Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
        [I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
    end;
end;

Kopien, Zeileneinfügungen und die Migration von Runs zwischen Arbeitsmappen

Seit HotXLS 2.384.6 behält jeder Kopierpfad der klassischen Engine Kommentar-Formatierungs-Runs, denn Range.Copy, CopyRange, Sheets.AddCopy und die Zellverschiebungen hinter Range.Insert und Range.Delete laufen alle durch TXLSRange.CopyCell, und CopyCell kopierte früher nur den Kommentartext und den Autor. Eine Verschiebung ist ein Kopieren plus ein Leeren, also ließ das Einfügen einer einzelnen Zeile über einem Zwei-Run-Notizfeld diese mit null Runs und einem Font zurück. Der Fix kopiert jeden Run und bewegt seinen Font durch TXLSWorkbook.MigrateRunFontIndex, das den Skip-4-Index in einen Slot umrechnet, den Font by Value in die Ziel-Font-Tabelle migriert und zurück in die Datei-Nummerierung wandelt; die SST-Rich-Text-Migration in Sheets.AddCopy ruft jetzt dieselbe Funktion auf, statt eine eigene Kopie der Arithmetik mitzuschleppen. Zwei Randfälle kamen mit: Ein direktes Einfügen, bei dem Quelle und Ziel derselbe Kommentar sind, darf seine Runs nicht leeren, bevor es sie liest, und Sheets.AddCopy macht jetzt einen zweiten Durchlauf für Kommentare an Zellen ohne gespeicherten Cell-Record, die es zuvor komplett übersprang. Die Font-Tabellen-Seite des Kopierens zwischen Arbeitsmappen folgt derselben By-Value-Logik wie die in Kopieren zwischen Arbeitsmappen und Formula-Rebinding behandelte Formelseite. Auf der XLSX-Engine klonten die Kopierpfade Runs bereits by Value; die Lücke steckte im Kommentarteil selbst, wo der Reader rFont, strike, u und vertAlign ignorierte und der Writer nie u oder vertAlign ausgab, sodass die Runs jetzt Speichern und erneutes Öffnen symmetrisch überstehen

Wie testen Sie Font-Indizes in BIFF8-Dateien?

Testen Sie Font-Indizes, indem Sie speichern und wieder öffnen, idealerweise über mehr als eine Generation, und indem Sie jedes ifnt auf einen FONT-Record zurückabbilden, statt einen numerischen Bereich zu behaupten. Jeder Bug in dieser Geschichte hat einen In-Memory-Test passiert: Die 2.384.1-Regression lebte in einem aufeinander abgestimmten Writer-Reader-Paar, der TXO-Drift brauchte zwei Speicherungen mit einer Font-Tabellen-Änderung dazwischen, und die verlorenen Kommentar-Runs auf XLSX zeigten sich erst nach einem erneuten Öffnen. Ein nützliches Harness öffnet eine von Excel erzeugte Beispieldatei, speichert sie zweimal durch HotXLS, fügt zwischen den Speicherungen einen Font hinzu oder entfernt einen und prüft dann die Run-Positionen plus, auf Byte-Ebene, die Font-Namen hinter jedem ifnt. Vergleichen Sie FontIndex-Werte vor und nach dem Speichern nicht miteinander, denn Neunummerierung ist legitim

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // von Excel erzeugt, C2 hat zwei Runs
  Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
  RunCount := Note.TextRuns.Count;
  SecondRunAt := Note.TextRuns.CharIndex[2];

  Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
  Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown);   // C2 wandert nach C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // neu öffnen, dem Speicher nie vertrauen
  Assert(Book.Open(OutFile) = 1);
  Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
  Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
  Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
  Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;

Wer klassisches XLS aus Delphi oder C++Builder liest und schreibt und nicht gerne nachverfolgt, welcher der vielen Font-Konsumenten einer Bibliothek noch mit [MS-XLS] §2.5.129 übereinstimmt: Die hier beschriebene Skip-4-Nummerierung, die Run-Neunummerierung beim Speichern und die By-Value-Run-Migration sind in die HotXLS Delphi spreadsheet component eingebaut, die XLS und XLSX ohne Excel oder OLE-Automation liest und schreibt