Article technique

Empreinte de graphique HotXLS et décalages d'ancrage Delphi

Le HotXLS Delphi Component ne rejoue un graphique Excel non modifié octet pour octet que si deux conditions sont réunies : le graphique a été atteint via la relation de dessin de la feuille de calcul et non via un nom de partie deviné, et l'empreinte 64 bits du modèle a été capturée après la fin de l'analyse du modèle de graphique. La version 2.382.0 a corrigé la première condition, la 2.382.3 la seconde, et a commencé à faire l'aller-retour des décalages d'ancrage xdr:colOff et xdr:rowOff non nuls que l'écrivain de dessin codait en dur à zéro. Les deux défauts viennent du même cas du corpus local, two-charts.xlsx : d'abord une assertion structurelle a vu deux parties de graphique devenir trois, puis une comparaison octet à octet de chaque xl/charts/chartN.xml a montré que des graphiques que personne n'avait touchés étaient quand même réécrits — et aucun des deux problèmes ne levait d'exception ni ne faisait râler Excel, ce qui explique pourquoi ils ont survécu aussi longtemps

Pourquoi un classeur à deux graphiques revenait-il avec trois parties de graphique ?

Parce que le chargeur avait un repli qui devinait. Quand une feuille de calcul n'avait pas de relation de dessin dans sa partie .rels, l'ancien code supposait que le dessin se trouvait au nom conventionnel xl/drawings/drawing{i+1}.xml, où i est la position de la feuille, et rattachait cette partie si elle existait dans l'archive. Dans two-charts.xlsx, la première feuille n'a ni dessin ni partie .rels, alors que xl/drawings/drawing1.xml existe bel et bien — il appartient à la seconde feuille, qui l'atteint via Target="../drawings/drawing1.xml". La feuille 1 héritait donc d'un graphique qu'elle n'avait jamais référencé, chart1.xml était analysé deux fois, et la sauvegarde écrivait le classeur avec trois parties de graphique au lieu de deux

Comment HotXLS résout les dessins de feuille dans l'échantillon two-charts : Sheet1 ne porte aucune relation de dessin ni partie rels tandis que Sheet2 atteint xl/drawings/drawing1.xml via ParPartTargets, et le repli d'avant la 2.382.0 devinait ce nom conventionnel à partir de la position de la feuille, si bien que chart1.xml était analysé deux fois et que les sauvegardes écrivaient trois parties de graphique jusqu'à ce que le correctif ne charge les dessins que via XlsxRtDrawing
Sheet1 ne référençait aucun graphique, donc le graphe de relations est la seule source sûre pour la cible du dessin, et un nom conventionnel deviné a transformé un classeur à deux graphiques en une sauvegarde à trois parties

Le correctif de HotXLS v2.382.0 a supprimé la devinette purement et simplement. Un dessin de feuille n'est plus chargé que via ParPartTargets[i].Values[XlsxRtDrawing], la cible enregistrée pour le type de relation de dessin sur cette feuille, et une feuille sans cette relation n'obtient aucun dessin. C'est le comportement qu'exige le format : l'élément <drawing r:id="…"/> de la feuille (ECMA-376 Partie 1 §18.3.1.36) est le seul lien entre une feuille et son dessin, et les noms de parties dans un paquet OPC n'ont pas d'autre sens que celui que leur donne le graphe de relations. Les archives écrites par Excel se trouvent utiliser les noms conventionnels, ce qui a permis au raccourci de passer si longtemps ; le décorticage de la résolution des relations OPC dans HotXLS explique pourquoi deviner un nom de partie n'est jamais sûr, même quand la devinette tombe juste

// Avant la v2.382.0 : une relation de dessin manquante donnait lieu à une devinette
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // peut appartenir à une autre feuille

// Depuis la v2.382.0 : la relation ou rien
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

Que garantit l'empreinte du graphique ?

L'empreinte décide, graphique par graphique, si la sauvegarde peut recopier la partie d'origine ou doit la régénérer. À l'import, avec PreserveUnsupportedParts activé avant Open, HotXLS conserve les octets UTF-8 bruts de chaque partie de graphique dans FRawChartXml, construit la sérialisation propre au modèle typé avec BuildChartKnownXml, et stocke la longueur de cette sérialisation dans FRawChartModelLength et son hash dans FRawChartModelHash. Le hash est un FNV-1a calculé sur les unités de code UTF-16 du XML généré, avec la base d'offset 64 bits standard 14695981039346656037 et le nombre premier 1099511628211. Au moment de la sauvegarde, XlsxChartRawModelUnchanged reconstruit le XML connu et compare longueur et hash ; une correspondance signifie que le modèle typé est exactement celui de l'import, donc rien de ce que l'application aurait pu modifier n'a changé

HotXLS capture l'empreinte du graphique à l'import, en gardant les octets UTF-8 bruts dans FRawChartXml tandis que BuildChartKnownXml produit FRawChartModelLength et un hash FNV-1a, et au moment de la sauvegarde XlsxChartRawModelUnchanged reconstruit et compare les deux valeurs : une correspondance rejoue les octets d'origine ou recopie l'entrée compressée, un écart retombe sur XlsxMergeChartXml
Une empreinte ne vaut que par le moment où elle est prise, et la capturer avant la fin de toutes les passes de récupération garantit un hash qui ne correspondra plus jamais au modèle complet
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // rien de conservé
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // rejeu à l'identique
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // fusion structurelle
end;

L'écrivain XLSX va un cran plus loin que BuildChartXmlFromKnown. Quand le modèle est inchangé et que StrictOOXML est désactivé, il tente d'abord de recopier l'entrée compressée directement de l'archive source vers la sortie sous le nouveau nom de partie du graphique, si bien que les octets ne sont même pas décodés puis recompressés. Ce n'est que si cette copie est impossible qu'il retombe sur le chemin décodage-ou-fusion. Le mécanisme lui-même — longueur plus hash, rejeu quand ils sont égaux, fusion sinon — est celui décrit dans la note sur la modification de graphiques Excel sans perdre le ChartML. Cet article porte sur la façon dont il a cessé de fonctionner en silence

Pourquoi tous les graphiques prenaient-ils quand même le chemin de fusion ?

Parce que l'empreinte était capturée un appel trop tôt. L'analyse d'un graphique dans HotXLS est une passe SAX sur la partie de graphique, suivie d'une série de passes de récupération qui extrayent du texte brut des détails que les gestionnaires SAX ne modélisent pas directement : XlsxChartParseSeriesFlags lit chaque bloc <c:ser> pour son drapeau <c:smooth> et les valeurs srgbClr du remplissage et du trait de marqueur, puis récupère les modes de croisement d'axes et les styles de graduations principales et secondaires pour les axes des catégories et des valeurs. Avant la v2.382.3, l'ordre à la fin de ParseChartXml était : classifier les groupes d'axes, construire le XML connu, capturer longueur et hash, et seulement ensuite exécuter XlsxChartParseSeriesFlags. L'empreinte décrivait donc un modèle auquel manquaient encore les drapeaux smooth, les couleurs de marqueur et les graduations. Au moment de la sauvegarde, BuildChartKnownXml s'exécutait sur le modèle complet, qui émettait désormais <c:smooth val="1"/> et les couleurs de marqueur récupérées. XML plus long, hash différent, XlsxChartRawModelUnchanged renvoyait False, et le graphique passait par XlsxMergeChartXml. La fusion est une opération correcte pour un graphique que quelqu'un a modifié, mais ce n'est pas une opération qui préserve les octets : elle resérialise l'arbre, et la règle de propriété qui laisse le modèle typé l'emporter pour les séries, les axes et les groupes de tracés fait que les nœuds régénérés remplacent les originaux. Le résultat visible dans la passe sur corpus était des couleurs de séries qui dérivaient sur des graphiques que personne n'avait modifiés — tous les graphiques de tous les classeurs préservés, à chaque sauvegarde, sans le moindre diagnostic nulle part

La réparation tient à une seule réorganisation : XlsxChartParseSeriesFlags s'exécute maintenant avant la construction du XML connu, donc l'empreinte décrit le modèle tel qu'il existera quand l'application le verra pour la première fois. La leçon vaut au-delà des graphiques. Une empreinte de détection de changement ne vaut que par le moment où elle est prise, et le moment sûr est après la fin de toutes les passes susceptibles de modifier le modèle. HotXLS a un second point de capture pour les deux mêmes valeurs, la référence qu'il réétablit face au fichier de sortie après une sauvegarde réussie, et ce point-là s'exécutait toujours sur un modèle entièrement analysé ; c'est celui de l'import qui faisait exception

Où sont passés les décalages d'ancrage ?

Dans un zéro bien littéral. Un twoCellAnchor dans la partie de dessin épingle un graphique entre deux cellules, et chaque coin porte un index de cellule plus un décalage à l'intérieur de cette cellule : from (ECMA-376 Partie 1 §20.5.2.5) et to (§20.5.2.32) contiennent chacun col, colOff (§20.5.2.4), row et rowOff. Les décalages sont exprimés en English Metric Units, 914400 par pouce, et Excel écrit des valeurs non nulles dès qu'un graphique a été placé ou redimensionné à la souris, c'est-à-dire la plupart des graphiques. Le premier graphique de two-charts.xlsx commence à la ligne 0 avec un rowOff de 19049 et se termine à la colonne 8, ligne 15, avec un colOff de 247650 et un rowOff de 66674 — environ un quart de pouce dans la dernière colonne. L'analyseur de dessin de HotXLS lisait ces quatre valeurs depuis toujours — le code des images s'en servait — mais l'écrivain de graphique émettait <xdr:colOff>0</xdr:colOff> et <xdr:rowOff>0</xdr:rowOff> pour chaque coin, ce qui recollait chaque graphique à la grille des cellules à la sauvegarde

Anatomie des coins xdr:twoCellAnchor du premier graphique de l'échantillon HotXLS : from porte col 0 et rowOff 19049 tandis que to porte col 8, colOff 247650 et rowOff 66674 en EMU de 914400 par pouce, et l'écrivain qui émettait des décalages nuls recollait les graphiques à la grille jusqu'à ce que FFromColOff, FToColOff et leurs frères rejouent les valeurs importées
L'ancre vit dans la partie de dessin et non dans celle du graphique, donc ce correctif est indépendant de celui de l'empreinte, et les deux devaient être livrés avant que le classeur fasse vraiment l'aller-retour
// Depuis la v2.382.3, l'écrivain d'ancre rejoue les décalages EMU importés
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart porte désormais FFromColOff, FFromRowOff, FToColOff et FToRowOff, remplis par l'analyseur de dessin et recopiés avec le reste de l'état d'ancrage quand un graphique est affecté. Ils sont délibérément privés : la surface d'ancrage publique reste les quatre coordonnées de cellule FromRow, FromCol, ToRow et ToCol, et un graphique créé depuis du code Delphi atterrit sur les frontières de cellules comme avant. Les décalages existent pour rendre l'aller-retour fidèle, pas pour exposer le positionnement infra-cellulaire comme une fonctionnalité. À noter que ce correctif est indépendant de l'empreinte : l'ancre vit dans la partie de dessin, pas dans celle du graphique, donc un graphique dont le ChartML se rejouait parfaitement aurait quand même sauté à la grille sans lui. Les conversions d'unités derrière ces valeurs EMU sont traitées dans la note sur la géométrie d'image et la mise à l'échelle EMU dans HotXLS

Comment prouver qu'un graphique fait l'aller-retour sans changement ?

En comparant les octets, pas en ouvrant le résultat dans Excel. Excel répare et normalise tellement de choses au chargement qu'un graphique qui a dérivé a l'air correct jusqu'au moment où un analyste remarque que la couleur de marqueur a changé. Le test de corpus qui a attrapé les deux défauts fait trois choses après une ouverture-sauvegarde sans aucune modification : il parcourt les relations de feuille, de dessin et de graphique et échoue sur toute référence de graphique dupliquée, orpheline ou pendante ; il compare une signature du type de graphique, des formules de séries et de la géométrie d'ancrage entre l'original et la sortie ; et pour two-charts.xlsx, il lit chaque xl/charts/chartN.xml dans les deux archives et exige des octets identiques. La même vérification est facile à écrire en Delphi avec la TZipFile de la RTL

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // lève une exception si la partie a disparu
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Trois conditions rendent cette comparaison significative, et chacune échoue en silence si on les oublie. PreserveUnsupportedParts doit valoir True avant Open, sinon aucun octet brut n'est capturé et chaque graphique est reconstruit depuis le modèle. StrictOOXML doit valoir False, parce que le mode strict force la régénération par conception. Et l'application ne doit pas toucher au graphique entre l'ouverture et la sauvegarde — lire des propriétés ne pose pas de problème, mais tout setter qui modifie le modèle typé fait basculer l'empreinte et envoie le graphique sur le chemin de fusion, ce qui est un comportement correct et pas ce que ce test vérifie. Les parties de graphique sont en outre renumérotées à partir d'un compteur global au classeur lors de la sauvegarde, donc un classeur dont l'ordre des feuilles ou des graphiques a changé placera des octets identiques sous un autre nom chartN.xml ; c'est pour cette raison que le vérificateur de corpus suit les relations plutôt que les noms

Les deux correctifs sont livrés dans HotXLS 2.382.0 et 2.382.3 et vérifiés sur Win32 et Win64 contre le corpus local, les échantillons de graphiques re-sauvegardés étant en plus rendus en PDF par une suite bureautique indépendante et comparés page par page aux originaux. HotXLS lit, modifie et écrit les graphiques XLSX depuis du code Delphi et C++Builder natif, sans aucune installation d'Excel, et c'est ce qui fait de ce niveau de fidélité une responsabilité de la bibliothèque — la page du composant tableur HotXLS pour Delphi contient la liste des fonctionnalités et un téléchargement d'essai