Article technique

Lire les actions des signets et des annotations PDF dans Delphi

Vous héritez d'un dossier de PDF venu d'amont, et la tâche semble triviale : dites-moi quels signets renvoient vers une URL externe, lesquels exécutent du JavaScript, et où pointent réellement ceux qui restent dans le document. Puis vous ouvrez la référence de l'API et découvrez que la bibliothèque sait créer chacune de ces actions, mais ne propose aucun moyen de les relire. Cette asymétrie est partout dans l'outillage PDF. Écrire un signet qui ouvre https://example.com est une opération en une ligne ; demander à un signet existant « que fais-tu, et vers quelle cible ? » revient souvent à parcourir à la main l'arbre d'objets brut via /A, /S, /Dest et toute une série de variantes de type d'ajustement que presque personne ne maîtrise du premier coup

PDF Library for Delphi est une bibliothèque PDF native en Object Pascal pour Delphi et C++Builder, et pendant longtemps elle a eu la même lacune : des accesseurs riches côté écriture, puis des getters qui vous rendaient un simple TPDFObject et vous laissaient fouiller dans les détails. La version v3.77.0 a comblé une partie de ce manque avec un petit ensemble d'appels d'introspection typés qui renvoient le type d'action, sa charge utile et la géométrie de destination sous forme de simples records. Cet article explique comment ces appels se mappent sur le modèle d'actions et de destinations d'ISO 32000-1, ainsi que les trois pièges concrets qui font échouer silencieusement une réécriture artisanale de ce code

Pourquoi la lecture des actions est plus difficile que leur écriture

Une action PDF est un dictionnaire doté d'une clé /S qui nomme son sous-type : GoTo, GoToR, URI, Launch, Named, JavaScript, et une queue plus longue qu'on rencontre rarement (ISO 32000-1 §12.6.4). Le problème, c'est que la charge utile se trouve dans une clé différente pour chaque sous-type, et qu'il n'existe aucun emplacement uniforme du genre « donne-moi la cible ». Une action URI conserve son adresse dans /URI. Une action GoToR ou Launch conserve une spécification de fichier dans /F. Une action JavaScript conserve son script dans /JS, qui peut être une chaîne ou un flux. Une action GoTo ne porte aucune charge utile propre ; sa cible est une destination, accrochée à /D, qu'il faut ensuite résoudre séparément

Quand vous écrivez une action, vous connaissez son type dès le départ, donc rien de tout cela ne compte. Quand vous en lisez une, vous devez d'abord aiguiller sur /S, puis aller chercher dans la bonne clé, puis gérer le fait que le même concept logique (« la chose vers laquelle pointe cette action ») est encodé de trois façons incompatibles. C'est exactement ce branchement que les getters typés absorbent. GetOutlineActionInfo et GetAnnotActionInfo renvoient tous deux un record TPDFlibActionInfo :

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // renseigné pour akURI
    JavaScript: WideString;   // renseigné pour akJavaScript
    FileName: AnsiString;     // renseigné pour akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

Le record vous indique quels champs sont pertinents grâce à Kind. Si Kind renvoie akURI, lisez URI et ignorez le reste. S'il renvoie akGoTo, aucun champ de charge utile ne s'applique et vous passez à la destination, un appel séparé traité plus loin. akNone est la réponse honnête lorsque le signet ou l'annotation n'a aucune action, plutôt qu'un zéro dont il faudrait deviner le sens

Parcourir l'arbre des signets pour trouver un signet

Avant de pouvoir introspecter un signet, il vous faut son handle. PDF Library for Delphi identifie les nœuds de plan (outline) par un ID entier, et FindOutlineByTitle en localise un par son texte visible, avec un contrôle explicite sur la profondeur de recherche :

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

L'argument Depth mérite qu'on s'y arrête. osdSiblingsOnly parcourt la chaîne de frères au niveau du nœud de départ et s'arrête là ; il trouvera un signet voisin mais ne descendra jamais dans les enfants d'un voisin. osdChildrenOnly regarde un niveau plus bas, dans les enfants directs du nœud de départ. osdFullSubTree parcourt récursivement toute la branche. Choisir le mauvais n'est pas une erreur mais un échec silencieux : une recherche limitée aux frères pour un titre situé deux niveaux plus bas renvoie simplement zéro, et vous en concluez que le signet n'existe pas alors qu'il était là depuis le début. Passez GetFirstOutline comme ID de départ pour chercher depuis la racine du document

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Cherche dans tout l'arbre depuis la racine un signet imbriqué.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID est maintenant un handle que vous pouvez passer aux
        // getters d'action et de destination ci-dessous.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

La correspondance se fait sur la chaîne de titre exacte, comparée en tant que WideString, donc elle est sensible à la casse et respecte le texte Unicode exactement tel qu'il est stocké. Si vos PDF source proviennent de producteurs incohérents, normalisez le titre que vous recherchez de la même façon que le document l'a stocké, sinon vous poursuivrez des échecs fantômes

Résoudre l'action et la cible d'un signet

Une fois le handle en main, GetOutlineActionInfo vous donne la vue typée. Le schéma est : appelez-le, aiguillez sur Kind, lisez le champ que ce type renseigne

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // voir la destination ci-dessous
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

C'est ici que se trouve le premier vrai piège, celui que les retours de tests ont mis au jour pendant l'implémentation. Il existe un getter plus ancien, GetActionURL, et s'en servir pour lire une action URI est l'erreur qui semble la plus naturelle. GetActionURL résout une spécification de fichier via la clé /F. C'est le bon choix pour GoToR et Launch, dont les cibles sont réellement des fichiers, mais c'est la mauvaise clé pour une action URI. L'adresse d'une action URI est une simple chaîne dans sa propre clé /URI, pas une spécification de fichier. Donnez une action URI au chemin de résolution de fichier et vous obtenez un résultat vide ou incohérent. Le getter typé gère cela en interne en lisant directement /URI pour akURI et en n'invoquant le résolveur de spécification de fichier que pour akGoToR et akLaunch, exactement la distinction qu'une version écrite à la main a tendance à brouiller

Types d'ajustement de destination et géométrie sous-jacente

Une action akGoTo signifie « naviguer dans ce document », mais elle ne vous dit rien sur où ni comment. C'est le rôle de la destination, et les destinations portent plus de nuances qu'on ne l'imagine. Une destination PDF n'est pas un simple numéro de page ; c'est une page plus une spécification d'« ajustement » (fit) qui indique comment le lecteur doit cadrer cette page (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo la renvoie sous forme de record :

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // base 1 ; 0 si non résolu
    Left, Top, Right, Bottom, Zoom: Double;
  end;

Les huit types d'ajustement répondent à des questions de cadrage différentes. dkXYZ positionne un point précis au coin supérieur gauche à un zoom explicite, donc il utilise Left, Top et Zoom. dkFit ajuste la page entière dans la fenêtre et ignore les coordonnées. dkFitH et dkFitV ajustent la largeur ou la hauteur de la page avec une seule coordonnée pertinente (un bord supérieur ou un bord gauche). dkFitR est le plus intéressant : il ajuste un rectangle spécifié, donc les quatre bords comptent. La famille dkFitB* fait la même chose mais relativement à la boîte englobante du contenu visible plutôt qu'à la page entière. Savoir quels champs sont actifs pour chaque type fait la différence entre lire correctement une destination et afficher des coordonnées incohérentes qui se trouvent être à zéro

Panneau de navigation par signets d'un lecteur PDF montrant un arbre de signets imbriqué
Chaque signet de ce panneau de navigation se résout en une action et, pour les sauts internes, une destination avec son propre type de cadrage et ses coordonnées

Sous le capot, l'implémentation s'appuie sur un alignement délibéré qui mérite d'être connu, car il explique pourquoi le mappage est fiable. La fonction interne GetDestType renvoie un entier de 1 à 8 pour les huit types d'ajustement, exactement dans l'ordre XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind est déclaré de façon à ce que ses ordinaux s'alignent un pour un : dkXYZ est l'ordinal 1, dkFitBV est l'ordinal 8, avec dkNone à zéro. La conversion est donc un simple cast ordinal direct avec un garde-fou de plage, et non une table de correspondance qui pourrait se désynchroniser à mesure que l'énumération grandit. C'est un petit détail, mais c'est le genre de chose qui, traitée de façon naïve, devient un bug de décalage d'un cran dès que quelqu'un réordonne une énumération

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // la destination ne s'est pas résolue
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

Une Page à zéro est le signal que la destination ne s'est pas résolue, généralement parce que l'action ne porte aucune destination ou que la destination nommée n'a pas pu être trouvée. Vérifiez-le avant de faire confiance à une quelconque coordonnée. Notez aussi que GetOutlineDestinationInfo cherche aux deux endroits où une destination peut se trouver : directement sur le /Dest du signet, et à l'intérieur du /D d'une action GoTo intégrée. Vous n'avez pas besoin de savoir quelle forme le producteur a utilisée

Actions d'annotation et piège SelectPage

Les annotations de lien portent des actions exactement comme le font les signets, et GetAnnotActionInfo renvoie le même record TPDFlibActionInfo avec le même schéma type-puis-charge-utile. Mais il y a ici un piège lié à l'état qui ne s'applique pas aux plans (outlines), et c'est le troisième piège

Les annotations appartiennent aux pages, et PDF Library for Delphi expose les annotations de la page courante via un état qui ne devient valide qu'après avoir sélectionné cette page. Appelez GetAnnotActionInfo sans avoir d'abord appelé SelectPage(N) et le handle d'annotation est zéro ; l'appel renvoie akNone et vous en concluez à tort que la page n'a aucune annotation actionnable. Le correctif tient en une ligne, mais il est facile de l'oublier quand on boucle sur les pages :

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // obligatoire avant de toucher aux annotations
    // GetAnnotActionID(1) <> 0 est le test fiable pour « a une
    // action ». CheckPageAnnots renvoie un indicateur de type booléen,
    // pas un compte, c'est donc ici un signal plus faible.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

Deux choses dans cette boucle sont délibérées. D'abord, SelectPage(P) vient avant tout accès aux annotations à chaque itération ; l'état des annotations par page ne se reporte pas d'une page à l'autre. Ensuite, le test d'existence utilise GetAnnotActionID(1) <> 0 plutôt que CheckPageAnnots. Ce dernier signale la présence sous forme d'indicateur de type booléen plutôt que d'un compte, donc un ID d'action non nul est la façon la plus précise de demander « y a-t-il une première annotation, et porte-t-elle une action que je peux lire ? » Une autre subtilité à signaler : pour les annotations, le script d'une action JavaScript est lu directement depuis /JS, en décodant un flux quand le script est stocké ainsi et en lisant une chaîne sinon, ce qui le fait survivre aux deux encodages courants

Où se situe l'introspection côté lecture

Ces getters sont intentionnellement étroits. Ce sont des lectures pures construites au-dessus des couches existantes d'action et de destination à handle entier de la bibliothèque, donc ils ne touchent aucun chemin d'écriture et n'ajoutent aucun risque aux documents que vous êtes par ailleurs en train d'éditer. Ils rapportent ce qui se trouve dans le fichier ; ils ne le valident pas par rapport à une politique et ne réécrivent rien. Si votre objectif est l'inverse, construire des signets et des annotations de lien qui portent ces actions dès le départ, cela relève du côté écriture, et l'article compagnon sur les actions de formulaire interactif et JavaScript en Delphi vous montre comment les créer. Pour extraire d'un PDF le contenu visible et structurel plutôt que son graphe de navigation, voir extraction du texte, des images et des polices avec PDF Library for Delphi

La frontière honnête à garder en tête est la suivante : l'introspection ne voit que ce que le producteur a réellement écrit. Un signet dont l'action a été laissée mal formée par un générateur, ou une destination pointant vers une cible nommée qui n'a jamais été définie, apparaîtra comme akNone ou comme une page zéro plutôt que comme une exception. C'est le bon comportement pour une API de lecture qui audite des fichiers non fiables, mais cela signifie que votre code doit traiter ces zéros comme « absent ou non résolu », et non comme la garantie d'une entrée bien formée. L'introspection typée des actions et des destinations présentée ici fait partie de PDF Library for Delphi, la bibliothèque PDF native pour Delphi et C++Builder