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