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;
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
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 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