Article technique

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

Un classeur à deux graphiques subit un open-and-save qui ne modifie rien, et trois choses différentes peuvent sortir à l'arrivée. Une sortie a trois parties de graphique là où la source en avait deux. Une autre en a toujours deux, mais une comparaison d'octets montre que chaque xl/charts/chartN.xml a été réécrit et que les couleurs des séries ont dérivé. Une troisième garde le XML du graphique intact alors que chaque graphique a sauté de quelques pixels sur la grille de cellules. Excel ouvre les trois sans invite de réparation. Chaque symptôme vient d'une décision du chemin de chargement et d'enregistrement : comment une feuille trouve son dessin, à quel moment l'empreinte de modification du graphique est prise, et si les décalages d'ancrage à l'intérieur de la partie dessin sont réécrits

Une feuille trouve son dessin via ses relations

Le symptôme des trois parties vient d'un chargeur qui devine les noms de parties. Dans un paquet XLSX, une feuille pointe vers son dessin avec un élément <drawing r:id="…"/> (ECMA-376 Partie 1 §18.3.1.36), et le r:id se résout via la partie .rels de la feuille vers une cible telle que ../drawings/drawing1.xml. Excel numérote par hasard les dessins de façon prévisible, si bien qu'un lecteur écrit à la main développe souvent un repli : quand la relation est absente, essayer xl/drawings/drawing{n}.xml pour la position de feuille n et l'utiliser si l'archive la possède

// Un raccourci qui apparaît dans les lecteurs XLSX écrits à la main (à ne pas livrer)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // résolu depuis le .rels de la feuille, '' quand absent
  if Result = '' then
  begin
    Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
    if Zip.IndexOf(Result) < 0 then
      Result := '';
  end;
end;

La devinette tient jusqu'à ce qu'une feuille sans graphique arrive en premier. Dans un classeur dont la première feuille n'a ni dessin ni partie .rels, tandis que la seconde atteint xl/drawings/drawing1.xml via Target="../drawings/drawing1.xml", le repli remet à la feuille 1 un dessin qu'elle n'a jamais référencé. chart1.xml est analysé deux fois, une par feuille, et l'enregistrement écrit trois parties de graphique. Les noms de parties d'un paquet OPC ne portent aucun sens au-delà de ce que le graphe de relations leur attribue, ce qui est tout l'argument de la visite guidée sur la résolution des relations OPC dans HotXLS

Comment une feuille trouve son dessin dans un classeur à deux graphiques : Sheet1 ne porte aucune relation de dessin ni partie rels tandis que Sheet2 atteint xl/drawings/drawing1.xml via sa relation de dessin, si bien qu'un chargeur qui devine ce nom conventionnel d'après la position de la feuille analyse chart1.xml deux fois et enregistre trois parties de graphique, et c'est pourquoi HotXLS ne charge un dessin que via la relation de dessin de la feuille
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é transforme un classeur à deux graphiques en enregistrement à trois parties

HotXLS prend la lecture stricte. Le dessin d'une feuille n'est chargé que depuis la cible de la relation de dessin de cette feuille, et une feuille sans relation n'obtient aucun dessin, quels que soient les fichiers qui traînent dans xl/drawings/. Une partie dessin que rien ne référence n'est jamais attachée à une feuille

Qu'est-ce qui décide si un graphique intact est copié ou reconstruit ?

Une empreinte par graphique le décide. Quand PreserveUnsupportedParts vaut True au moment d'Open (True est le défaut), HotXLS conserve les octets d'origine de chaque partie de graphique à côté du modèle de graphique analysé. Il sérialise ensuite le modèle avec son propre writer de graphique et enregistre deux nombres pour cette sortie : sa longueur, et un hachage FNV-1a 64 bits sur ses unités de code UTF-16, avec la base de décalage standard 14695981039346656037 et le nombre premier 1099511628211. TXLSXChart.HasPreservedXml indique si un graphique porte ces octets d'origine

Au moment d'enregistrer, le modèle du graphique est de nouveau sérialisé et les deux nombres sont comparés. Longueur égale et hachage égal signifient que le modèle est exactement ce qu'il était à l'import, donc rien de ce que l'application aurait pu modifier n'a changé. Dans ce cas, avec StrictOOXML désactivé, le writer copie l'entrée compressée de l'archive source directement dans la sortie sous le nouveau nom de partie du graphique ; les octets ne sont jamais décodés ni re-déflatés. Si l'un des deux nombres diffère, le XML d'origine est fusionné avec le modèle à la place. Changer le format de ligne d'une barre d'erreur ou d'une courbe de tendance compte aussi comme une modification, même si ce format ne fait pas partie du modèle sérialisé

HotXLS capture une empreinte de graphique à l'import, conserve les octets d'origine de la partie de graphique et enregistre la longueur et le hachage FNV-1a 64 bits de sa propre sérialisation du modèle de graphique, puis à l'enregistrement reconstruit et compare les deux valeurs, si bien qu'une correspondance rejoue les octets d'origine ou copie l'entrée compressée et une divergence fusionne le XML d'origine avec le modèle
L'empreinte ne vaut que par l'instant où elle est prise, et la capturer avant que chaque passe de récupération soit terminée garantit un hachage qui ne correspond jamais au modèle achevé

La fusion est la bonne opération pour un graphique que quelqu'un a modifié. Elle garde les parties du XML d'origine que le modèle ne couvre pas, laisse le modèle l'emporter pour les séries, les axes et les groupes de tracé, et resérialise le résultat. Ce qu'elle ne peut pas faire, c'est reproduire les octets d'origine. La note sur la modification des graphiques Excel sans perdre le ChartML détaille ce que la fusion conserve ; cet article traite des conditions dans lesquelles un graphique non modifié ne l'atteint jamais

uses System.SysUtils, lxHandleX;

procedure ResaveUntouched(const Src, Dst: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  S, C: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.PreserveUnsupportedParts := True;  // le défaut ; doit être en place avant Open
    Book.StrictOOXML := False;              // la sortie Strict régénère chaque graphique
    if Book.Open(Src) <> 1 then
      raise Exception.Create('Cannot open ' + Src);
    for S := 1 to Book.Sheets.Count do      // Sheets[] est basé sur 1
    begin
      Sheet := Book.Sheets[S];
      for C := 0 to Sheet.Charts.Count - 1 do  // Charts[] est basé sur 0
        Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
          BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
    end;
    if Book.SaveAs(Dst) <> 1 then
      raise Exception.Create('Cannot save ' + Dst);
  finally
    Book.Free;
  end;
end;

Quand un graphique doit être régénéré depuis le modèle à dessein, par exemple pour retirer un formatage porté par la source, TXLSXChart.ClearPreservedXml abandonne les octets stockés et ce graphique est écrit depuis le modèle seul

Pourquoi chaque graphique prendrait-il de toute façon le chemin de la fusion ?

Parce qu'une empreinte prise une étape trop tôt ne correspond jamais. Le XML de graphique est rarement lu en une seule passe. Une analyse en flux construit les séries, les axes et les groupes de tracé, puis un ensemble de passes de suivi relit dans le texte brut du graphique des détails plus faciles à récupérer une fois les séries existantes : le drapeau <c:smooth> de chaque <c:ser>, les valeurs srgbClr du remplissage et du contour de marqueur, les modes de croisement d'axes, et les styles de graduations majeures et mineures. La collection de séries n'existe tout simplement pas encore pendant que la passe en flux tourne, donc ces passes doivent venir après

Supposez que l'empreinte est capturée à la fin de la passe en flux, avant que les passes de suivi ne tournent. Elle décrit alors un modèle sans drapeaux de lissage, sans couleurs de marqueur et avec des graduations par défaut. À l'enregistrement, le sérialiseur tourne sur le modèle achevé et émet <c:smooth val="1"/> plus les couleurs récupérées. Le XML est plus long, le hachage diffère, et chaque graphique de chaque classeur préservé passe par la fusion à chaque enregistrement. Sur un graphique en courbes avec séries lissées et marqueurs personnalisés, les nœuds <c:ser> régénérés remplacent les originaux, et c'est de là que vient la dérive des couleurs

HotXLS ne capture l'empreinte qu'une fois chaque passe de récupération terminée, si bien que le hachage décrit le modèle tel que l'application le voit en premier. Les deux mêmes nombres sont rétablis après un enregistrement réussi, cette fois depuis le fichier de sortie, et cette seconde capture part elle aussi d'un modèle entièrement analysé. Un hachage de détection de changement doit être pris après la dernière passe qui peut encore muter le modèle. Le prendre plus tôt donne une vérification qui échoue à chaque enregistrement

Les décalages d'ancrage vivent dans la partie dessin

Le symptôme de l'alignement sur la grille n'a rien à voir avec le XML de graphique. L'endroit où un graphique se pose sur la feuille est stocké dans la partie dessin, et la forme habituelle est xdr:twoCellAnchor (ECMA-376 Partie 1 §20.5) : un coin from et un coin to, chacun avec col, colOff, row et rowOff. Les décalages sont des English Metric Units, 914400 par pouce et 12700 par point, et Excel écrit des valeurs non nulles dès qu'un graphique est déplacé ou redimensionné à la souris. Pour un fichier qui déclare un coin from à la colonne 0, ligne 0 avec un rowOff de 19049, et un coin to à la colonne 8, ligne 15 avec un colOff de 247650 et un rowOff de 66674, le graphique commence 1,5 point sous le haut de la ligne 1 et finit un peu plus d'un quart de pouce dans la colonne I

Anatomie des coins xdr:twoCellAnchor pour un graphique d'exemple : 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 parce qu'un writer qui émet des décalages nuls colle les graphiques à la grille, HotXLS réécrit les décalages importés à l'enregistrement
L'ancrage vit dans la partie dessin plutôt que dans la partie graphique, donc le rejeu des décalages est indépendant de l'empreinte, et un aller-retour fidèle a besoin des deux

Un writer qui lit ces quatre nombres puis émet <xdr:colOff>0</xdr:colOff> pour chaque coin déplace chaque graphique sur les lignes de grille. La partie graphique peut rejouer octet pour octet et le graphique se déplace quand même, parce que les deux vivent dans des parties différentes. HotXLS conserve le type d'ancrage chargé (twoCellAnchor, oneCellAnchor ou absoluteAnchor, exposé via PhysicalAnchorKind) avec ses valeurs EMU, et les réécrit à l'identique. Les décalages se lisent via FromOffsetXEMU, FromOffsetYEMU, ToOffsetXEMU et ToOffsetYEMU, et les conversions d'unités derrière eux sont détaillées dans la note sur la géométrie d'images et l'échelle EMU dans HotXLS

procedure NudgeChart(Chart: TXLSXChart);
const
  EmuPerPoint = 12700;
begin
  if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
    Exit;
  Writeln(Format('from R%dC%d +(%d, %d) EMU  to R%dC%d +(%d, %d) EMU',
    [Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
     Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
  // Remplace tout le cadre en un appel : lignes et colonnes sont basées sur 1,
  // les décalages sont des EMU à l'intérieur de la cellule, le dernier argument est le comportement editAs
  Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
    Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
    Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;

Utilisez SetTwoCellAnchor quand l'intention est de déplacer ou redimensionner. Affecter FromRow, FromCol, ToRow ou ToCol sur un graphique à deux cellules ne change que cet indice de cellule et laisse les décalages où ils étaient, ce qui est le bon comportement pour un petit ajustement et le mauvais pour placer un graphique à partir de rien. SetTwoCellAnchor valide le cadre dans son ensemble et lève ERangeError quand le coin de fin précède le début ou que les décalages sortent de la plage EMU d'Excel. Un graphique créé avec AddChart démarre avec des décalages nuls, donc il se pose exactement sur les frontières de cellules jusqu'à ce que vous lui en donniez

Prouver l'aller-retour par une comparaison d'octets

Ouvrir la sortie dans Excel ne prouve presque rien ici. Excel normalise assez au chargement pour qu'un graphique resérialisé paraisse identique jusqu'à ce que quelqu'un remarque qu'une couleur de marqueur a changé, et un alignement sur la grille de quelques points est facile à rater à l'écran. La vérification qui vaut l'automatisation compare les parties de graphique octet pour octet après un open-and-save sans modification, et le TZipFile de la RTL suffit pour cela

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;

Quelques conditions décident si une divergence dans cette vérification signifie quelque chose :

  • PreserveUnsupportedParts doit valoir True avant Open ; sans lui, aucun octet d'origine n'est conservé et chaque graphique est écrit depuis le modèle
  • StrictOOXML doit valoir False, puisque la sortie Strict régénère les graphiques par conception. Laissez-le désactivé dans tout pipeline qui promet que des graphiques intacts survivent à un enregistrement
  • Un paquet source écrit avec des espaces de noms OOXML Strict est converti vers les espaces de noms transitionnels lors d'un enregistrement transitionnel, si bien que ses octets de graphique diffèrent même si rien d'autre n'a changé
  • Le code entre Open et SaveAs peut lire librement les propriétés de graphique, mais tout setter qui change le modèle change l'empreinte et envoie ce graphique par la fusion, ce qui est correct et hors de ce que ce test mesure
  • Les parties de graphique sont renumérotées depuis un compteur à l'échelle du classeur à l'enregistrement, si bien qu'un classeur dont l'ordre de feuilles ou de graphiques a changé place des octets identiques sous un autre nom chartN.xml ; un vérificateur pour ce cas suit les relations de dessin au lieu de faire correspondre les noms

Les ancres demandent une comparaison par valeurs. La partie dessin est écrite à neuf autour des graphiques qu'elle porte, donc ses octets sont la mauvaise chose à comparer ; rouvrir les deux fichiers et comparer FromOffsetXEMU à ToOffsetYEMU graphique par graphique attrape un writer qui transforme 247650 en 0. Une vérification structurelle qui parcourt les relations de feuilles, de dessins et de graphiques et échoue sur toute référence de graphique dupliquée, orpheline ou pendante attrape le cas des trois parties avant même que la comparaison d'octets ne tourne

Tout ceci tourne dans du code Delphi et C++Builder natif sans installation d'Excel sur la machine, donc le classeur qui revient d'un traitement par lots est la seule preuve de ce qui est arrivé à ses graphiques. Licences, API graphique complète et build d'essai sont sur la page du composant tableur Delphi HotXLS