Tehnički članak

BIFF8 font indeks preskače 4: rich text runovi u HotXLS-u

HotXLS svaku BIFF8 referencu na font broji onako kako je definiše [MS-XLS] §2.5.129 FontIndex: vrednosti od 0 do 3 su nula-bazirane, vrednosti iznad 4 su jedno-bazirane, a 4 se nikada ne pojavljuje, pa je peti FONT zapis ifnt 5 i najveća validna ifnt vrednost jednaka je broju FONT zapisa. Od HotXLS 2.384.4 tom pravilu slede XF pisac, XF čitač, rich text string runovi i migracija runova između radnih sveski, a 2.384.5 i 2.384.6 prostiru ga i na runove komentara i text boxova, uključujući i kopiranje i ubacivanje redova

Pravilo deluje kao greška u kucanju dok ne naletite na nju. Neko otvori radnu svesku sa osam FONT zapisa, pronađe XF koji pokazuje na font 8 i zaključi da je pisac proizveo indeks van opsega. Baš je to razmišljanje ušlo u HotXLS 2.384.1 kao „popravka", i pretvorilo je ispravnu implementaciju u onu u kojoj svaki prilagođeni font u fajlu koji otvori Excel dospeva jedno mesto ranije. Zanimljivo ovde nije sam off-by-one, nego koliko mesta u BIFF8 biblioteci nosi istu konvenciju i kako vezivanje fonta može preživeti jedno čuvanje a puknuti na drugom. Ako ste se već borili sa čudima dužine i enkodovanja iz teksta o dekodovanju BIFF8 XLUnicodeString cch i fHigh, ovo je ista porodica bugova: fajl je u redu, aritmetika nije

Šta pravilo [MS-XLS] FontIndex zapravo kaže?

[MS-XLS] §2.5.129 kaže da je FontIndex ispod 4 nula-bazirana pozicija zapisa, FontIndex iznad 4 jedno-bazirana pozicija zapisa, a vrednost 4 se NE SME koristiti. Isti FontIndex tip koriste XF zapisi, SST formatting runovi i TXO formatting runovi, pa jedno pogrešno pročitano pravilo pokvari sva tri. Dokaz je lako reprodukovati na fajlovima koje je pisao Excel: SOLVSAMP.XLS koji dolazi uz Office ima 19 FONT zapisa i maksimalan XF ifnt od 19, radna sveska sa 43 zapisa staje na 43, a fajl sačuvan u Excelu 16 sa 30 FONT zapisa usmerava ćelije sa Courier New na ifnt 22, dvadeset drugi zapis. Nijedan nikada ne sadrži 4. Ako preslikavanje morate analizirati sami u dijagnostičkom alatu, konverzija su dve kratke funkcije

// [MS-XLS] 2.5.129 FontIndex: 0..3 nula-bazirano, > 4 jedno-bazirano, 4 nevalidno
function FontIndexToRecordNo(Ifnt: Word): Integer;  // FONT zapis od 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 ne sme da se pojavi
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
HotXLS FontIndex preslikavanje po MS-XLS 2.5.129 gde ifnt 0 do 3 jesu nula-bazirane pozicije FONT zapisa, ifnt 5 i više jesu jedno-bazirane a vrednost 4 se nikada ne pojavljuje, uz FontIndexToRecordNo konverziju i dokaze iz radnih sveski koje je pisao Excel poput SOLVSAMP.XLS
Peti FONT zapis je ifnt 5, a ne 4 — radna sveska sa 19 zapisa staje na ifnt 19, i nijedan fajl koji je pisao Excel nikada ne upisuje zabranjenu vrednost između

Unutar HotXLS-a isto pravilo živi na dva ogledaljena mesta. TXLSFontList.GetSaveIndex uzima jedno-baziranu poziciju fonta u referenciranoj listi i umanjuje samo pozicije od 1 do 4, pa se pozicija 5 upisuje kao ifnt 5. TXLSReader.ParseXF radi obrnuto pri učitavanju: svaki ifnt od 5 naviše umanjuje se na nula-baziran slot font liste, a sve ispod ostaje na mestu. SST rich-run remap i CountRichRunFontRefs primenjuju istu ifnt >= 5 konverziju, i u tome je poenta: jedna konvencija, svaki potrošač

// TXLSFontList.GetSaveIndex (strana pisc)
Result := inherited GetSaveIndex(Index);   // referencirana pozicija od 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 postaju 0..3, 5+ nepromenjeno

// TXLSReader.ParseXF (strana čitača)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 je slot 4 font liste

Zašto je nula-bazirana „popravka" pomerila svaki prilagođeni font za jedno mesto?

Nula-bazirano prepisivanje u HotXLS 2.384.1 pomerilo je svaki prilagođeni font jer je jedno-baziran indeks čitalo kao nula-baziran, a zatim promenilo četiri mesta poziva da se uklapi u to pogrešno čitanje: GetSaveIndex, ParseXF, SST run remap i migraciju runova između radnih sveski u Sheets.AddCopy. HotXLS round trip i dalje je izgledao uredno, jer su se pisac i čitač slagali međusobno. Excel se nije slagao. Fajl zapisan u 2.384.1 stavio je prvi prilagođeni font na ifnt 4, što Excel tretira kao podrazumevani font, a svaki sledeći prilagođeni font po zapis ranije; otvaranje Excel fajla išlo je u suprotnom smeru, vezujući svaki font po zapis kasnije

HotXLS 2.384.1 regresija u kojoj GetSaveIndex i ParseXF čitaju jedno-bazirane FontIndex vrednosti kao nula-bazirane, upisuju prvi prilagođeni font kao zabranjeni ifnt 4 koji Excel razrešava na podrazumevani font i dospevaju sa svakim sledećim fontom po zapis ranije dok round trip i dalje prolazi
Pisac i čitač slagali su se u istom pogrešnom čitanju, pa je test sa čuvanjem i ponovnim otvaranjem ostajao zelen dok je svaki font koji otvori Excel dospevao jedno mesto pogrešno — kad jedna konvencija živi na sedam mesta a vi menjate četiri, pre sumnjajte u svoju izmenu

Trag koji je trebalo da zaustavi izmenu ležao je u istoj bazi koda. CountRichRunFontRefs, remap FONTX i FBI za grafikone i font lista style engine-a nikada nisu dirani i i dalje su koristili skip-4, pa se biblioteka suprotstavila samoj sebi trenutka kad je 2.384.1 stigla, i samo slučajnost da su rich-text fontove obično referencirali i neki XF držala je protivrečnost skrivenu. Kada se jedna konvencija pojavljuje na sedam mesta a vi menjate četiri, posumnjajte prvo u svoju izmenu, pa tek onda u ostala tri. Verzija 2.384.4 vratila je specifikacijsku numeraciju na sva četiri mesta, a stari regresioni test, koji je asertovao ifnt < FontCount i time kodirao pogrešno čitanje, zamenjen je testovima koji svaki upisani ifnt preslikavaju nazad na ime FONT zapisa kroz formulu iz specifikacije. Jedno pošteno ograničenje ostaje: fajlovi sačuvani u verzijama 2.384.1 do 2.384.3 sa pet ili više fontova nose pomerene indekse koje čitač ne može razlikovati od validnih podataka, pa je jedini lek da se regenerišu

Zašto se font runovi komentara kvaro tek na drugom čuvanju?

Runovi komentara i text boxova kvarili su se na drugom čuvanju jer je HotXLS prva N-1 FONT zapisa čuvao bezuslovno, a odbacivao samo poslednji kada ga nijedan XF ne referencira, dok su TXO formatting runovi ([MS-XLS] §2.4.329) zapisivani nazad bajt po bajt bez prenumeracije. .xls fajlovi koje piše Excel uvek se završavaju nereferenciranim pratećim fontom (9pt DengXian na sistemu sa kineskim lokalom), pa pri prvom čuvanju font koji je koristio samo run komentara nikada nije bio poslednji, i ništa se vidljivo nije pomerilo. To prvo čuvanje je ipak odbacilo prateći font i promovisalo font samo-komentara na poslednju poziciju. Drugo čuvanje ga je tada odbacilo kao nereferenciranog, ifnt runa pokazivao je iza kraja, a Excel padao je na podrazumevani font; ako je radna sveska u međuvremenu dobila novi font, run se tiho vezao za njega, što je u testiranju stilizovani text box run pretvorilo u Arial. Fajlovi bogati komentarima, poput onih opisanih u tekstu o izgradnji workflow-a za reviziju komentara i hiperlinkova, baš su mesto gde ovo ujeda, jer se otvaraju, anotiraju i čuvaju iznova i iznova

HotXLS tabela fontova kroz dva čuvanja gde nereferencirani prateći FONT zapis koji Excel uvek upisuje otpada prvi, font samo-komentara postaje poslednji i tada se odbacuje jer su TXO formatting runovi zapisivani nazad bez brojanja referenci, sve dok CountRichRunFontRefs nije ispravio filter preživljavanja u 2.384.5
Prvo čuvanje delovalo je uredno jer je prateći font preuzeo gubitak, a font komentara nestao je tek na drugom — preslikajte svaki ifnt na ime FONT zapisa kroz čuvanja umesto da verujete testu u memoriji

HotXLS 2.384.5 tretira TXO runove kao SST runove. CountRichRunFontRefs sada prolazi kroz svaki TMSOShapeTextBox na svakom radnom listu, pretvara skip-4 ifnt svakog runa u slot i broji ga kao referencu, pa font koji koristi samo run preživi filter čuvanja. Nastala tabela slot-to-save-index odlazi u FontRunRemap svakog crteža, a TMSOShapeTextBox.Store prepisuje indekse runova na privatnoj kopiji sirovih bajtova runova, ostavljajući prateći TxOLastRun na miru jer ne nosi font. Za kod aplikacije ugovor je jednostavan: TXLSComment.TextRuns.FontIndex i TXLSTextBox.TextRuns.FontIndex koriste numeraciju fajla, sa preskočenom 4, tačno kao što je pročitano; indeksi runova su jedno-bazirani a CharIndex je offset znaka na kom run počinje. Nakon čuvanja upisani broj može se razlikovati od onog koji ste postavili, ali i dalje pokazuje na isti 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];   // numeracija fajla, sa preskočenom 4
      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;

Kopije, ubacivanje redova i migracija runova između radnih sveski

Od HotXLS 2.384.6 svaka copy putanja klasičnog engine-a čuva formatting runove komentara, jer Range.Copy, CopyRange, Sheets.AddCopy i pomeranja ćelija iza Range.Insert i Range.Delete sada prolaze kroz TXLSRange.CopyCell, a CopyCell je nekada kopirao samo tekst komentara i autora. Pomeranje je kopija plus brisanje, pa je ubacivanje jednog reda iznad napomene sa dva runa ostavljalo nulu runova i jedan font. Popravka kopira svaki run i premešta njegov font kroz TXLSWorkbook.MigrateRunFontIndex, koji preslikava skip-4 indeks u slot, migrira font po vrednosti u odredišnu tabelu fontova i preslikava nazad u numeraciju fajla; SST rich-text migracija u Sheets.AddCopy sada zove istu funkciju umesto da vuče sopstvenu kopiju aritmetike. Uz to su došla i dva granična slučaja: lepljenje na mestu gde su izvor i odredište isti komentar ne sme očistiti njegove runove pre nego što ih pročita, a Sheets.AddCopy sada radi drugi prolaz za komentare zakačene za ćelije bez sačuvanog zapisa ćelije, koje je ranije preskakao potpuno. Strana tabele fontova pri kopiranju između radnih sveski sledi istu logiku po vrednosti kao i strana formula koju pokriva kopiranje između radnih sveski i rebinding formula. Na XLSX engine-u copy putanje su runove već klonirale po vrednosti; rupa je bila u samom delu za komentare, gde je čitač ignorišao rFont, strike, u i vertAlign, a pisac nikada nije emitovao u ni vertAlign, pa runovi sada preživljavaju čuvanje i ponovno otvaranje simetrično

Kako biste trebali testirati font indekse u BIFF8 fajlovima?

Font indekse testirajte čuvanjem i ponovnim otvaranjem, idealno kroz više od jedne generacije, i preslikavanjem svakog ifnt nazad na FONT zapis umesto asertovanja numeričkog opsega. Svaki bug u ovoj priči prošao je test u memoriji: 2.384.1 regresija živela je u uparenom paru pisca i čitača, TXO odstupanje tražilo je dva čuvanja sa izmenom tabele fontova između, a izgubljeni runovi komentara na XLSX-u pokazali su se tek posle ponovnog otvaranja. Koristan harness otvara uzorak koji je pisao Excel, čuva ga dva puta kroz HotXLS, dodaje ili uklanja font između čuvanja, a zatim proverava pozicije runova plus, na nivou bajtova, imena fontova iza svakog ifnt. Ne poredite FontIndex vrednosti pre i posle čuvanja, jer je prenumeracija legitimna

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // napisao Excel, C2 ima dva runa
  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 se pomera na C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // ponovno otvaranje, ne veruj memoriji
  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;

Ako iz Delphi-ja ili C++Builder-a čitate i upisujete klasični XLS i ne želite da pratite koji će od mnogih potrošača fontova u biblioteci i dalje biti usaglašen sa [MS-XLS] §2.5.129, skip-4 numeracija, prenumeracija runova pri čuvanju i migracija runova po vrednosti opisani ovde ugrađeni su u HotXLS Delphi spreadsheet komponentu, koja čita i upisuje XLS i XLSX bez Excela i OLE automation-a