HotXLS 2.376.0 a corrigé une dérive de longueur d’enregistrement BIFF dans son générateur XLS classique : l’émetteur SXEx des vues PivotTable déclarait un corps de 24 octets dans son en-tête, puis en ajoutait 26. Un lecteur BIFF fait confiance à la longueur déclarée ; les deux octets excédentaires désynchronisaient donc tout ce qui suivait et les classeurs associant une PivotTable à une feuille de graphique perdaient le graphique à la réouverture
La partie intéressante n’est pas le mot décalé d’une unité. C’est la distance entre l’erreur et le symptôme. Rien n’échouait au point du bug. Les enregistrements du pivot étaient sérialisés proprement, le fichier s’écrivait sans erreur, Excel l’ouvrait et les dégâts n’apparaissaient que des centaines d’octets plus loin, dans un sous-flux sans aucun rapport. Cette distance est caractéristique de tout format binaire préfixé par une longueur, et il vaut la peine de la comprendre avant d’en écrire un nouvel émetteur
Pourquoi une seule longueur d’enregistrement erronée détruit-elle tout un flux de feuille de calcul ?
Un flux de classeur BIFF8 n’a aucun encadrement au-delà de sa propre arithmétique. Chaque enregistrement est constitué d’un en-tête de 4 octets, formé de l’identifiant d’enregistrement (2 octets) et de la longueur du corps (2 octets), suivi d’exactement ce nombre d’octets de charge utile ([MS-XLS] 2.1.4). Il n’y a ni séparateur, ni octet magique, ni checksum, ni point de resynchronisation. Le lecteur arrive à l’enregistrement suivant uniquement parce que le précédent lui a dit la vérité sur sa propre taille. La longueur déclarée n’est pas une métadonnée de l’enregistrement ; c’est le pointeur vers le suivant. Suivez donc ce que les deux octets excédentaires ont provoqué. Le lecteur a consommé l’en-tête SXEx, ignoré les 24 octets promis par l’en-tête et s’est retrouvé deux octets trop tôt, sur une paire de zéros restée dans le corps surdimensionné. Il a lu ces zéros comme un identifiant d’enregistrement $0000, puis l’identifiant de fin de feuille de calcul suivant ($000A) comme la longueur de cet enregistrement fantôme et a consciencieusement sauté dix octets dans ce qui venait après. À partir de là, chaque en-tête était lu au mauvais offset. Dans le classeur en échec, cela produisait une feuille de graphique dont _Chart valait nil après la réouverture et un dump de débogage où $18AF était interprété comme un identifiant d’enregistrement. Aucune de ces valeurs n’apparaît à proximité du code du pivot
L’émetteur et l’écriture ne confrontent jamais leurs comptes
La raison structurelle qui rendait la dérive possible est que HotXLS construit un enregistrement BIFF comme un TXLSBlob dont l’en-tête et la charge utile sont deux faits indépendants. EmitSXEx écrit l’identifiant d’enregistrement, puis Blob.AddWord(24) pour la longueur, puis ajoute le corps champ par champ. Ce 24 est une constante comptée à la main, jamais dérivée des octets qui suivent ni contrôlée par rapport à eux. Le chemin d’écriture ne comble pas non plus l’écart : AddRec transmet le blob à TXLSBlobList.Append, qui copie verbatim Data.DataLength octets dans le flux de sortie. DataLength est le vrai nombre d’octets ; l’écrivain émet donc fidèlement 26 octets de corps derrière un en-tête qui en annonce 24. Les deux moitiés font exactement ce qu’on leur a demandé et personne n’est chargé de remarquer leur contradiction. HotXLS évite déjà ce problème lorsqu’il rejoue des charges utiles préservées : TXLSWorkbook.StoreDConnBlobs calcule le mot de longueur de l’en-tête à partir de la longueur réelle du corps plutôt que d’un littéral, raison exacte pour laquelle le rejeu de blobs n’a jamais dérivé
Ce que [MS-XLS] 2.4.282 fixe pour SXEx
La spécification est sans ambiguïté sur la taille, ce qui a rendu le correctif mécanique. [MS-XLS] 2.4.282 définit le corps SXEx comme un grbit de 4 octets suivi de dix champs de 2 octets : csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle et cchVacateStyle. Quatre plus vingt font vingt-quatre. L’ancien émetteur écrivait onze mots nuls là où la spécification en définit dix, et les appels anonymes à AddWord(0) ne portaient aucun nom de champ ; les compter à l’œil pendant une revue était donc aussi fiable qu’on peut l’imaginer. La préallocation était l’indice que la disposition avait été comprise mais pas la boucle : TXLSBlob.Create(28) demande exactement quatre octets d’en-tête plus un corps de 24 octets, alors que le blob dépassait cette indication à chaque appel, silencieusement, parce que AdjustBufferSize réalloue à la demande. Une indication de capacité que le code dépasse immédiatement mérite un second regard dans tout sérialiseur
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // en-tête de 4 octets + corps de 24 octets
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Dix mots nuls complètent le corps de 24 octets selon [MS-XLS] 2.4.282
// La longueur déclarée DOIT correspondre aux octets écrits, sinon chaque
// enregistrement suivant est mal analysé
Blob.AddWord(0); // csxformat
Blob.AddWord(0); // cchErrorString
Blob.AddWord(0); // cchNullString
Blob.AddWord(0); // cchTag
Blob.AddWord(0); // csxselect
Blob.AddWord(0); // crwPage
Blob.AddWord(0); // ccolPage
Blob.AddWord(0); // cchPageFieldStyle
Blob.AddWord(0); // cchTableStyle
Blob.AddWord(0); // cchVacateStyle
AddRec(DataList, Blob);
Result := 1;
end;
Pourquoi cela a-t-il survécu à toute une suite de tests PivotTable ?
Parce que les tests de pivot existants ne faisaient jamais de round-trip par fichier. Ils construisaient un classeur, vérifiaient le modèle en mémoire et s’arrêtaient là ; les assertions en mémoire ne peuvent pas voir une différence de longueur qui n’existe que dans le flux d’octets sérialisé. L’ensemble d’enregistrements couvert par l’écriture d’enregistrements PivotTable BIFF8 depuis Delphi était bien testé selon cette norme, tout en livrant un émetteur qui corrompait le flux. Le défaut avait aussi besoin d’une deuxième fonctionnalité pour devenir visible : une feuille avec pivot suivie de peu de choses se rouvrait encore, car la corruption sortait de la fin d’un sous-flux que personne n’inspectait. Seule la combinaison d’une PivotTable et d’une feuille de graphique, où les feuilles de graphique et les dessins occupent un sous-flux situé après la feuille de calcul, transformait un mauvais alignement silencieux en objet visiblement manquant
// PivotChartRoundTripThroughLinkRecords, version condensée
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath); // l’analyse erronée se produit ici
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Avant le correctif, Wb.Sheets[3]._Chart valait nil sur cette ligne, car le lecteur avait perdu la limite du sous-flux bien avant d’atteindre le BOF du graphique. L’assertion qui a finalement détecté un bug de sérialisation du pivot était une assertion sur un graphique
Relire un flux BIFF mal aligné jusqu’au premier enregistrement erroné
Parcourez la chaîne des en-têtes et affichez-la, car un flux BIFF désynchronisé s’annonce structurellement bien avant que les données aient l’air incorrectes. Commencez au BOF du sous-flux ($0809), lisez l’identifiant et la longueur, avancez de quatre octets plus la longueur et recommencez. Tant que le flux est aligné, vous arrivez sur des identifiants plausibles et la chaîne se termine exactement sur EOF ($000A). Lorsqu’elle dérive, vous obtenez des identifiants inexistants, des longueurs qui dépassent le tampon ou une chaîne qui passe tout droit au-delà de l’endroit où EOF aurait dû se trouver
// Parcourir un flux d’enregistrements BIFF et s’arrêter au premier en-tête impossible
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
Pos: LongWord;
Id, Len: Word;
begin
Pos := 0;
while Pos + 4 <= Size do
begin
Id := PWord(Buf + Pos)^;
Len := PWord(Buf + Pos + 2)^;
// Un identifiant nul n’est jamais un enregistrement légal et un corps qui
// dépasse le tampon prouve que la chaîne a déjà dérivé en amont
if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
begin
WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
Break;
end;
WriteLn(Format('%6d id=$%.4x len=%d', [Pos, Id, Len]));
if Id = $000A then
WriteLn('-- EOF, substream ends cleanly --');
Inc(Pos, 4 + LongWord(Len));
end;
end;
Lisez ensuite la sortie à rebours et retenez une règle : le premier enregistrement qui ne peut pas être analysé n’est presque jamais le coupable. C’est la victime. Le coupable est l’enregistrement immédiatement précédent, le dernier qui a été analysé sans plainte, car un enregistrement qui ment sur sa propre longueur se laisse toujours analyser correctement. Ici, le parcours s’est arrêté sur un enregistrement fantôme $0000 et l’enregistrement précédent était SXEx. Comparez la longueur déclarée de cet enregistrement à la liste des champs de la spécification, octet par octet : l’arithmétique tombe juste ou non. Si le parcours n’atteint même jamais un premier enregistrement sain, le problème est à un niveau inférieur, dans le fichier composé OLE2 qui contient le flux Workbook, et aucun nombre de dumps au niveau des enregistrements ne vous aidera
Un émetteur qui ne peut pas mentir sur sa propre longueur
Le correctif durable n’est pas une constante correcte, mais la suppression de la possibilité d’en écrire une incorrecte. Réservez le mot de longueur, émettez le corps puis corrigez l’en-tête avec le nombre d’octets réellement produit. HotXLS expose précisément ce qu’il faut : TXLSBlob.DataLength fournit l’offset courant et SetWord réécrit à une position déjà émise
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // mémoriser l’emplacement du mot de longueur
Blob.AddWord(0); // valeur provisoire, corrigée par EndRecord
end;
procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
Body: LongWord;
begin
Body := Blob.DataLength - LenPos - SizeOf(Word);
if Body > 8224 then
raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
Blob.SetWord(Word(Body), LenPos);
end;
Soyez honnête sur le point où cette garantie s’arrête. Une assertion générale selon laquelle les octets émis valent 2 + 2 + la valeur déclarée ne tient que pour les enregistrements qui respectent la limite BIFF8 de 8224 octets de charge utile. Les corps trop grands déclarent légitimement 8224 dans l’en-tête et continuent dans des enregistrements Continue $003C, exactement comme le font les écrivains de cache de pivot et de connexions HotXLS pour les grandes charges ; l’invariant est donc conditionnel : sous la limite, la longueur du blob émis doit être égale à la longueur déclarée plus quatre, au-dessus, le séparateur possède l’arithmétique. Encodez cette distinction dans l’helper plutôt que dans un commentaire. Le même raisonnement se transfère à tout format tag-longueur-valeur, pas seulement à BIFF. Un émetteur qui déclare une taille avant de savoir ce qu’il a écrit formule une affirmation que le code ne peut pas contrôler et que le reviewer ne peut pas compter ; cela fonctionne jusqu’à ce qu’une deuxième fonctionnalité arrive en aval de la première
Le writer BIFF8, les émetteurs d’enregistrements de pivot et le sous-flux de graphique décrit ici sont livrés dans le composant tableur HotXLS pour Delphi pour Delphi et C++Builder, qui lit et écrit XLS, XLSX et ODS sans Excel installé