Article technique

Implémenter le format de presse-papiers CF_HTML en Delphi

Copiez une plage depuis une grille Delphi et collez-la dans Word, et la mise en forme disparaît généralement : texte brut, pas d'en-têtes en gras, pas de bordures, pas de remplissages. HotXLS comble cet écart avec TXLSRange.CopyToClipboard, qui place une charge utile de presse-papiers CF_HTML — le format Windows pour du HTML stylé avec des marqueurs de fragment exacts à l'octet près — sur le presse-papiers à côté du texte Unicode brut

Cela paraît simple jusqu'à ce qu'on examine ce qu'exige réellement une charge utile CF_HTML. Le format a besoin d'un court en-tête texte nommant exactement où le fragment commence et se termine à l'intérieur du tampon de presse-papiers plus large, et ces positions sont des décalages d'octets, comptés à travers quel que soit l'encodage multi-octets dans lequel le HTML finit. Trompez-vous d'un seul octet dans l'arithmétique et l'application cible saisit soit la mauvaise tranche de balisage, soit abandonne et retombe sur du texte brut, et aucun de ces deux échecs ne ressemble à un bogue dans votre code — cela ressemble à Word étant Word

Pourquoi le copier-coller depuis une grille Delphi perd-il généralement sa mise en forme

L'appel presse-papiers Windows par défaut que la plupart du code Delphi utilise, SetClipboardData avec CF_TEXT ou CF_UNICODETEXT, ne transporte jamais que des caractères bruts, si bien que tout style appliqué dans la grille source n'a nulle part où aller. Word, Outlook, et tout navigateur basé sur Chromium recherchent un format plus riche lorsque vous collez : une représentation HTML de la sélection, complète avec styles en ligne, structure de tableau, et liens. Excel lui-même s'appuie exactement sur cette astuce — copiez une plage dans Excel et le presse-papiers reçoit silencieusement plusieurs formats à la fois, HTML parmi eux, si bien que quelle que soit l'application dans laquelle vous collez, elle choisit le plus riche qu'elle comprend. Un composant qui n'écrit jamais que CF_UNICODETEXT ne donne rien à travailler à chacun de ces consommateurs plus riches, et la richesse visuelle que l'utilisateur vient de copier n'est tout simplement pas là à coller

Qu'est-ce exactement que le format de presse-papiers CF_HTML ?

CF_HTML n'est pas un format de presse-papiers système fixe comme CF_TEXT ; c'est un format enregistré dynamiquement, demandé par nom via RegisterClipboardFormat('HTML Format'), et sa charge utile est un court en-tête ASCII suivi d'un document ou fragment HTML. L'en-tête porte cinq champs — Version, StartHTML, EndHTML, StartFragment, EndFragment — où Version vaut toujours 0.9 et les quatre autres sont des nombres décimaux écrits sous forme de chiffres ASCII. StartHTML et EndHTML délimitent le document entier tel que l'application réceptrice devrait l'analyser pour le contexte, polices et styles inclus, tandis que StartFragment et EndFragment délimitent la tranche plus étroite qui atterrit réellement au niveau du curseur, conventionnellement marquée dans le balisage lui-même par des commentaires <!--StartFragment--> et <!--EndFragment--> afin que les limites survivent à une re-sérialisation naïve

Diagramme d'une charge utile de presse-papiers CF_HTML construite par HotXLS dans Delphi, montrant l'en-tête ASCII à cinq champs et le document UTF-8 avec les marqueurs de commentaire StartFragment et EndFragment
L'enveloppe CF_HTML est un court en-tête ASCII devant un document UTF-8, la tranche collée étant marquée par des commentaires StartFragment et EndFragment

Décalages d'octets, pas comptages de caractères : le piège classique de CF_HTML

Les quatre champs numériques de l'en-tête CF_HTML sont des décalages d'octets dans la séquence exacte d'octets assise sur le presse-papiers, comptés depuis le tout premier caractère de l'en-tête lui-même — pas des comptages de caractères, pas des points de code Unicode, et pas des décalages relatifs au fragment ou à la balise <body>. Cette distinction est là où les implémentations CF_HTML écrites à la main se trompent silencieusement : la propriété Length d'un UnicodeString Delphi rapporte des unités de code UTF-16, ce qui se trouve égaler le nombre d'octets pour du texte ASCII pur, si bien que le bogue passe intact à travers tout test écrit avec des données d'exemple en anglais et n'apparaît qu'une fois qu'une cellule copiée contient un tiret cadratin, un symbole monétaire, ou un caractère accentué — un symbole euro est une unité de code UTF-16 mais trois octets en UTF-8, et chaque décalage calculé après ce point dérive du nombre d'octets supplémentaires que l'encodage a ajoutés. L'échec qui s'ensuit n'est pas un plantage ; c'est l'application réceptrice saisissant exactement la plage d'octets vers laquelle l'en-tête pointait, trouvant une tranche de balisage qui commence ou se termine en plein milieu d'une balise, et soit affichant du charabia, soit abandonnant et retombant sur le texte brut assis à côté sur le presse-papiers, silencieusement, sans rien dans votre code pour expliquer pourquoi — voici la forme de code qui produit exactement cet échec :

// Fragile : Length() sur un UnicodeString compte des unités de code UTF-16, pas des octets
var
  Header: string;
  Fragment: string;
  StartFragmentOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 + 'StartHTML:0000000000'#13#10 + '...';
  StartFragmentOfs := Length(Header) + Pos('<!--StartFragment-->', Fragment);
  // Un symbole monétaire, un tiret cadratin ou tout caractère accentué placé
  // avant ce point coûte un caractère ici mais deux ou trois octets
  // une fois le document encodé en UTF-8, si bien que StartFragmentOfs pointe désormais
  // avant l'endroit où le fragment commence réellement sur le vrai presse-papiers
end;

Comment HotXLS garde l'en-tête précis à l'octet près

HotXLS évite structurellement cette classe de bogue : TXLSRange.CopyToClipboard et l'unité lxClipboard en dessous construisent le document CF_HTML et son en-tête entièrement en AnsiString, le type chaîne d'octets de Delphi, si bien que Length et Pos renvoient déjà des positions d'octets partout dans le calcul — il n'y a pas d'étape séparée, et donc pas d'étape à oublier, où un comptage de caractères Unicode devrait être converti en comptage d'octets avant d'entrer dans l'en-tête

Diagramme contrastant les comptages d'unités de code UTF-16 avec les offsets d'octets UTF-8 dans un en-tête CF_HTML Delphi, où des caractères accentués et un signe euro font dériver les frontières de fragments
Un seul caractère multi-octets décale chaque décalage d'octet calculé après lui, si bien que HotXLS mesure l'en-tête entier en octets AnsiString plutôt qu'en unités de code

Il existe une seconde astuce, plus petite, qui mérite d'être connue si vous construisez un jour un en-tête CF_HTML à la main. L'en-tête est écrit deux fois : une fois avec dix chiffres zéro tenant lieu de chacun des quatre décalages, afin que sa propre longueur en octets puisse être mesurée, et une seconde fois avec les vrais décalages substitués. Comme chaque décalage réel est formaté selon cette même largeur fixe de dix chiffres, le second en-tête ressort exactement de la même longueur en octets que la version placeholder, ce qui est précisément pourquoi la mesure antérieure reste valide après la réécriture. Sautez la largeur fixe, formatez un nombre avec un simple IntToStr à la place, et l'en-tête peut rétrécir ou grandir d'un chiffre entre les deux passes, invalidant silencieusement chaque décalage qui le suit :

const
  Placeholder = '0000000000';   // 10 chiffres ASCII : largeur fixe à l'entrée comme à la sortie
var
  Header: AnsiString;           // AnsiString.Length est un nombre d'octets, pas de caractères
  StartHtmlOfs: Integer;
begin
  Header := 'Version:0.9'#13#10 +
    'StartHTML:' + Placeholder + #13#10 +
    'EndHTML:' + Placeholder + #13#10 +
    'StartFragment:' + Placeholder + #13#10 +
    'EndFragment:' + Placeholder + #13#10;
  StartHtmlOfs := Length(Header);   // sûr à mesurer une seule fois, dès le début
  // ...calculez les vrais décalages par rapport au document AnsiString...
  // puis reconstruisez Header avec les vrais nombres formatés sur la même
  // largeur de 10 chiffres, afin que sa longueur en octets -- et donc StartHtmlOfs --
  // ne bouge jamais entre la passe placeholder et la passe finale
end;

Pourquoi la charge utile en texte brut doit-elle tout de même accompagner l'ensemble

TXLSRange.CopyToClipboard ne place jamais CF_HTML seul sur le presse-papiers ; il écrit toujours CF_UNICODETEXT dans le même appel, car CF_HTML est un format enregistré plutôt que l'une des constantes fixes CF_* que toute application Windows sait déjà rechercher — un simple éditeur de texte, une grille héritée, ou quoi que ce soit qui n'a jamais vérifié la présence de 'HTML Format' ne le verra pas du tout, et la plage que vous avez copiée arrive soit sous forme de texte délimité par tabulations, soit n'arrive pas du tout. Ce texte délimité par tabulations n'est pas non plus une approximation grossière : les cellules de formule se copient sous forme de leur chaîne de formule avec un = initial restauré si le texte stocké l'avait supprimé, correspondant au comportement du propre texte de presse-papiers d'Excel, les cellules ordinaires copient leur FormattedText — la chaîne telle qu'affichée, si bien qu'une cellule monétaire se copie sous la forme 1 234,56 $, pas la valeur sous-jacente 1234.56 — et tout champ contenant une tabulation, une apostrophe, ou un saut de ligne est mis entre guillemets avec les guillemets intégrés doublés, la même convention qu'utilise le CSV

Diagramme de CopyToClipboard de HotXLS écrivant CF_HTML et CF_UNICODETEXT vers le presse-papiers Windows pour que Word et les navigateurs collent des tableaux stylés tandis que les éditeurs simples reçoivent du texte délimité par tabulations
CopyToClipboard écrit toujours une moitié en texte brut à côté du HTML, si bien que chaque cible de Word au Bloc-notes reçoit quelque chose d'honnête

SaveAsHTML n'est pas un chemin de rendu séparé boulonné juste pour le cas du presse-papiers. CopyToClipboard appelle exactement le même écrivain HTML décrit dans l'export CSV, TSV et HTML de HotXLS, puis enveloppe ce que produit cet écrivain dans l'enveloppe CF_HTML au lieu de l'enregistrer comme un fichier autonome, si bien que tout ce qui est vrai de ce HTML se transmet directement à ce qui atterrit sur le presse-papiers. Rassembler une plage de feuille de calcul dans les deux formats en un seul appel ressemble à ceci :

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    // Les plages classiques TXLSWorkbook exposent la méthode identique que
    // Workbook.Sheets[1].Range['A1', 'F40'].CopyToClipboard
    if Book.Sheets[1].Range['A1:F40'].CopyToClipboard then
      ShowMessage('Range copied - press Ctrl+V in Word or a browser')
    else
      ShowMessage('Clipboard was busy; see the retry pattern below');
  finally
    Book.Free;
  end;
end;

La plage collée conserve-t-elle ses polices, couleurs, et cellules fusionnées ?

Oui, car la moitié HTML de la charge utile est un rendu complet de la plage, pas un simple dépotoir de données : polices, couleurs de remplissage, bordures, formats de nombre, et cellules fusionnées passent toutes sous forme de styles en ligne et de structure de tableau, la même mécanique de style couverte dans le guide de HotXLS sur la mise en forme conditionnelle et le texte enrichi, puisque les segments de texte enrichi d'une cellule et le résultat de la mise en forme conditionnelle alimentent tous deux le même rendu dont CopyToClipboard tire ses informations. Ce qui ne survit pas au voyage, c'est le comportement de formule vivante : la forme en texte brut d'une cellule de formule porte la chaîne de formule, si bien qu'une cible de collage consciente des feuilles de calcul pourrait en principe la recalculer, mais la forme HTML ne porte jamais que le dernier résultat calculé, car HTML n'a aucune notion de formule qu'un navigateur ou un traitement de texte puisse évaluer

Vérifier le collage, et gérer un presse-papiers occupé

Deux habitudes attrapent la plupart des problèmes de presse-papiers avant qu'un client ne le fasse. Collez d'abord dans le Bloc-notes pour confirmer que le repli CF_UNICODETEXT est du texte délimité par tabulations sain, puis collez la même copie dans Word ou un navigateur pour confirmer que la version stylée apparaît — une charge utile qui a l'air correcte dans l'un et fausse dans l'autre signifie généralement que les marqueurs de fragment ont atterri au mauvais endroit. Traitez ensuite le résultat booléen que renvoie CopyToClipboard comme significatif, pas décoratif : OpenClipboard peut échouer lorsqu'un autre processus détient le presse-papiers ouvert, assez courant sur un bureau chargé pour qu'un appel non vérifié finisse par ne rien coller sans erreur pour expliquer pourquoi, ce contre quoi la nouvelle tentative ci-dessous protège :

function TryCopyRangeToClipboard(Workbook: TXLSXWorkbook): Boolean;
var
  Attempt: Integer;
begin
  Result := False;
  for Attempt := 1 to 5 do
  begin
    Result := Workbook.Sheets[1].Range['A1:F40'].CopyToClipboard;
    if Result then
      Break;
    Sleep(50);   // laissez un moment à l'application qui tient le presse-papiers
  end;
  if not Result then
    raise Exception.Create('Could not take ownership of the clipboard');
end;

Le format lui-même n'a rien d'exotique une fois que l'en-tête est précis à l'octet près et que le repli en texte brut est honnête sur ce qu'il contient — il existe pratiquement inchangé depuis qu'Internet Explorer l'a défini pour la première fois, et chaque application Windows majeure continue de le lire de la même façon. CopyToClipboard se trouve aux côtés de PasteFromClipboard, le côté lecture du même échange, dans la surface de presse-papiers et d'export plus large documentée sur la page produit du composant HotXLS