Tehnički članak

BIFF8 indeks fonta preskače 4: rich text runovi u Delphiju

HotXLS svaku BIFF8 referencu na font broji onako kako je definira [MS-XLS] §2.5.129 FontIndex: vrijednosti 0 do 3 su nula-bazirane, vrijednosti iznad 4 jedna-bazirane, a 4 se nikad ne pojavljuje, pa je peti FONT zapis ifnt 5 i najveći valjani ifnt jednak je broju FONT zapisa. Od HotXLS 2.384.4 XF writer, XF čitač, rich text runovi u stringovima i migracija runova između radnih knjiga svi slijede to pravilo, a 2.384.5 i 2.384.6 protežu ga na runove komentara i text boxa, uključujući i kopije i umetanje redaka

Pravilo izgleda kao greška u kucanju dok ne naletite na nju. Netko otvori radnu knjigu s osam FONT zapisa, nađe XF koji pokazuje na font 8 i zaključi da je writer proizveo indeks izvan raspona. Upravo to je rasuđivanje ušlo u HotXLS 2.384.1 kao "popravak", i pretvorilo je ispravnu implementaciju u onu u kojoj svaki prilagođeni font u datoteci otvorenoj u Excelu slijetao je jedan slot ranije. Zanimljivo ovdje nije sama off-by-one greška, nego koliko mjesta u BIFF8 biblioteci nosi istu konvenciju i kako vezivanje fonta može preživjeti jedno spremanje a puknuti na drugom. Ako ste se već borili s čudima duljine i kodiranja obrađenima u članku o dekodiranju BIFF8 XLUnicodeString cch i fHigh, ovo je ista obitelj grešaka: datoteka je u redu, aritmetika nije

Što 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 jedna-bazirana pozicija zapisa, a vrijednost 4 se NE SMJE koristiti. Isti FontIndex tip koriste XF zapisi, SST formatting runovi i TXO formatting runovi, pa jedno krivo pročitano pravilo pokvari sva tri. Dokaz se lako reproducira s datotekama nastalima u Excelu: SOLVSAMP.XLS isporučen s Officeom ima 19 FONT zapisa i najveći XF ifnt 19, radna knjiga s 43 zapisa staje na 43, a datoteka spremljena Excelom 16 sa 30 FONT zapisa upućuje svoje Courier New ćelije na ifnt 22, dvadeset i drugi zapis. Nijedan nikad ne sadrži 4. Ako mapiranje analizirate sami u dijagnostičkom alatu, konverzija su dvije kratke funkcije

// [MS-XLS] 2.5.129 FontIndex: 0..3 nula-bazirano, > 4 jedna-bazirano, 4 nevažeće
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 smije se pojaviti
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
HotXLS FontIndex mapiranje po MS-XLS 2.5.129 gdje su ifnt 0 do 3 nula-bazirane pozicije FONT zapisa, ifnt 5 i više jedna-bazirano, a vrijednost 4 se nikad ne pojavljuje, uz FontIndexToRecordNo konverziju i dokaze iz radnih knjiga nastalih u Excelu poput SOLVSAMP.XLS
Peti FONT zapis je ifnt 5, a ne 4 — radna knjiga s 19 zapisa staje na ifnt 19, i nijedna datoteka nastala u Excelu nikad ne sprema zabranjenu vrijednost između

Unutar HotXLS-a isto pravilo živi na dva zrcalna mjesta. TXLSFontList.GetSaveIndex uzima poziciju fonta u referenciranom popisu od 1 i dekrementira samo pozicije 1 do 4, pa se pozicija 5 zapisuje kao ifnt 5. TXLSReader.ParseXF pri učitavanju radi obrnuto: svaki ifnt od 5 naviše dekrementira se u nula-bazirani slot popisa fontova, a sve ispod ostaje na mjestu. SST rich-run remap i CountRichRunFontRefs primjenjuju istu ifnt >= 5 konverziju, i to je poanta: jedna konvencija, svaki konzument

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

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

Zašto je nula-bazirani "popravak" pomaknuo svaki prilagođeni font za jedan?

Nula-bazirano prepisivanje u HotXLS 2.384.1 pomaknulo je svaki prilagođeni font jer je jedna-bazirani indeks čitalo kao nula-bazirani, a zatim promijenilo četiri call sitea da se poklope s tim krivim čitanjem: GetSaveIndex, ParseXF, SST run remap i migraciju runova između radnih knjiga u Sheets.AddCopy. HotXLS round tripovi i dalje su izgledali u redu, jer su se pisac i čitač slagali jedan s drugim. Excel se nije slagao. Datoteka napisana 2.384.1 stavila je prvi prilagođeni font na ifnt 4, koji Excel tretira kao zadani font, a svaki kasniji prilagođeni font jedan zapis ranije; otvaranje Excelove datoteke išlo je obrnutim smjerom, vezujući svaki font jedan zapis kasnije

Regresija HotXLS 2.384.1 gdje GetSaveIndex i ParseXF čitaju jedna-bazirane FontIndex vrijednosti kao nula-bazirane, zapisujući prvi prilagođeni font kao zabranjeni ifnt 4 koji Excel razrješava na zadani font i sljećući svaki kasniji font jedan zapis ranije dok round tripovi još prolaze
Pisac i čitač slagali su se oko istog krivog čitanja, pa je test spremanje-otvori-natrag ostao zelen dok je svaki font otvoren u Excelu slijetao jedan slot pokraj — kad jedna konvencija živi na sedam mjesta i mijenjate četiri, prvo sumnjajte u vlastitu izmjenu

Razlog koji je trebao zaustaviti izmjenu sjedio je u istom kodu. CountRichRunFontRefs, chart FONTX i FBI remap te popis fontova style enginea nikad nisu dirani i i dalje su koristili skip-4, pa se biblioteka proturječila sama sebi trenutka kad je 2.384.1 sletjela, a samo slučajnost da rich-text fontove obično referencira i neki XF držala je proturječje skrivenim. Kad jedna konvencija stoji na sedam mjesta, a vi mijenjate četiri, posumnjajte u svoju izmjenu prije nego u ostala tri. Verzija 2.384.4 vratila je specifikacijsku numeraciju na sva četiri mjesta, a stari regresijski test, koji je tvrdio ifnt < FontCount i time kodirao krivo čitanje, zamijenjen je testovima koji svaki zapisani ifnt mapiraju natrag na ime FONT zapisa kroz formulu iz specifikacije. Jedno pošteno ograničenje ostaje: datoteke spremljene od 2.384.1 do 2.384.3 s pet ili više fontova nose pomaknute indekse koje čitač ne može razlikovati od valjanih podataka, pa je jedini lijek da se regeneriraju

Zašto se font runovi komentara kvare tek pri drugom spremanju?

Runovi komentara i text boxa pucali su pri drugom spremanju jer je HotXLS prvih N-1 FONT zapisa držao bezuvjetno, a odbacivao samo posljednji kad ga nijedan XF ne referencira, dok su TXO formatting runovi ([MS-XLS] §2.4.329) zapisivani natrag bajt po bajt bez prenumeracije. .xls datoteke nastale u Excelu uvijek završavaju s nereferenciranim zadnjim fontom (9pt DengXian na sustavu s kineskim localeom), pa pri prvom spremanju font kojeg je koristio samo run komentara nikad nije bio posljednji, i ništa se vidljivo nije pomaknulo. To prvo spremanje ipak je odbacilo zadnji font i gurnulo font samo-komentara na posljednju poziciju. Drugo spremanje tada ga je odbacilo kao nereferenciran, ifnt runa pokazivao je iza kraja, a Excel se vraćao na zadani font; ako je radna knjiga u međuvremenu dobila novi font, run se tiho vezao uz taj umjesto, što je u testiranju styled text box run pretvorilo u Arial. Datoteke bogate komentarima poput onih opisanih u članku o izgradnji review workflowa za komentare i hyperlinkove upravo su mjesto gdje ovo grize, jer se otvaraju, anotiraju i spremaju iznova i iznova

HotXLS tablica fontova kroz dva spremanja gdje nereferencirani zadnji FONT zapis koji Excel uvijek piše otpada prvi, font samo-komentara postaje posljednji i biva odbačen jer su TXO formatting runovi zapisivani natrag bez brojanja referenci, sve dok CountRichRunFontRefs nije popravio filter preživljavanja u 2.384.5
Prvo spremanje izgledalo je čisto jer je zadnji font podnio gubitak, a font komentara nestao je tek pri drugom — mapirajte svaki ifnt na ime FONT zapisa kroz spremanja umjesto da vjerujete testu u memoriji

HotXLS 2.384.5 tretira TXO runove poput SST runova. 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 kojeg koristi samo run preživi filter spremanja. Nastala tablica slot-u-save-index ulazi u FontRunRemap svakog crteža, a TMSOShapeTextBox.Store prepisuje indekse runova na privatnoj kopiji sirovih bajtova runova, ostavljajući završni TxOLastRun na miru jer ne nosi font. Za aplikacijski kod ugovor je jednostavan: TXLSComment.TextRuns.FontIndex i TXLSTextBox.TextRuns.FontIndex koriste numeraciju datoteke, 4 preskočeno, točno kako je pročitano; indeksi runova su od 1, a CharIndex je pomak znaka gdje run počinje. Nakon spremanja spremljeni 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 datoteke, 4 preskočeno
      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, umetanje redaka i migracija runova između radnih knjiga

Od HotXLS 2.384.6 svaki copy put klasičnog enginea čuva formatting runove komentara, jer Range.Copy, CopyRange, Sheets.AddCopy i pomaci ćelija iza Range.Insert i Range.Delete svi prolaze kroz TXLSRange.CopyCell, a CopyCell kopirao je samo tekst komentara i autora. Pomak je kopija plus čišćenje, pa je umetanje jednog retka iznad note s dva runa ostavilo nulu runova i jedan font. Popravak kopira svaki run i premješta njegov font kroz TXLSWorkbook.MigrateRunFontIndex, koji skip-4 indeks pretvara u slot, migrira font po vrijednosti u odredišnu tablicu fontova i pretvara natrag u numeraciju datoteke; SST rich-text migracija u Sheets.AddCopy sada zove istu funkciju umjesto da nosi vlastitu kopiju aritmetike. Došla su i dva rubna slučaja: paste na mjestu gdje su izvor i odredište isti komentar ne smije očistiti njegove runove prije nego ih pročita, a Sheets.AddCopy sada radi drugi prolaz za komentare prikačene na ćelije bez pohranjenog cell zapisa, koje je ranije preskakao sasvim. Strana tablice fontova kod kopiranja između radnih knjiga slijedi istu logiku po vrijednosti kao i strana formula obrađena u članku o kopiranju između radnih knjiga i rebindingu formula. Na XLSX engineu copy putevi već su klonirali runove po vrijednosti; rupa je bila u samom dijelu za komentare, gdje je čitač ignorirao rFont, strike, u i vertAlign, a writer nikad nije ispisivao u ni vertAlign, pa runovi sada preživljavaju spremanje i ponovno otvaranje simetrično

Kako testirati indekse fontova u BIFF8 datotekama?

Indekse fontova testirajte spremanjem i ponovnim otvaranjem, po mogućnosti kroz više od jedne generacije, te mapiranjem svakog ifnt natrag na FONT zapis umjesto tvrdnje o numeričkom rasponu. Svaka greška u ovoj priči prošla je test u memoriji: regresija 2.384.1 živjela je u uparenom paru pisca i čitača, TXO drift trebao je dva spremanja s izmjenom tablice fontova između, a izgubljeni runovi komentara na XLSX-u pokazali su se tek nakon ponovnog otvaranja. Koristan harness otvara primjerak nastao u Excelu, sprema ga dvaput kroz HotXLS, dodaje ili uklanja font između spremanja, a zatim provjerava pozicije runova plus, na razini bajtova, imena fontova iza svakog ifnt. Ne uspoređujte FontIndex vrijednosti prije i poslije spremanja, jer je prenumeriranje legitimno

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // nastalo u Excelu, 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 seli u C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // ponovno otvori, memoriji ne vjeruj
  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 Delphija ili C++Buildera čitate i zapisujete klasični XLS i ne biste željeli pratiti koji će od mnogih konzumenata fontova u biblioteci i dalje priznavati [MS-XLS] §2.5.129, skip-4 numeracija, prenumeracija runova pri spremanju i migracija runova po vrijednosti opisani ovdje ugrađeni su u HotXLS Delphi spreadsheet komponentu, koja čita i zapisuje XLS i XLSX bez Excela i OLE automationa