Technický článek

FontIndex v BIFF8 přeskočí 4 a rich text runy v HotXLS

HotXLS čísluje každou referenci na písmo v BIFF8 tak, jak ji definuje [MS-XLS] §2.5.129 FontIndex: hodnoty 0 až 3 jsou indexované od nuly, hodnoty nad 4 indexované od jedničky a 4 se nikdy neobjeví, takže pátý záznam FONT je ifnt 5 a největší validní ifnt se rovná počtu záznamů FONT. Od HotXLS 2.384.4 toto pravidlo dodržuje XF writer, XF reader, rich text runy stringů i migrace runů mezi sešity a 2.384.5 s 2.384.6 ho rozšiřují na runy komentářů a textových boxů, včetně kopírování a vkládání řádků

Pravidlo vypadá jako překlep, dokud do něj nenarazíte. Někdo otevře sešit s osmi záznamy FONT, najde XF ukazující na písmo 8 a usoudí, že writer vyprodukoval index mimo rozsah. Přesně takhle uvažovala oprava ve HotXLS 2.384.1 a z korektní implementace udělala takovou, ve které každé vlastní písmo v souboru otevřeném v Excelu dopadlo o slot dřív. Zajímavý není sám off-by-one, ale to, kolik míst v knihovně pro BIFF8 nese stejnou konvenci, a to, že vazba na písmo může jedno uložení přežít a rozbít se při druhém. Pokud jste už bojovali se zvláštnostmi délky a kódování popsanými v Dekódování XLUnicodeString BIFF8 v Delphi: cch a fHigh, je to stejná rodina bugů: soubor je v pořádku, aritmetika není

Co pravidlo FontIndex z [MS-XLS] doopravdy říká?

[MS-XLS] §2.5.129 říká, že FontIndex pod 4 je pozice záznamu počítaná od nuly, FontIndex nad 4 je pozice počítaná od jedničky a hodnota 4 se NESMÍ používat. Stejný typ FontIndex používají záznamy XF, formátovací runy SST i formátovací runy TXO, takže jedno špatně přečtené pravidlo poškodí všechny tři. Důkaz se snadno zreprodukuje se soubory od Excelu: SOLVSAMP.XLS dodávaný s Office má 19 záznamů FONT a maximální XF ifnt 19, sešit se 43 záznamy končí na 43 a soubor uložený Excelem 16 se 30 záznamy FONT ukazuje buňky s Courier New na ifnt 22, tedy dvacátý druhý záznam. V žádném z nich se 4 nikdy neobjeví. Pokud potřebujete analyzovat mapování sami v diagnostickém nástroji, konverze jsou dvě krátké funkce

// [MS-XLS] 2.5.129 FontIndex: 0..3 od nuly, > 4 od jedničky, 4 neplatná
function FontIndexToRecordNo(Ifnt: Word): Integer;  // záznam FONT počítaný od 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 nesmí nastat
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Mapování FontIndex v HotXLS podle MS-XLS 2.5.129, kde ifnt 0 až 3 jsou pozice záznamů FONT počítané od nuly, ifnt 5 a výš počítané od jedničky a hodnota 4 nikdy nenastává, s konverzí FontIndexToRecordNo a důkazy ze sešitů od Excelu jako SOLVSAMP.XLS
Pátý záznam FONT je ifnt 5, ne 4 — sešit s 19 záznamy končí na ifnt 19 a žádný soubor od Excelu nikdy neuloží zakázanou hodnotu mezi nimi

Uvnitř HotXLS žije totéž pravidlo na dvou zrcadlových místech. TXLSFontList.GetSaveIndex vezme pozici písma v odkazovaném seznamu počítanou od 1 a dekrementuje jen pozice 1 až 4, takže pozice 5 se zapisuje jako ifnt 5. TXLSReader.ParseXF dělá při načtení opak: jakýkoli ifnt 5 a víc se dekrementuje na slot seznamu písem počítaný od nuly a cokoli pod tím zůstává. Remap rich runů SST a CountRichRunFontRefs aplikují stejnou konverzi ifnt >= 5 — a přesně o to jde: jedna konvence, každý konzument

// TXLSFontList.GetSaveIndex (strana writeru)
Result := inherited GetSaveIndex(Index);   // odkazovaná pozice od 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 se stanou 0..3, 5+ beze změny

// TXLSReader.ParseXF (strana readeru)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 je slot 4 v seznamu písem

Proč zero-based „oprava" posunula každé vlastní písmo o jedničku?

Zero-based přepis v HotXLS 2.384.1 posunul každé vlastní písmo, protože četl index počítaný od jedničky jako index od nuly a pak přiohlásil čtyři místa volání, aby odpovídala tomuhle čtení: GetSaveIndex, ParseXF, remap runů SST a migraci runů mezi sešity v Sheets.AddCopy. Round trip uvnitř HotXLS pořád vypadal dobře, protože se writer a reader shodovali navzájem. Excel nesouhlasil. Soubor zapsaný verzí 2.384.1 postavil první vlastní písmo na ifnt 4, což Excel bere jako defaultní písmo, a každé pozdější vlastní písmo o záznam dřív; otevírání souboru od Excelu jelo opačným směrem a vázalo každé písmo o záznam později

Regrese HotXLS 2.384.1, kdy GetSaveIndex a ParseXF četly hodnoty FontIndex počítané od jedničky jako od nuly, zapsaly první vlastní písmo jako zakázané ifnt 4, které Excel vyřeší jako defaultní písmo, a nechaly každé pozdější písmo o záznam dřív, zatímco round trip uvnitř knihovny stále procházel
Writer a reader se shodli na stejném špatném čtení, takže test ulož-a-znovu-otevři zůstával zelený, zatímco každé písmo v Excelu dopadlo o slot vedle — když jedna konvence žije na sedmi místech a měníte čtyři, podezřívejte nejdřív svou změnu

Stopa, která měla změnu zastavit, ležela ve stejném kódu. CountRichRunFontRefs, remap FONTX a FBI u grafů a seznam písem ve style engine nebyly nikdy sahnuty a pořád používaly skip-4, takže si knihovna protiřečela v okamžiku, kdy 2.384.1 vyjela, a kontradikci udržovala v tajnosti jen shoda okolností, že písma rich textu bývala obvykle odkazovaná i nějakým XF. Když se jedna konvence objevuje na sedmi místech a měníte čtyři, podezřívejte svou změnu dřív než zbylé tři. Verze 2.384.4 obnovila číslování podle specifikace na všech čtyřech místech a starý regresní test, který assertoval ifnt < FontCount a tedy zakódoval špatné čtení, nahradily testy mapující každý zapsaný ifnt zpět na jméno záznamu FONT přes vzorec ze specifikace. Jedna upřímná mezera zůstává: soubory uložené verzemi 2.384.1 až 2.384.3 s pěti a více písmy nesou posunuté indexy, které reader neodliší od validních dat, takže jediným lékem je jejich regenerace

Proč se runy písem komentářů lámou až při druhém uložení?

Runy komentářů a textových boxů se lámaly při druhém uložení, protože HotXLS držel prvních N-1 záznamů FONT bezpodmínečně a zahazoval jen poslední, pokud na něj neodkazoval žádný XF, zatímco formátovací runy TXO ([MS-XLS] §2.4.329) se zapisovaly zpět bajt po bajtu bez přečíslování. Soubory .xls od Excelu končí vždy neodkazovaným závěrečným písmem (9pt DengXian na systému s čínským locale), takže při prvním uložení nebylo písmo používané jen runem komentáře nikdy poslední a nic se viditelně nepohnulo. To první uložení ale závěrečné písmo upustilo a povýšilo písmo jen pro komentář na poslední pozici. Druhé uložení ho pak zahodilo jako neodkazované, ifnt runu ukazoval za konec a Excel spadl zpátky na defaultní písmo; pokud si sešit mezitím připsal nové písmo, run se potichu svázal s ním, což při testování změnilo stylizovaný run textového boxu na Arial. Soubory plné komentářů, jako ty popsané v Komentáře buněk a odkazy v Excelu v Delphi s HotXLS, jsou přesně místa, kde tohle kouše, protože se pořád otevírají, komentují a ukládají

Tabulka písem HotXLS přes dvě uložení, kdy neodkazovaný závěrečný záznam FONT, který Excel vždy zapisuje, upadne jako první, písmo jen pro komentář se stane posledním a pak se zahodí, protože formátovací runy TXO se zapisovaly zpět bez počítání referencí, dokud CountRichRunFontRefs v 2.384.5 neopravil filtr přežití
První uložení vypadalo čistě, protože závěrečné písmo vzalo šrám na sebe, a písmo komentáře zmizelo až při druhém — mapujte každé ifnt na jméno záznamu FONT napříč uloženími místo důvěry v in-memory test

HotXLS 2.384.5 bere runy TXO jako runy SST. CountRichRunFontRefs teď projde každý TMSOShapeTextBox na každém listu, zkonvertuje skip-4 ifnt každého runu na slot a započítá ho jako referenci, takže písmo používané jen runem přežije save filtr. Výslednou tabulku slotů na save index dostane každý drawing do svého FontRunRemap a TMSOShapeTextBox.Store přepíše indexy runů na privátní kopii surových bajtů runů, přičemž závěrečnému TxOLastRun nechá pokoj, protože žádné písmo nenese. Pro aplikační kód je kontrakt prostý: TXLSComment.TextRuns.FontIndex a TXLSTextBox.TextRuns.FontIndex používají číslování souboru s vynechanou 4, přesně jak přišlo z readu; indexy runů se počítají od 1 a CharIndex je znakový offset, kde run začíná. Po uložení se uložené číslo může lišit od toho, které jste nastavili, ale ukazuje pořád na totéž písmo

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];   // číslování souboru, 4 vynecháno
      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, vkládání řádků a migrace runů mezi sešity

Od HotXLS 2.384.6 zachovává každá copy cesta classic engine formátovací runy komentářů, protože Range.Copy, CopyRange, Sheets.AddCopy i posuny buněk za Range.Insert a Range.Delete jdou přes TXLSRange.CopyCell a CopyCell kopíroval dřív jen text komentáře a autora. Posun je kopie plus vymazání, takže vložení jediného řádku nad poznámkou se dvěma runy ho nechalo s nulou runů a jedním písmem. Oprava kopíruje každý run a stěhuje jeho písmo přes TXLSWorkbook.MigrateRunFontIndex, které zkonvertuje skip-4 index na slot, přestěhuje písmo po hodnotě do cílové tabulky písem a zkonvertuje zpět na číslování souboru; migrace rich textu SST v Sheets.AddCopy teď volá tutéž funkci místo to, aby vozila vlastní kopii aritmetiky. Přibyly dva edge cases: in-place paste, kde zdroj a cíl je tentýž komentář, nesmí vymazat runy dřív, než je přečte, a Sheets.AddCopy dělá druhé kolo i pro komentáře připojené k buňkám bez uloženého záznamu buňky, které dřív přeskočil úplně. Strana tabulky písem u kopírování mezi sešity následuje tutéž logiku po hodnotě jako strana vzorců popsaná v Kopírování mezi sešity a přepojení vzorců v HotXLS pro Delphi. Na XLSX engine klonovaly copy cesty runy po hodnotě už dřív; díra byla přímo v části komentářů, kde reader ignoroval rFont, strike, u a vertAlign a writer nikdy nevypustil u ani vertAlign, takže runy teď přežívají uložení a znovuotevření symetricky

Jak testovat font indexy v souborech BIFF8?

Testujte font indexy uložením a znovuotevřením, ideálně přes víc než jednu generaci, a mapováním každého ifnt zpět na záznam FONT místo assertování číselného rozsahu. Každý bug v tomhle příběhu prošel in-memory testem: regrese 2.384.1 bydlela ve spárovaném páru writeru a readeru, drift TXO potřeboval dvě uložení se změnou tabulky písem mezi nimi a ztracené runy komentářů na XLSX se ukázaly až po znovuotevření. Užitečný harness otevře vzorek od Excelu, uloží ho dvakrát přes HotXLS, mezi uloženími přidá nebo odebere písmo a pak zkontroluje pozice runů a na úrovni bajtů jména písem za každým ifnt. Neporovnávejte hodnoty FontIndex před a po uložení, protože přečíslování je legitimní

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // od Excelu, C2 má dva runy
  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 přesune na C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // znovuotevření, paměti nevěřte
  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;

Pokud čtete a zapisujete classic XLS z Delphi nebo C++Builderu a nechcete dohánět, který z mnoha konzumentů písem v knihovně zrovna souhlasí s [MS-XLS] §2.5.129, skip-4 číslování, přečíslování runů při uložení a migrace runů po hodnotě popsané tady jsou zabudované v komponentě HotXLS pro Excel v Delphi, která čte a zapisuje XLS a XLSX bez Excelu a OLE automation