Article technique

Index de police BIFF8 : le 4 sauté, runs HotXLS en Delphi

HotXLS numérote chaque référence de police BIFF8 comme le définit [MS-XLS] §2.5.129 FontIndex : les valeurs 0 à 3 sont en base 0, les valeurs au-dessus de 4 en base 1, et 4 n'apparaît jamais, si bien que le cinquième enregistrement FONT est ifnt 5 et que le plus grand ifnt valide égale le nombre d'enregistrements FONT. Depuis HotXLS 2.384.4, l'écriture des XF, la lecture des XF, les runs de chaînes en texte riche et la migration des runs entre classeurs suivent tous cette règle, et 2.384.5 et 2.384.6 l'étendent aux runs de commentaires et de zones de texte, y compris à travers les copies et les insertions de lignes

La règle ressemble à une faute de frappe tant que vous ne la croisez pas. Quelqu'un ouvre un classeur avec huit enregistrements FONT, trouve un XF pointant sur la police 8, et conclut que le générateur a produit un index hors plage. Ce raisonnement exact est parti en production dans HotXLS 2.384.1 comme une « correction », et il a transformé une implémentation correcte en une implémentation où chaque police personnalisée d'un fichier ouvert dans Excel atterrissait un emplacement trop tôt. La partie intéressante n'est pas le décalage de un en soi, c'est le nombre d'endroits d'une bibliothèque BIFF8 qui portent la même convention, et la façon dont une liaison de police peut survivre à une sauvegarde et casser à la seconde. Si vous avez déjà combattu les particularités de longueur et de codage couvertes dans le décodage de XLUnicodeString cch et fHigh en BIFF8, c'est la même famille de bogue : le fichier va bien, l'arithmétique non

Que dit réellement la règle FontIndex de [MS-XLS] ?

[MS-XLS] §2.5.129 dit qu'un FontIndex inférieur à 4 est une position d'enregistrement en base 0, qu'un FontIndex supérieur à 4 est une position en base 1, et que la valeur 4 NE DOIT PAS être utilisée. Le même type FontIndex est utilisé par les enregistrements XF, les runs de formatage SST et les runs de formatage TXO, si bien qu'une règle mal lue corrompt les trois. La preuve se reproduit facilement avec des fichiers écrits par Excel : SOLVSAMP.XLS livré avec Office a 19 enregistrements FONT et un ifnt XF maximal de 19, un classeur de 43 enregistrements plafonne à 43, et un fichier sauvegardé par Excel 16 avec 30 enregistrements FONT pointe ses cellules en Courier New sur ifnt 22, le 22e enregistrement. Aucun ne contient jamais de 4. Si vous devez analyser le mappage vous-même dans un outil de diagnostic, la conversion tient en deux petites fonctions

// [MS-XLS] 2.5.129 FontIndex : 0..3 en base 0, > 4 en base 1, 4 invalide
function FontIndexToRecordNo(Ifnt: Word): Integer;  // enregistrement FONT en base 1
begin
  if Ifnt < 4 then
    Result := Ifnt + 1
  else if Ifnt > 4 then
    Result := Ifnt
  else
    Result := -1;  // 4 ne doit pas survenir
end;

function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
  if RecordNo <= 4 then
    Result := RecordNo - 1
  else
    Result := RecordNo;
end;
Mappage FontIndex de HotXLS selon MS-XLS 2.5.129 où ifnt 0 à 3 sont des positions d'enregistrements FONT en base 0, ifnt 5 et au-delà en base 1 et la valeur 4 ne survient jamais, avec la conversion FontIndexToRecordNo et la preuve issue de classeurs écrits par Excel comme SOLVSAMP.XLS
Le cinquième enregistrement FONT est ifnt 5, pas 4 — un classeur de 19 enregistrements plafonne à ifnt 19, et aucun fichier écrit par Excel ne stocke jamais la valeur interdite entre les deux

À l'intérieur de HotXLS, la même règle vit dans deux endroits en miroir. TXLSFontList.GetSaveIndex prend la position en base 1 d'une police dans la liste référencée et ne décrémente que les positions 1 à 4, si bien que la position 5 s'écrit ifnt 5. TXLSReader.ParseXF fait l'inverse au chargement : tout ifnt de 5 ou plus est décrémenté vers un emplacement de liste de polices en base 0, et tout ce qui est en dessous reste en place. Le remappage des runs riches SST et CountRichRunFontRefs appliquent la même conversion ifnt >= 5, et c'est bien là le point : une seule convention, tous les consommateurs

// TXLSFontList.GetSaveIndex (côté écriture)
Result := inherited GetSaveIndex(Index);   // position référencée en base 1
if (Result > 0) and (Result < 5) then
  Dec(Result);                             // 1..4 deviennent 0..3, 5+ inchangé

// TXLSReader.ParseXF (côté lecture)
fnti := Data.GetWord(0);
if fnti >= 5 then
  Dec(fnti);                               // ifnt 5 est l'emplacement 4 de la liste

Pourquoi une « correction » en base 0 a-t-elle décalé chaque police personnalisée de un ?

La réécriture en base 0 de HotXLS 2.384.1 a décalé chaque police personnalisée parce qu'elle lisait un index en base 1 comme un index en base 0, puis a modifié quatre sites d'appel pour coller à cette mauvaise lecture : GetSaveIndex, ParseXF, le remappage des runs SST et la migration des runs entre classeurs dans Sheets.AddCopy. Les allers-retours HotXLS avaient l'air bons, parce que générateur et lecteur étaient d'accord entre eux. Excel, non. Un fichier écrit par 2.384.1 mettait la première police personnalisée à ifnt 4, qu'Excel traite comme la police par défaut, et chaque police personnalisée suivante un enregistrement trop tôt ; l'ouverture d'un fichier Excel allait dans l'autre sens, liant chaque police un enregistrement trop tard

Régression HotXLS 2.384.1 où GetSaveIndex et ParseXF lisaient des valeurs FontIndex en base 1 comme en base 0, écrivaient la première police personnalisée comme le ifnt 4 interdit qu'Excel résout vers la police par défaut et faisaient atterrir chaque police suivante un enregistrement trop tôt tandis que les allers-retours passaient encore
Générateur et lecteur partageaient la même mauvaise lecture, si bien qu'un test sauvegarde-puis-réouverture restait vert tandis que chaque police ouverte dans Excel atterrissait à un emplacement près — quand une convention vit à sept endroits et que vous en changez quatre, suspectez d'abord votre changement

L'indice qui aurait dû arrêter le changement se trouvait dans la même base de code. CountRichRunFontRefs, le remappage FONTX et FBI des graphiques et la liste de polices du moteur de styles n'ont jamais été touchés et continuaient d'utiliser le saut du 4, si bien que la bibliothèque se contredisait dès l'arrivée de 2.384.1, et seule la coïncidence que les polices de texte riche étaient d'habitude aussi référencées par un XF gardait la contradiction cachée. Quand une convention apparaît à sept endroits et que vous en changez quatre, suspectez votre changement avant de suspecter les trois autres. La version 2.384.4 a restauré la numérotation de la spécification aux quatre endroits, et l'ancien test de régression, qui assertait ifnt < FontCount et encodait donc la mauvaise lecture, a été remplacé par des tests qui remappent chaque ifnt écrit vers un nom d'enregistrement FONT par la formule de la spécification. Une limite honnête demeure : les fichiers sauvegardés par 2.384.1 à 2.384.3 avec cinq polices ou plus portent des index décalés qu'un lecteur ne peut pas distinguer de données valides, donc le seul remède est de les régénérer

Pourquoi les runs de polices des commentaires cassent-ils seulement à la seconde sauvegarde ?

Les runs de commentaires et de zones de texte cassaient à la seconde sauvegarde parce que HotXLS gardait les N-1 premiers enregistrements FONT inconditionnellement et ne lâchait que le dernier quand aucun XF ne le référençait, tandis que les runs de formatage TXO ([MS-XLS] §2.4.329) étaient réécrits octet pour octet sans renumérotation. Les fichiers .xls écrits par Excel finissent toujours avec une police de fin non référencée (un DengXian 9 pt sur un système en locale chinoise), si bien qu'à la première sauvegarde, la police utilisée uniquement par un run de commentaire n'était jamais la dernière, et rien ne bougeait visiblement. Cette première sauvegarde lâchait pourtant la police de fin et promouvait la police propre au commentaire au dernier rang. La seconde sauvegarde l'éliminait alors comme non référencée, le ifnt du run pointait au-delà de la fin, et Excel retombait sur la police par défaut ; si le classeur avait gagné une nouvelle police entre-temps, le run se liait tranquillement à celle-là à la place, ce qui dans les tests transformait un run de zone de texte stylé en Arial. Les fichiers riches en commentaires comme ceux décrits dans construire un flux de revue de commentaires et liens hypertextes sont exactement là où cela mord, parce qu'ils sont ouverts, annotés et sauvegardés en boucle

Table de polices HotXLS sur deux sauvegardes où l'enregistrement FONT de fin non référencé qu'Excel écrit toujours tombe le premier, la police propre au commentaire devient dernière puis est éliminée parce que les runs de formatage TXO étaient réécrits sans compter les références, jusqu'à ce que CountRichRunFontRefs corrige le filtre de survie en 2.384.5
La première sauvegarde avait l'air propre parce que la police de fin prenait la perte, et la police du commentaire ne disparaissait qu'à la seconde — mappez chaque ifnt vers un nom d'enregistrement FONT entre les sauvegardes au lieu de faire confiance à un test en mémoire

HotXLS 2.384.5 traite les runs TXO comme les runs SST. CountRichRunFontRefs parcourt désormais chaque TMSOShapeTextBox de chaque feuille, convertit le ifnt avec saut du 4 de chaque run en emplacement et le compte comme une référence, si bien qu'une police propre aux runs survit au filtre de sauvegarde. La table emplacement-vers-index-de-sauvegarde qui en résulte va dans le FontRunRemap de chaque dessin, et TMSOShapeTextBox.Store réécrit les index de runs sur une copie privée des octets bruts des runs, en laissant tranquille le TxOLastRun final qui ne porte aucune police. Pour le code applicatif, le contrat est simple : TXLSComment.TextRuns.FontIndex et TXLSTextBox.TextRuns.FontIndex utilisent la numérotation du fichier, 4 sauté, exactement comme lu ; les index de runs sont en base 1 et CharIndex est l'offset caractère où le run démarre. Après une sauvegarde, le nombre stocké peut différer de celui que vous avez fixé, mais il pointe toujours vers la même police

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];   // numérotation du fichier, 4 sauté
      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;

Copies, insertions de lignes et migration de runs entre classeurs

Depuis HotXLS 2.384.6, chaque chemin de copie du moteur classique conserve les runs de formatage des commentaires, parce que Range.Copy, CopyRange, Sheets.AddCopy et les décalages de cellules derrière Range.Insert et Range.Delete passent tous par TXLSRange.CopyCell, et CopyCell ne copiait que le texte du commentaire et son auteur. Un décalage est une copie plus un effacement, donc insérer une seule ligne au-dessus d'une note à deux runs la laissait avec zéro run et une police. La correction copie chaque run et déplace sa police via TXLSWorkbook.MigrateRunFontIndex, qui convertit l'index avec saut du 4 en emplacement, migre la police par valeur dans la table de polices de destination et reconvertit vers la numérotation du fichier ; la migration de texte riche SST dans Sheets.AddCopy appelle désormais la même fonction au lieu de transporter sa propre copie de l'arithmétique. Deux cas limites sont venus avec : un collage sur place où source et destination sont le même commentaire ne doit pas effacer ses runs avant de les lire, et Sheets.AddCopy fait maintenant une seconde passe pour les commentaires attachés à des cellules sans enregistrement de cellule stocké, qu'elle sautait entièrement auparavant. Le côté table de polices de la copie entre classeurs suit la même logique par valeur que le côté formules couvert dans copie entre classeurs et reliage des formules. Sur le moteur XLSX, les chemins de copie clonaient déjà les runs par valeur ; le trou était dans la partie commentaires elle-même, où le lecteur ignorait rFont, strike, u et vertAlign et où le générateur n'émettait jamais u ni vertAlign, si bien que les runs survivent maintenant de façon symétrique à la sauvegarde et à la réouverture

Comment tester les index de police dans des fichiers BIFF8 ?

Testez les index de police en sauvegardant et en rouvrant, idéalement sur plus d'une génération, et en remappant chaque ifnt vers un enregistrement FONT plutôt qu'en assertant une plage numérique. Chaque bogue de cette histoire a passé un test en mémoire : la régression 2.384.1 vivait dans une paire générateur-lecteur accordée, la dérive TXO demandait deux sauvegardes avec un changement de table de polices entre les deux, et les runs de commentaires perdus sur XLSX ne se montraient qu'après une réouverture. Un harnais utile ouvre un échantillon écrit par Excel, le sauvegarde deux fois via HotXLS, ajoute ou retire une police entre les sauvegardes, puis vérifie les positions des runs et, au niveau octet, les noms de polices derrière chaque ifnt. Ne comparez pas les valeurs de FontIndex avant et après une sauvegarde, puisque la renumérotation est légitime

procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
  Book: IXLSWorkbook;
  Note: TXLSComment;
  RunCount: Integer;
  SecondRunAt: Word;
begin
  Book := TXLSWorkbook.Create;
  Assert(Book.Open(SrcFile) = 1);          // écrit par Excel, C2 a deux 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 va en C3
  Assert(Book.SaveAs(OutFile) = 1);

  Book := TXLSWorkbook.Create;               // réouverture, sans confiance à la mémoire
  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;

Si vous lisez et écrivez du XLS classique depuis Delphi ou C++Builder et préférez ne pas suivre lequel des nombreux consommateurs de polices d'une bibliothèque reste encore d'accord avec [MS-XLS] §2.5.129, la numérotation avec saut du 4, la renumérotation des runs à la sauvegarde et la migration des runs par valeur décrites ici sont intégrées dans le composant tableur HotXLS pour Delphi, qui lit et écrit XLS et XLSX sans Excel ni automatisation OLE