Article technique

Liens hypertexte Delphi HotPDF : Conseils pour l'annotation PrintHyperlink

Les liens hypertexte PDF sont des annotations URI : un rectangle couvrant une zone de la page qui, lorsqu'on clique dessus, indique à la visionneuse d'ouvrir une URL. L'annotation et le texte en dessous sont des objets complètement indépendants. PrintHyperlink de HotPDF regroupe les deux en un seul appel, dessinant le texte et calculant le rectangle d'annotation à partir des métriques de texte rendues. Cette commodité cache un détail qui vaut la peine d'être compris avant d'écrire du code de production. Ce n'est pas non plus toute l'histoire : AddURILink place une zone cliquable sur le contenu que vous avez dessiné vous-même, et AddGoToLink gère la navigation interne — les deux sont abordés ci-dessous

Comment fonctionne PrintHyperlink

PrintHyperlink se trouve sur THPDFPage et prend quatre arguments : les coordonnées X et Y (en points, origine en bas à gauche, Y augmentant vers le haut), la chaîne d'étiquette (label string) à dessiner et la cible de l'URL. En interne, il appelle TextOut dans la couleur de lien hypertexte actuelle, puis calcule immédiatement le rectangle d'annotation à partir de TextWidth et TextHeight avec les métriques de police actuelles. Cela signifie que la police et la taille doivent être définies avant l'appel, et elles ne doivent pas changer entre le dessin de l'étiquette et le placement de l'annotation, car les deux sont résolus dans le même appel

La couleur par défaut est clBlue. SetRGBHyperlinkColor ne la modifie que pour les appels ultérieurs ; il ne met pas à jour rétroactivement les annotations déjà écrites. Si vous avez besoin de couleurs différentes pour différents groupes de liens sur la même page, appelez SetRGBHyperlinkColor avant chaque groupe et réinitialisez-la ensuite

Voici un document minimal qui écrit trois liens avec deux couleurs différentes :

procedure CreateLinkedReport(const FileName: string);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;

    Pdf.CurrentPage.SetFont('Arial', [], 11);

    // Default blue for informational links
    Pdf.CurrentPage.TextOut(50, 750, 0, 'Reference links:');
    Pdf.CurrentPage.PrintHyperlink(50, 720, 'Product page', 'https://www.loslab.com/en-us/pdf-library/delphi-pdf-component.html');
    Pdf.CurrentPage.PrintHyperlink(50, 695, 'Online manual', 'https://www.loslab.com/en-us/pdf-library/delphi-pdf-component.html');

    // Red for the action link
    Pdf.CurrentPage.SetRGBHyperlinkColor(clRed);
    Pdf.CurrentPage.PrintHyperlink(50, 660, 'Purchase license', 'https://www.loslab.com/en-us/buy-hotpdf-fastspring.html');
    Pdf.CurrentPage.SetRGBHyperlinkColor(clBlue);  // restore default

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Le piège des coordonnées

HotPDF utilise une origine en bas à gauche avec Y croissant vers le haut, en points (1/72 de pouce). Une page A4 mesure 595 x 842 pt ; une page Lettre US mesure 612 x 792 pt. Y = 750 se trouve près du haut d'une page A4, et Y = 50 serait près de la marge inférieure. Quiconque vient des graphiques d'écran ou du HTML suppose le contraire et place la première ligne de lien directement hors de la zone visible

Le rectangle d'annotation que calcule PrintHyperlink utilise le même système de coordonnées. Si vous faites pivoter la page ultérieurement, la mettez à l'échelle ou modifiez la taille de la page sans recalculer vos valeurs X/Y, le texte visible et le rectangle cliquable s'écarteront l'un de l'autre. Le lien « fonctionne » dans le sens où un clic à proximité du texte déclenche l'URL, mais la zone réactive ne correspond plus à ce que le lecteur voit. Testez sur la taille de page et le niveau de zoom réels que vous livrez, pas seulement sur la machine de développement à 100 %

Un cas où la dérive (drift) est garantie : si vous appelez PrintHyperlink avec des coordonnées appropriées pour une page A4, puis passez à une page au format étroit personnalisé sans ajuster les valeurs X/Y, l'annotation peut se retrouver entièrement en dehors de la page. L'objet d'annotation est toujours écrit dans le PDF ; la plupart des visionneuses le découpent (clip) silencieusement, de sorte que le lien disparaît simplement sans aucune erreur

Texte de l'étiquette par rapport à la cible de l'URL

Les arguments Text et Link sont indépendants. Vous pouvez dessiner « Download invoice PDF » (Télécharger le PDF de la facture) alors que la cible est une URL HTTPS complète (fully qualified) avec des paramètres de requête. Cette séparation est délibérée ; l'étiquette visible doit être lisible par l'homme et l'URL peut être longue ou générée de manière dynamique

Ce qui pose problème, c'est lorsque l'étiquette est l'URL brute elle-même, surtout si elle est longue. Si l'URL s'enroule visuellement sur deux lignes mais que le rectangle d'annotation a été calculé pour une chaîne d'une seule ligne, seule la première ligne est cliquable. PrintHyperlink ne gère pas le flux multiligne ; gardez l'étiquette suffisamment courte pour tenir sur une ligne avec la taille de police et la largeur de page actuelles, utilisez une courte étiquette descriptive avec l'URL complète comme cible, ou appliquez la solution de contournement par ligne présentée dans la section suivante

Pour les documents qui seront archivés ou distribués sans connexion Internet active, déterminez également si l'URL elle-même doit apparaître sous forme imprimée quelque part dans le corps du document, et pas seulement sous forme de métadonnées d'annotation. Un lecteur qui imprime le PDF sur papier ne tire aucun bénéfice d'une annotation URI

Contourner la limitation multiligne

Lorsqu'une étiquette de lien doit véritablement s'étendre sur plus d'une ligne (une longue URL imprimée textuellement ou une phrase renvoyée à la ligne qui doit être cliquable de bout en bout), la solution consiste à cesser de la traiter comme un seul lien et à la traiter comme un lien par ligne. Chaque appel à PrintHyperlink calcule son rectangle à partir du texte qu'il dessine, de sorte que plusieurs appels partageant la même cible Link produisent plusieurs annotations de taille correcte qui ouvrent toutes la même URL. Le lecteur ne peut pas voir la différence ; chaque ligne répond à un clic

procedure PrintWrappedHyperlink(Page: THPDFPage; X, TopY, LineStep: Single;
  const Lines: array of AnsiString; const Link: AnsiString);
var
  I: Integer;
begin
  for I := 0 to High(Lines) do
    Page.PrintHyperlink(X, TopY - I * LineStep, Lines[I], Link);
end;

// Usage: break the label at the positions where your layout wraps it
Pdf.CurrentPage.SetFont('Arial', [], 10);
PrintWrappedHyperlink(Pdf.CurrentPage, 50, 400, 14,
  ['https://www.loslab.com/en-us/pdf-library/',
   'delphi-pdf-component.html'],
  'https://www.loslab.com/en-us/pdf-library/delphi-pdf-component.html');

Le fractionnement de la chaîne est de votre responsabilité : cassez-la (break it) aux mêmes positions où elle s'enroulerait visuellement avec la police et la largeur de colonne actuelles, en utilisant TextWidth pour tester chaque ligne candidate. L'alternative consiste à dessiner vous-même le texte renvoyé à la ligne avec de simples appels TextOut, puis à superposer un rectangle AddURILink sur chaque ligne — la meilleure voie lorsque le texte est déjà produit par votre propre logique de retour à la ligne (word-wrap logic), ce qui nous amène à cette fonction

AddURILink : zones cliquables sur tout ce que vous avez dessiné

PrintHyperlink est un wrapper de commodité : il dessine sa propre étiquette et dérive le rectangle des métriques de cette étiquette. AddURILink est la moitié de niveau inférieur exposée directement :

function AddURILink(Rectangle: TRect; const URL: AnsiString;
  const Description: AnsiString = ''): THPDFDictionaryObject;

Il n'écrit que l'annotation : aucun texte n'est dessiné et aucune couleur ne change. Le Rectangle est interprété dans le même espace de coordonnées que vos appels de dessin, vous pouvez donc réutiliser les valeurs X/Y exactes que vous avez passées à TextOut ou à un appel d'image. Cela en fait le bon outil chaque fois que le contenu visible existe déjà : une zone réactive (hotspot) d'image, une cellule de tableau, un bloc de texte dessiné plus tôt ou une ligne d'un paragraphe renvoyé à la ligne comme dans la solution de contournement ci-dessus. L'annotation porte une bordure de largeur nulle (zero-width border), donc rien de visible ne change ; la région cliquable est exactement le rectangle que vous spécifiez

La fonction renvoie le dictionnaire d'annotation sous forme de THPDFDictionaryObject. La plupart des appelants ignorent le résultat, mais le conserver vous permet d'ajuster les entrées de l'annotation avant l'écriture du document

Deux détails de conformité sont intégrés. Dans les modes PDF/A, le drapeau d'impression de l'annotation est défini comme l'exigent ces normes. Sous PDFUACompliance, le paramètre Description doit être une chaîne non vide — il devient l'entrée /Contents de l'annotation, qui est ce que la technologie d'assistance annonce pour le lien — et l'appel lève une exception plutôt que d'émettre silencieusement un fichier non conforme. PrintHyperlink est antérieur à cette règle et n'attache aucune description, donc pour la sortie PDF/UA, dessinez l'étiquette avec TextOut et placez l'annotation avec AddURILink plus une description significative

La règle de décision est simple : utilisez PrintHyperlink lorsque le lien est un court morceau de texte que vous n'avez pas encore dessiné ; utilisez AddURILink lorsque la région cliquable est définie par un contenu que vous dessinez ou mesurez vous-même

Navigation interne avec AddGoToLink

Les URL externes ne représentent que la moitié de ce que font les annotations de lien. L'autre moitié est la navigation à l'intérieur du document : une table des matières qui saute aux chapitres, des références croisées entre les sections. HotPDF expose cela via AddGoToLink :

procedure AddGoToLink(Rectangle: TRect; TargetPageIndex: Integer;
  YPos: Single = -1; const Description: AnsiString = '');

Il convient de préciser trois sémantiques, car aucune n'est devinable à partir de la signature. TargetPageIndex est de base zéro (zero-based) : la première page du document est la page 0, correspondant à CurrentPageNumber. La page cible doit déjà exister lorsque vous effectuez l'appel ; si l'index est hors limites, la procédure revient sans ajouter d'annotation — pas d'exception, pas de lien, pas d'avertissement. Pour une table des matières qui pointe vers l'avant, créez d'abord toutes les pages, puis revenez en arrière et ajoutez les liens

YPos sélectionne la position verticale sur la page cible, dans le même espace de coordonnées que vos appels de dessin. La valeur par défaut de -1 (toute valeur négative) écrit une coordonnée de destination nulle, indiquant à la visionneuse de conserver sa position verticale actuelle lorsqu'elle atterrit sur la page cible. Passez une valeur non négative et la visionneuse fait défiler le document de sorte que cette position se trouve en haut de la fenêtre — utilisez la coordonnée Y de l'en-tête vers lequel vous créez le lien. Le zoom est toujours laissé inchangé. Comme pour AddURILink, Description doit être non vide sous PDFUACompliance et devient le texte alternatif du lien

procedure BuildLinkedTOC(const FileName: string);
const
  Chapters: array[0..2] of string =
    ('Introduction', 'Installation', 'API Reference');
var
  Pdf: THotPDF;
  I, Y: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;                        // page 0 becomes the TOC page

    // Create the chapter pages first so the link targets exist
    for I := 0 to High(Chapters) do
    begin
      Pdf.AddPage;                       // pages 1..3
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
      Pdf.CurrentPage.TextOut(50, 780, 0, Chapters[I]);
    end;

    // Switch back to page 0 and draw the TOC entries with their links
    Pdf.CurrentPageNumber := 0;
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Contents');
    Pdf.CurrentPage.SetFont('Arial', [], 11);

    Y := 720;
    for I := 0 to High(Chapters) do
    begin
      Pdf.CurrentPage.TextOut(70, Y, 0, Chapters[I]);
      Pdf.CurrentPage.AddGoToLink(
        Rect(70, Y + 14, 300, Y - 3),    // covers the entry with padding
        I + 1,                           // zero-based: chapters are pages 1..3
        780,                             // land with the heading at the top
        AnsiString('Go to ' + Chapters[I]));
      Y := Y - 25;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Chaque entrée obtient un rectangle plus large que le texte afin que toute la ligne réponde au pointeur, et chaque lien atterrit avec l'en-tête du chapitre (dessiné à Y = 780) en haut de la fenêtre. Si vous insérez ultérieurement une page avant les chapitres, chaque TargetPageIndex se décale de un ; calculez les index à partir de votre boucle de création de page plutôt que de les coder en dur (hard-coding)

Un exemple complet de génération de documents

Le modèle ci-dessous montre un scénario plus réaliste : générer un court rapport avec une section d'en-tête, un corps de texte et une ligne de liens en pied de page, le tout à partir du code plutôt qu'à partir d'un formulaire avec des champs TEdit :

procedure GenerateProductSheet(
  const FileName, ProductName, ProductURL, SupportURL: string);
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Compression := cmFlateDecode;
    Pdf.BeginDoc;

    // Header
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 750, 0, WideString(ProductName));

    // Body paragraph placeholder
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 710, 0, 'See the links below for full documentation.');

    // Footer links
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 80, 0, 'Links:');
    Pdf.CurrentPage.PrintHyperlink(50, 60, 'Product page', ProductURL);
    Pdf.CurrentPage.PrintHyperlink(200, 60, 'Support', SupportURL);

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Notez que SetFont est appelé avant chaque groupe d'appels de texte. La police ne persiste pas à travers AddPage, et si vous oubliez de la définir avant PrintHyperlink sur une nouvelle page, le rectangle d'annotation sera calculé en fonction des métriques par défaut de la page, qui peuvent différer de ce à quoi vous vous attendez

Où la gestion des annotations varie selon les visionneuses

Les annotations URI PDF sont définies dans l'ISO 32000-1 §12.6.4.7, et toute visionneuse conforme doit les suivre. En pratique, quelques comportements diffèrent selon la visionneuse. Adobe Acrobat affiche une invite de sécurité au premier clic pour les URL qui ne figurent pas dans la liste des domaines de confiance ; de nombreux navigateurs et lecteurs légers (lightweight readers) ne le font pas. Certaines visionneuses de PDF d'entreprise dans des environnements verrouillés désactivent complètement les annotations URI par politique, de sorte qu'un clic ne fait rien, sans erreur visible. Les applications PDF mobiles varient selon qu'elles ouvrent les liens à l'intérieur de la vue Web de l'application ou qu'elles les transmettent au navigateur système

Aucun d'entre eux n'est un bogue que vous pouvez corriger du côté de la génération ; ce sont des décisions de politique de la visionneuse. Ce que vous pouvez faire, c'est écrire des étiquettes de lien qui rendent l'URL visible dans le corps du document également, afin qu'un lecteur dans un environnement restreint puisse toujours copier l'adresse manuellement. L'annotation est la commodité ; le texte est la solution de repli (fallback)

Un autre détail qu'il vaut la peine de connaître : les annotations URI PDF ne comportent aucun soulignement visuel par défaut. Le soulignement que vous voyez dans la plupart des visionneuses est dessiné par la visionneuse elle-même en fonction du type d'annotation, et non par un glyphe dans le flux de contenu. Si vous avez besoin d'un soulignement physique qui survit à l'impression vers un dispositif de rendu non interactif (non-interactive renderer) ou à la conversion PDF en image, dessinez-le explicitement avec LineTo et Stroke au décalage Y approprié sous la ligne de base du texte. C'est une opération de dessin distincte, et non quelque chose que PrintHyperlink gère pour vous

L'API de lien hypertexte présentée ici fait partie du Composant HotPDF pour Delphi et C++Builder