Teknisk artikel

BIFF8 Font Index springer 4 over: rich text-runs i HotXLS

HotXLS nummererer hver BIFF8-fontreference, som [MS-XLS] §2.5.129 FontIndex definerer den: værdierne 0 til 3 er nul-baserede, værdier over 4 er én-baserede, og 4 optræder aldrig, så den femte FONT-record er ifnt 5, og den største gyldige ifnt svarer til antallet af FONT-records. Siden HotXLS 2.384.4 følger XF-writeren, XF-readeren, rich text-strenge-runs og cross-workbook-run-migration alle den regel, og 2.384.5 og 2.384.6 udvider den til comment- og textbox-runs, også gennem kopier og rækkeindsættelser

Reglen ligner en tastefejl, til man rammer den. Nogen åbner en arbejdsbog med otte FONT-records, finder en XF, der peger på font 8, og konkluderer, at writeren har produceret et indeks uden for intervallet. Netop den ræsonnement blev skibet i HotXLS 2.384.1 som en "fix", og den gjorde en korrekt implementering til én, hvor hver selvvalgt font i en Excel-åbnet fil landede én plads for tidligt. Det interessante er ikke off-by-one'en i sig selv, det er, hvor mange steder i et BIFF8-bibliotek, der bærer samme konvention, og hvordan en fontbinding kan overleve én gemning og brække sig på den anden. Har du allerede kæmpet med længde- og encode-quirkene fra decoding BIFF8 XLUnicodeString cch og fHigh, er dette samme bugfamilie: filen er fin, aritmetikken er det ikke

Hvad siger [MS-XLS] FontIndex-reglen egentlig?

[MS-XLS] §2.5.129 siger, at et FontIndex under 4 er en nul-baseret recordposition, et FontIndex over 4 er en én-baseret recordposition, og at værdien 4 MÅ IKKE bruges. Samme FontIndex-type bruges af XF-records, SST-formaterruns og TXO-formaterruns, så én mislæst regel korrupterer alle tre. Beviset er let at reproducere med Excel-skrevne filer: SOLVSAMP.XLS, der fulgte med Office, har 19 FONT-records og en maksimal XF-ifnt på 19, en arbejdsbog med 43 records topper på 43, og en fil gemt af Excel 16 med 30 FONT-records peger sine Courier New-celler på ifnt 22, den 22. record. Ingen af dem indeholder nogensinde en 4. Skal du selv analysere mappingen i et diagnostikværktøj, er konverteringen to korte funktioner

// [MS-XLS] 2.5.129 FontIndex: 0..3 nul-baseret, > 4 én-baseret, 4 ugyldig
function FontIndexToRecordNo(Ifnt: Word): Integer;  // 1-baseret FONT-record
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 må ikke optræde
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
HotXLS FontIndex-mapping efter MS-XLS 2.5.129, hvor ifnt 0 til 3 er nul-baserede FONT-recordpositioner, ifnt 5 og op er én-baserede, og værdien 4 aldrig optræder, med FontIndexToRecordNo-konverteringen og beviser fra Excel-skrevne arbejdsbøger som SOLVSAMP.XLS
Den femte FONT-record er ifnt 5, ikke 4 — en arbejdsbog med 19 records topper på ifnt 19, og ingen Excel-skrevet fil gemmer nogensinde den forbudte værdi imellem

Inde i HotXLS bor samme regel to spejlede steder. TXLSFontList.GetSaveIndex tager den 1-baserede position af en font i den refererede liste og dekrementerer kun positionerne 1 til 4, så position 5 skrives som ifnt 5. TXLSReader.ParseXF gør det omvendte ved indlæsning: enhver ifnt på 5 eller derover dekrementeres til en nul-baseret fontlisteplads, og alt herunder bliver stående. SST rich-run-remappet og CountRichRunFontRefs anvender samme ifnt >= 5-konvertering, og det er hele pointen: én konvention, alle forbrugere

// TXLSFontList.GetSaveIndex (writer-siden)
Result := inherited GetSaveIndex(Index);   // 1-baseret refereret position
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 bliver 0..3, 5+ uændret

// TXLSReader.ParseXF (reader-siden)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 er font-liste-plads 4

Hvorfor flyttede en nul-baseret "fix" hver selvvalgt font med én?

Den nul-baserede omskrivning i HotXLS 2.384.1 flyttede hver selvvalgt font, fordi den læste et én-baseret indeks som et nul-baseret og derefter ændrede fire kaldsteder til at passe til den mislæsning: GetSaveIndex, ParseXF, SST-run-remappet og cross-workbook-run-migrationen i Sheets.AddCopy. HotXLS' egne round trips så stadig fine ud, fordi writer og reader var enige med hinanden. Excel var ikke enig. En fil skrevet af 2.384.1 lagde den første selvvalgte font på ifnt 4, som Excel behandler som default-fonten, og alle senere selvvalgte fonte én record for tidligt; åbning af en Excel-fil gik den anden vej og bundt hver font én record for sent

HotXLS 2.384.1-regression, hvor GetSaveIndex og ParseXF læser én-baserede FontIndex-værdier som nul-baserede, skriver den første selvvalgte font som den forbudte ifnt 4, som Excel opløser til default-fonten, og lander alle senere fonte én record for tidligt, mens round trips stadig bestod
Writer og reader var enige om samme mislæsning, så en gem-og-genåbn-test forblev grøn, mens hver Excel-åbnet font landede én plads ved siden af — når én konvention bor syv steder, og du ændrer fire, mistænk din ændring først

Det tell, der burde have stoppet ændringen, lå i samme kodebase. CountRichRunFontRefs, chart FONTX- og FBI-remappet og style-motorens fontliste blev aldrig rørt og brugte stadig skip-4, så biblioteket motsagde sig selv i det øjeblik, 2.384.1 landede, og kun det tilfælde, at rich text-fonte normalt også blev refereret af en eller anden XF, holdt modsætningen skjult. Når én konvention optræder syv steder, og du ændrer fire, så mistænk din egen ændring, før du mistænker de tre andre. Version 2.384.4 genskabte spec-nummereringen på alle fire steder, og den gamle regressionstest, som assertede ifnt < FontCount og dermed indkodede mislæsningen, blev udskiftet med tests, der mapper hver skrevne ifnt tilbage til et FONT-record-navn gennem spec-formlen. Én ærlig begrænsning står tilbage: filer gemt af 2.384.1 til 2.384.3 med fem eller flere fonte bærer forskudte indekser, som en reader ikke kan skælne fra gyldige data, så den eneste kur er at generere dem forfra

Hvorfor knækker comment-fontruns først ved den anden gemning?

Comment- og textbox-runs knækkede ved den anden gemning, fordi HotXLS beholdt de første N-1 FONT-records betingelsesløst og kasserede kun den sidste, når ingen XF refererede den, mens TXO-formaterrunerne ([MS-XLS] §2.4.329) blev skrevet tilbage byte for byte uden renummerering. Excel-skrevne .xls-filer slutter altid med en urefereret afsluttende font (en 9pt DengXian på et kinesisk-lokalet system), så ved den første gemning var fonten, som kun et comment-run brugte, aldrig den sidste, og intet flyttede sig synligt. Men den første gemning kasserede afslutningsfonten og forfremmede comment-only-fonten til sidste position. Ved den anden gemning blev den så kasseret som urefereret, run'ets ifnt pegede forbi enden, og Excel faldt tilbage til default-fonten; havde arbejdsbogen fået en ny font imellem, bundt runet i stedet lydløst til den, hvilket i testene forvandlede et stylet textbox-run til Arial. Comment-tunge filer som dem, der beskrives i building a comments and hyperlinks review workflow, er netop dér, det bider, for de bliver åbnet, annoteret og gemt igen og igen

HotXLS fonttabel over to gemninger, hvor den urefererede afsluttende FONT-record, som Excel altid skriver, kasseres først, comment-only-fonten bliver sidst og derefter kasseres, fordi TXO-formaterruns blev skrevet tilbage uden referencetælling, indtil CountRichRunFontRefs rettede overlevelsesfilteret i 2.384.5
Den første gemning så ren ud, fordi afslutningsfonten tog tabet, og comment-fonten forsvandt først ved den anden — map hver ifnt til et FONT-record-navn på tværs af gemninger i stedet for at stole på en in-memory-test

HotXLS 2.384.5 behandler TXO-runs som SST-runs. CountRichRunFontRefs går nu igennem hver TMSOShapeTextBox på hvert regneark, konverterer hvert runs skip-4-ifnt til en plads og tæller den som en reference, så en run-only-font overlever gemningsfilteret. Den fremkomne plads-til-save-indeks-tabel går ind i hver drawings FontRunRemap, og TMSOShapeTextBox.Store omskriver run-indekserne på en privat kopi af de rå run-bytes og lader den afsluttende TxOLastRun være, for den bærer ingen font. For applikationskoden er kontrakten simpel: TXLSComment.TextRuns.FontIndex og TXLSTextBox.TextRuns.FontIndex bruger filnummereringen, 4 sprunget over, præcis som læst; run-indekser er 1-baserede, og CharIndex er det tegnoffset, hvor runet starter. Efter en gemning kan det gemte tal afvige fra det, du satte, men det peger stadig på samme 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];   // filnummerering, 4 sprunget over
      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;

Kopier, rækkeindsættelser og cross-workbook-run-migration

Siden HotXLS 2.384.6 beholder hver copy-sti på classic-motoren comment-formaterruns, fordi Range.Copy, CopyRange, Sheets.AddCopy og de celleforskydninger, der ligger bag Range.Insert og Range.Delete, alle går gennem TXLSRange.CopyCell, og CopyCell kopierede tidligere kun comment-teksten og forfatteren. En forskydning er en kopi plus en rydning, så indsættelse af én række over et notat med to runs efterlod det med nul runs og én font. Fixet kopierer hvert run og flytter dets font gennem TXLSWorkbook.MigrateRunFontIndex, som konverterer skip-4-indekset til en plads, migrerer fonten by value ind i destinationsfonttabellen og konverterer tilbage til filnummerering; SST rich text-migrationen i Sheets.AddCopy kalder nu samme funktion i stedet for at bære sin egen kopi af aritmetikken. To edge cases fulgte med: en in-place-indætning, hvor kilde og destination er samme comment, må ikke rydde dens runs, før de er læst, og Sheets.AddCopy laver nu et ekstra gennemløb for comments hæftet til celler uden gemt cellerecord, som den tidligere sprang helt over. Fonttabel-siden af cross-workbook-kopiering følger samme by value-logik som formulasiden, der er dækket i cross-workbook copy og formula rebinding. På XLSX-motoren klonede copy-stierne allerede runs by value; hullet sad i comment-delen selv, hvor readeren ignorerede rFont, strike, u og vertAlign, og writeren aldrig udsendte u eller vertAlign, så runene overlever nu gemning og genåbning symmetrisk

Hvordan bør du teste fontindekser i BIFF8-filer?

Test fontindekser ved at gemme og genåbne, ideelt på tværs af mere end én generation, og ved at mappe hver ifnt tilbage til en FONT-record frem for at asserte et numerisk interval. Hver bug i denne historie bestod en in-memory-test: 2.384.1-regressionen bo i et matchet writer- og reader-par, TXO-driften krævede to gemninger med en fonttabelændring imellem, og de tabte comment-runs på XLSX viste sig først efter en genåbning. Et nyttigt harness åbner en Excel-skrevet sample, gemmer den to gange gennem HotXLS, tilføjer eller fjerner en font mellem gemningerne og tjekker derefter run-positioner plus, på byteniveau, fontnavnene bag hver ifnt. Sammenlign ikke FontIndex-værdier før og efter en gemning, for renummerering er 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);          // Excel-skrevet, C2 har to 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 flyttes til C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // genåbn, stol aldrig på hukommelsen
  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;

Læser og skriver du classic XLS fra Delphi eller C++Builder, og vil du helst ikke holde styr på, hvilke af bibliotekets mange fontforbrugere der stadig er enige med [MS-XLS] §2.5.129, så er skip-4-nummereringen, run-renummerering ved gemning og by value-run-migrationen beskrevet her indbygget i HotXLS Delphi spreadsheet component, som læser og skriver XLS og XLSX uden Excel eller OLE automation