Technisch artikel

FontIndex slaat 4 over: BIFF8 rich text runs in HotXLS

HotXLS nummert elke BIFF8-fontverwijzing zoals [MS-XLS] §2.5.129 FontIndex die definieert: waarden 0 tot 3 zijn zero-based, waarden boven 4 zijn one-based, en 4 komt nooit voor, dus het vijfde FONT-record is ifnt 5 en de grootste geldige ifnt is gelijk aan het aantal FONT-records. Sinds HotXLS 2.384.4 volgen de XF-writer, de XF-reader, rich text-string-runs en de migratie van runs over werkmappen heen die regel allemaal, en 2.384.5 en 2.384.6 breiden haar uit naar commentaar- en tekstvak-runs, ook door kopieën en rij-inservingen heen

De regel oogt als een typefout totdat u haar tegenkomt. Iemand opent een werkmap met acht FONT-records, vindt een XF die naar font 8 wijst, en concludeert dat de writer een out-of-range-index heeft geproduceerd. Precies dat redeneerschip verscheen in HotXLS 2.384.1 als "fix", en het veranderde een correcte implementatie in er een waarin elke custom font in een door Excel geopend bestand één plek te vroeg belandde. Het interessante is niet de off-by-one zelf, het is hoeveel plekken in een BIFF8-library dezelfde conventie dragen, en hoe een fontbinding één opslag kan overleven en bij de tweede breekt. Als u al eerder hebt gestreden met de lengte- en encoding-fratsen uit het decoderen van BIFF8 XLUnicodeString cch en fHigh, dit is dezelfde bugfamilie: het bestand is in orde, de rekenkunde niet

Wat zegt de FontIndex-regel uit [MS-XLS] nu precies?

[MS-XLS] §2.5.129 zegt dat een FontIndex onder 4 een zero-based recordpositie is, een FontIndex boven 4 een one-based recordpositie, en dat de waarde 4 NIET gebruikt mag worden. Hetzelfde FontIndex-type wordt gebruikt door XF-records, SST-opmaakruns en TXO-opmaakruns, dus één verkeerd gelezen regel corrumpeert alle drie. Het bewijs is makkelijk te reproduceren met door Excel gemaakte bestanden: SOLVSAMP.XLS die bij Office zit heeft 19 FONT-records en een maximale XF-ifnt van 19, een werkmap met 43 records komt niet boven 43 uit, en een door Excel 16 opgeslagen bestand met 30 FONT-records wijst zijn Courier New-cellen naar ifnt 22, het 22ste record. Geen van allen bevat ooit een 4. Wilt u de toewijzing zelf analyseren in een diagnostisch hulpmiddel, de conversie is twee korte functies

// [MS-XLS] 2.5.129 FontIndex: 0..3 zero-based, > 4 one-based, 4 ongeldig
function FontIndexToRecordNo(Ifnt: Word): Integer;  // 1-based FONT-record
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 mag niet voorkomen
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
HotXLS FontIndex-toewijzing volgens MS-XLS 2.5.129 waarbij ifnt 0 tot 3 zero-based FONT-recordposities zijn, ifnt 5 en hoger one-based, en de waarde 4 nooit voorkomt, met de FontIndexToRecordNo-conversie en bewijs uit door Excel gemaakte werkmappen zoals SOLVSAMP.XLS
Het vijfde FONT-record is ifnt 5, niet 4 — een werkmap met 19 records komt niet boven ifnt 19 uit, en geen enkel door Excel gemaakt bestand slaat ooit de verboden waarde daartussen op

Binnen HotXLS woont dezelfde regel op twee gespiegelde plekken. TXLSFontList.GetSaveIndex neemt de one-based positie van een font in de verwezen lijst en verlaagt alleen posities 1 tot 4, dus positie 5 wordt weggeschreven als ifnt 5. TXLSReader.ParseXF doet bij het laden het omgekeerde: elke ifnt van 5 of hoger wordt verlaagd naar een zero-based fontlijst-plek, en alles daaronder blijft staan. De SST-rich-run-herafbakening en CountRichRunFontRefs passen dezelfde ifnt >= 5-conversie toe, en dat is het punt: één conventie, elke consument

// TXLSFontList.GetSaveIndex (kant van de writer)
Result := inherited GetSaveIndex(Index);   // 1-based verwezen positie
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 worden 0..3, 5+ onveranderd

// TXLSReader.ParseXF (kant van de reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 is fontlijst-plek 4

Waarom schoof een zero-based "fix" elke custom font één plek op?

De zero-based-herziening in HotXLS 2.384.1 verschoof elke custom font omdat hij een one-based index als zero-based las en daarna vier aanroepplekken aanpaste aan die verkeerde lezing: GetSaveIndex, ParseXF, de SST-run-herafbakening en de migratie van runs over werkmappen in Sheets.AddCopy. HotXLS-roundtrips zagen er nog prima uit, want writer en reader waren het met elkaar eens. Excel was het er niet mee eens. Een door 2.384.1 geschreven bestand zette de eerste custom font op ifnt 4, wat Excel als het standaardfont behandelt, en elk latere custom font één record te vroeg; het openen van een Excel-bestand ging de andere kant op en bond elk font één record te laat

HotXLS 2.384.1-regressie waarbij GetSaveIndex en ParseXF one-based FontIndex-waarden als zero-based lazen, de eerste custom font als het verboden ifnt 4 weggeschreven die Excel naar het standaardfont herleidt, en elk latere font één record te vroeg belandde terwijl roundtrips nog slaagden
Writer en reader waren het over dezelfde verkeerde lezing eens, dus een opslag-daarna-heropen-test bleef groen terwijl elk door Excel geopend font één plek ernaast belandde — als één conventie op zeven plekken leeft en u er vier wijzigt, verdenk dan eerst uw eigen wijziging

Het signaal dat de wijziging had moeten stoppen, stond in dezelfde codebase. CountRichRunFontRefs, de chart-FONTX- en FBI-herafbakening en de fontlijst van de style-engine werden nooit aangeraakt en gebruikten nog steeds skip-4, dus de library sprak zichzelf tegen zodra 2.384.1 binnenkwam, en alleen het toeval dat rich-text-fonts meestal ook door een of andere XF werden verwezen hield de tegenstelling verborgen. Als één conventie op zeven plekken opduikt en u wijzigt er vier, verdenk dan uw eigen wijziging vóórdat u de andere drie verdenkt. Versie 2.384.4 herstelde de spec-nummering op alle vier de plekken, en de oude regressietest, die ifnt < FontCount eiste en daarmee de verkeerde lezing inkodificeerde, werd vervangen door tests die elke weggeschreven ifnt via de spec-formule terug afbeelden op een FONT-recordnaam. Eén eerlijke beperking blijft: bestanden die door 2.384.1 tot en met 2.384.3 zijn opgeslagen met vijf of meer fonts dragen verschoven indexen die een reader niet van geldige data kan onderscheiden, dus het enige geneesmiddel is ze opnieuw te genereren

Waarom breken commentaar-fontruns pas bij de tweede opslag?

Commentaar- en tekstvak-runs braken bij de tweede opslag omdat HotXLS de eerste N-1 FONT-records onvoorwaardelijk hield en alleen de laatste liet vallen wanneer geen XF ernaar verwees, terwijl de TXO-opmaakruns ([MS-XLS] §2.4.329) byte voor byte werden teruggeschreven zonder hernummering. Door Excel gemaakte .xls-bestanden eindigen altijd met een niet-verwezen achterste font (een 9pt-DengXian op een systeem met Chinese locale), dus bij de eerste opslag was het font dat alleen door een commentaarrun werd gebruikt nooit het laatste, en er verschoof niets zichtbaar. Die eerste opslag liet het achterste font wel vallen en promoveerde het alleen-door-commentaar-gebruikte font naar de laatste positie. De tweede opslag gooide het toen weg als niet-verwezen, de ifnt van de run wees voorbij het einde, en Excel viel terug op het standaardfont; had de werkmap intussen een nieuw font gekregen, dan bond de run geruisloos aan dat font, wat in de test een opgemaakte tekstvak-run in Arial veranderde. Bestanden vol commentaar zoals die in het bouwen van een review-workflow met commentaar en hyperlinks zijn precies waar dit bijt, want die worden herhaaldelijk geopend, van aantekeningen voorzien en opgeslagen

HotXLS-fonttabel over twee opslagen waarbij het niet-verwezen achterste FONT-record dat Excel altijd schrijft eerst valt, het alleen-door-commentaar-gebruikte font laatste wordt en dan wordt weggegooid omdat TXO-opmaakruns zonder referentietelling werden teruggeschreven, tot CountRichRunFontRefs het overlevingsfilter in 2.384.5 verholpen
De eerste opslag oogde schoon omdat het achterste font de klap opving, en het commentaarfont verdween pas bij de tweede — blijf elke ifnt over opslagen heen afbeelden op een FONT-recordnaam in plaats van een in-memory test te vertrouwen

HotXLS 2.384.5 behandelt TXO-runs zoals SST-runs. CountRichRunFontRefs loopt nu elke TMSOShapeTextBox op elk werkblad af, zet de skip-4-ifnt van elke run om naar een plek en telt haar als referentie, dus een font dat alleen door runs wordt gebruikt overleeft het opslagfilter. De ontstane plek-naar-opslagindex-tabel gaat de FontRunRemap van elke tekening in, en TMSOShapeTextBox.Store herschrijft de run-indexen op een privé-kopie van de ruwe run-bytes, met de achterste TxOLastRun met rust omdat die geen font draagt. Voor applicatiecode is het contract eenvoudig: TXLSComment.TextRuns.FontIndex en TXLSTextBox.TextRuns.FontIndex gebruiken de bestandsnummering, 4 overgeslagen, precies zoals gelezen; run-indexen zijn one-based en CharIndex is de tekenoffset waar de run begint. Na een opslag kan het opgeslagen nummer afwijken van het door u gezette, maar het wijst nog steeds naar hetzelfde 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];   // bestandsnummering, 4 overgeslagen
      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;

Kopieën, rij-inservingen en migratie van runs over werkmappen

Sinds HotXLS 2.384.6 houdt elk kopieerpad van de klassieke engine de opmaakruns van commentaar vast, want Range.Copy, CopyRange, Sheets.AddCopy en de celverschuivingen achter Range.Insert en Range.Delete lopen allemaal door TXLSRange.CopyCell, en CopyCell kopieerde voorheen alleen de commentaartekst en de auteur. Een verschuiving is een kopie plus een leegmaakactie, dus het inservoegen van één rij boven een notitie met twee runs liet die achter met nul runs en één font. De fix kopieert elke run en verplaatst haar font via TXLSWorkbook.MigrateRunFontIndex, dat de skip-4-index naar een plek omzet, het font op waarde naar de doelfonttabel migreert en terugconverteert naar bestandsnummering; de SST-rich-text-migratie in Sheets.AddCopy roept nu dezelfde functie aan in plaats van haar eigen exemplaar van de rekenkunde mee te slepen. Er kwamen twee randgevallen mee: een in-place plakking waarbij bron en doel hetzelfde commentaar zijn mag zijn runs niet leegmaken vóórdat hij ze leest, en Sheets.AddCopy maakt nu een tweede ronde voor commentaar dat aan cellen zonder opgeslagen celrecord hangt, wat voorheen geheel werd overgeslagen. De fonttabelkant van kopiëren over werkmappen volgt dezelfde logica op waarde als de formulekant uit kopiëren over werkmappen en formule-herbinding. Op de XLSX-engine kloonden de kopieerpaden runs al op waarde; het gat zat in het commentaardeel zelf, waar de reader rFont, strike, u en vertAlign negeerde en de writer u of vertAlign nooit uitzond, dus de runs overleven opslag en heropenen nu symmetrisch

Hoe test u fontindexen in BIFF8-bestanden?

Test fontindexen door op te slaan en te heropenen, bij voorkeur over meer dan één generatie, en door elke ifnt terug af te beelden op een FONT-record in plaats van een numeriek bereik te eisen. Elke bug in dit verhaal haalde een in-memory test: de 2.384.1-regressie woonde in een op elkaar afgestemd writer-reader-paar, de TXO-drift had twee opslagen met een fonttabelwijziging ertussen nodig, en de verloren commentaarruns op XLSX kwamen pas na een heropenen boven water. Een nuttig harnas opent een door Excel gemaakt voorbeeldbestand, slaat het twee keer door HotXLS op, voegt tussen de opslagen door een font toe of verwijdert er een, en controleert dan de runposities plus, op byteniveau, de fontnamen achter elke ifnt. Vergelijk FontIndex-waarden niet vóór en na een opslag, want hernummering is legitiem

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // door Excel gemaakt, C2 heeft twee 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 schuift naar C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // heropen, vertrouw geheugen nooit
  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;

Leest en schrijft u klassieke XLS vanuit Delphi of C++Builder en wilt u liever niet bijhouden welke van de vele fontconsumenten van een library nog met [MS-XLS] §2.5.129 meeloopt, dan zit de skip-4-nummering, de run-hernummering bij opslag en de run-migratie op waarde die hier is beschreven ingebouwd in de HotXLS Delphi-spreadsheetcomponent, die XLS en XLSX leest en schrijft zonder Excel of OLE-automatisering