Article technique

Supprimer une page PDF en Delphi : élaguer les références

Un rapport balisé amputé de sa page de couverture peut s'ouvrir avec le bon nombre de pages et rester cassé à trois endroits : le signet « Contents » ne fait rien au clic, le contrôleur d'accessibilité signale un élément de structure qui n'appartient à aucune page, et un validateur strict relève une référence vers un objet libre. L'arbre de pages, lui, est correct. ISO 32000-1, §7.7.3, demande seulement que la page quitte /Kids et que /Count baisse d'une unité, et un fichier peut satisfaire cette clause pendant que la moitié de son catalogue pointe encore vers la page manquante

Sept structures peuvent détenir une référence vers une page

Une seule d'entre elles est l'arbre de pages. Les autres se répartissent entre le catalogue, les signets, la structure logique et les annotations des autres pages, et chacune d'elles résout vers null dès que l'objet page a disparu du fichier. Ce que fait un lecteur avec ce null reste son affaire, si bien que le même fichier élagué peut mal se comporter différemment dans deux lecteurs

  • L'arbre de noms sous /Names /Dests (§7.7.4, §12.3.2.3) associe des noms à des tableaux de destination dont le premier élément est la page
  • Le dictionnaire /Dests antérieur à la 1.2, directement dans le catalogue, contient le même genre de tableaux, indexés par nom
  • Les éléments de signet (§12.3.3) atteignent une page par un /Dest en ligne ou par une action /A avec /S /GoTo et un tableau /D
  • Les éléments de structure (§14.7.2) portent une clé /Pg qui nomme la page où vit leur contenu marqué, et leurs enfants /K peuvent être des références de contenu marqué et des références d'objet (§14.7.4.3) liées à cette page
  • Le ParentTree (§14.7.4.4) ramène les numéros /StructParents des pages et annotations vers des éléments de structure, et un élément peut s'y trouver sans apparaître nulle part sur la chaîne /K depuis la racine
  • Les annotations de lien des autres pages (§12.5.6.5) portent un /Dest ou une action /GoTo visant la page, et le /OpenAction du catalogue peut faire de même
Pourquoi retirer une page HotPDF de /Kids ne suffit pas : ISO 32000-1 laisse l'arbre de noms /Names /Dests, le dictionnaire /Dests hérité du catalogue, les éléments de signet, les éléments de structure avec /Pg, le ParentTree, les annotations de lien et /OpenAction tous détenir une référence vers le même objet page, et seul l'arbre de pages est reconstruit
Une page PDF est une cible vers laquelle la moitié du catalogue pointe : retirer la feuille satisfait l'arbre de pages pendant que tous les autres pointeurs résolvent vers null, si bien qu'un rapport élagué perd son signet Contents et échoue à son contrôle d'accessibilité

Ce que THotPDF.DeletePage fait avant de toucher à /Kids

THotPDF.DeletePage(PageIndex) sur un document chargé balaye d'abord chacune de ces structures et réécrit l'arbre de pages en dernier. L'index part de zéro. Deux gardes s'exécutent avant que quoi que ce soit ne change : un index hors bornes lève Invalid page number, et une demande de suppression de la dernière page restante lève aussi une exception, car un nœud /Pages sans aucun enfant ne décrit pas un document valide

L'ordre du balayage est fixe : l'arbre /Names /Dests, le dictionnaire /Dests hérité du catalogue, le /OpenAction, les signets, /StructTreeRoot avec son ParentTree, et enfin les tableaux /Annots de chaque page qui reste. Ce n'est qu'après cela que l'objet page est marqué supprimé, que ses annotations widget sont détachées de l'arbre de champs AcroForm, et que /Kids, /Count et le /Parent de chaque page survivante sont réécrits. Côté formulaire, un champ parent resté sans enfants après le départ de son widget est supprimé lui aussi, jusqu'à /Fields, si bien qu'un formulaire élagué ne garde aucune entrée de champ sans rien à l'écran derrière

Le balayage de références fixe que THotPDF.DeletePage exécute avant que l'arbre de pages ne soit touché : les gardes rejettent un index hors bornes ou la dernière page, puis /Names /Dests et le /Dests hérité sont élagués, le /OpenAction est abandonné, les destinations de signets sont déplacées vers la page conservée la plus proche, StructTreeRoot et ParentTree sont élagués, les liens des pages conservées sont retirés, et /Kids, /Count et /Parent sont réécrits en dernier
Chaque structure reçoit le traitement que la spécification permet : les noms disparaissent, les signets atterrissent sur la page conservée la plus proche, les éléments de structure perdent /Pg ou disparaissent, et la réécriture de /Kids n'intervient qu'une fois que plus rien ne peut atteindre l'objet supprimé

DeletePages prend une chaîne de plage à base un comme '1,3-5,7-', la notation qu'utilisent les autres opérations sur les pages d'un document chargé, et supprime depuis la page sélectionnée la plus haute en descendant. Procéder de haut en bas garde valides les numéros que vous avez écrits pendant que les pages disparaissent dessous. Une plage qui couvre toutes les pages lève une exception pour la même raison qui fait refuser la dernière page à DeletePage, et la fonction renvoie le nombre de pages supprimées

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // LoadFromFile renvoie le nombre de pages chargées
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
      // Base zéro : la page 0 est la couverture
      Pdf.DeletePage(0);
      // Plage à base un, numérotée sur le document tel qu'il est maintenant
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Les noms disparaissent, les signets se déplacent

Les destinations nommées qui visaient la page supprimée sont retirées. Les signets qui la visaient sont reciblés. Dans l'arbre /Names /Dests, chaque nœud est visité et chaque destination est testée sous les deux formes que la spécification permet, le tableau nu et le dictionnaire avec une clé /D ; la paire nom/valeur part quand le premier élément du tableau est la page supprimée. Un nœud intermédiaire resté sans /Names ni /Kids est délié de son parent, si bien que l'arbre ne garde pas de feuilles creuses. Le dictionnaire /Dests hérité du catalogue subit le même test, clé par clé, et un /OpenAction du catalogue qui s'ouvrait sur la page supprimée est abandonné, ce qui laisse la vue d'ouverture au réglage par défaut du lecteur

Un nœud qui perd des entrées perd aussi son tableau /Limits. Les nouvelles clés minimale et maximale ne sont pas recalculées, et les lecteurs résolvent les noms sans elles. §7.9.6 exige bien un /Limits sur les nœuds intermédiaires et les feuilles, alors si une étape de preflight de votre chaîne fait respecter cette règle à la lettre, faites passer le fichier élagué par cette étape dans le même travail

Les signets sont parcourus par /First et /Next avec une liste de visités et une limite de profondeur de 128, si bien qu'une chaîne /Next corrompue qui boucle sur elle-même ne peut pas bloquer l'appel. Pour chaque tableau /Dest ou tableau /D d'un /GoTo visant la page supprimée, le premier élément est remplacé par la page qui la suivait, ou par la page qui la précédait quand la page supprimée était la dernière. Les paramètres de vue après la référence de page restent tels quels, si bien qu'un saut /XYZ garde ses valeurs left, top et zoom et les applique à la nouvelle page

L'asymétrie est délibérée. Un nom qui ne résout vers rien est légal et sans danger ; un GoTo qui nomme une destination manquante ne fait simplement rien. Un signet avec une destination null, c'est pire : il reste assis dans le panneau latéral, il a l'air cliquable, et il échoue. Atterrir sur la page survivante la plus proche est le meilleur de deux résultats imparfaits. Le reciblage ne couvre que les tableaux de destination explicites. Un élément de signet dont /Dest est une chaîne de nom se résout par l'arbre de noms, et une fois que le balayage a retiré ce nom, le signet ne mène nulle part, alors les documents qui font un usage intensif des signets nommés méritent une vérification après la suppression. Le contrôle ci-dessous lit les noms avec ResolveLoadedNamedDestination, qui ne regarde que le tableau /Names du nœud racine ; il donne une réponse significative pour un arbre de noms plat, alors qu'un arbre découpé en /Kids renvoie -1 pour chaque nom. La structure des signets elle-même, avec la sémantique de /Count qui ne saute pas aux yeux, est couverte dans le guide d'ajout de signets et de destinations nommées sur un PDF chargé

// Vérifiez le résultat au lieu de le présumer.
// Memo1 est un TMemo sur votre propre fiche.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination ne lit que le tableau /Names racine,
// ce contrôle est donc significatif pour un arbre de noms plat ; sur
// un arbre en /Kids il renvoie -1 pour chaque nom, supprimé ou non
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Un signet qui pointait vers la couverture se résout maintenant vers la page
// qui la suivait, soit l'index 0 en base zéro après la suppression
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

Élaguer l'arbre de structure et le ParentTree

Les éléments de structure qui n'existaient que pour la page supprimée sont retirés, et les éléments qui s'étendent sur plusieurs pages perdent leur clé /Pg mais gardent leurs enfants. Le parcours part de /StructTreeRoot, descend /K jusqu'à une profondeur de 128, et accepte aussi bien la forme tableau que la forme dictionnaire unique de /K que §14.7.2 permet. Les enfants de chaque élément sont élagués avant que l'élément lui-même ne soit jugé. Si l'élagage a vidé son /K, l'élément est retiré et son parent l'abandonne. Si son propre /Pg nomme la page supprimée mais qu'il a encore des enfants et un parent /P, seul /Pg part, car /Pg sur un élément n'est que la page par défaut pour les enfants de contenu marqué qui ne nomment pas la leur, et ces enfants peuvent très bien vivre sur d'autres pages. Un élément dont /Pg est la page supprimée et qui n'a rien sous lui est retiré sans autre forme de procès ; les dictionnaires de référence de contenu marqué dont /Pg nomme la page relèvent de cette dernière règle

Le ParentTree reçoit le même traitement, pour une raison facile à manquer : un élément peut être accessible depuis le ParentTree et depuis nulle part ailleurs. Cet arbre de nombres associe des entiers /StructParents à un élément unique ou à un tableau d'éléments. Chaque valeur est élaguée avec les règles ci-dessus, les valeurs retirées sortent de leurs tableaux, une paire /Nums dont le tableau finit vide est supprimée, et un nœud sans /Nums ni /Kids est délié. Sautez cette passe et les orphelins continuent de pointer vers une page libérée par /Pg. Pour quiconque extrait du texte dans l'ordre des balises, c'est direct : l'extraction de texte dans l'ordre de la structure parcourt exactement ces arbres, et un élément avec un /Pg null est un paragraphe qui sort de l'ordre de lecture

Pourquoi les liens des pages survivantes sont-ils retirés plutôt que reciblés ?

Une annotation de lien sur une page conservée dont le tableau /Dest ou l'action /GoTo vise la page supprimée est retirée des /Annots de cette page, et son étiquette part avec elle. Le dictionnaire OBJR dont /Obj nommait l'annotation est sorti de son élément de structure ; si cet OBJR était le seul enfant de l'élément, l'élément est retiré aussi, et le ParentTree est élagué une seconde fois. Laisser l'OBJR derrière briserait §14.7.4.3, qui attend de /Obj qu'il référence un objet réel, et un contrôleur PDF/UA verrait un élément Link dont la référence d'objet ne mène nulle part

Les signets se déplacent ; les liens non. Un renvoi dans le corps du texte qui dit « voir page 3 » est faux dès que la page 3 a disparu, et le pointer tranquillement vers la page 4 transforme un lien cassé en lien trompeur. Un signet qui atterrit sur le chapitre suivant, c'est de la navigation ; un lien dans le corps est une affirmation sur le contenu. La suppression est la valeur par défaut la plus sûre. Si votre flux a besoin que ces liens survivent, réécrivez leurs destinations avant d'appeler DeletePage, car après l'appel ils sont déjà sortis des tableaux /Annots

Enregistrements incrémentaux et le piège des enfants directs

DeletePage se comporte pareillement entre BeginIncrementalUpdate et SaveIncrementalUpdate. La section appendue contient les objets que le balayage a changés plus des entrées libres pour les objets supprimés qui avaient un numéro d'objet en propre, comme la page et les annotations de lien supprimées. Tout ce qui est au-dessus du %%EOF d'origine reste identique octet par octet, comme §7.5.6 l'exige

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Les éléments de structure qui ont perdu un enfant /MCR direct
  // sont appendus sous forme réécrite avec un tableau /K plus court
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

C'est exactement là qu'un élagueur écrit à la main se plante. Les références de contenu marqué (/Type /MCR) et les références d'objet (/Type /OBJR) sont typiquement des dictionnaires directs assis dans le tableau /K de leur élément parent. Un objet direct n'a ni numéro d'objet ni entrée xref, il ne peut donc pas être libéré tout seul. Le geste évident est de « libérer » quand même chaque enfant retiré, et tout écrivain qui associe un objet direct à l'objet indirect qui le porte libérera alors le porteur, qui est l'élément de structure conservé. Cet élément sort en null, et le contenu balisé de pages jamais touchées perd ses balises. HotPDF n'envoie un objet supprimé dans la liste libre que lorsqu'il a un numéro d'objet ; pour un enfant direct, il marque l'élément porteur comme changé, si bien que l'élément est réécrit sans l'enfant et que la liste libre reste propre

Pourquoi un enfant /MCR ou OBJR retiré ne doit jamais être enregistré comme libre dans une mise à jour incrémentale : c'est un dictionnaire direct dans le tableau /K de son élément, alors le libérer reviendrait à libérer l'élément de structure porteur, qui serait écrit en null et débaliserait les pages survivantes, tandis que le geste correct réécrit le conteneur et laisse la liste libre tranquille
Seuls les objets avec un numéro d'objet en propre vont dans la liste libre, si bien qu'un enregistrement incrémental appende les conteneurs réécrits plus des entrées libres pour la page et les autres objets indirects supprimés

La même prudence décide de ce que DeletePage laisse tranquille dans un document chargé. Les flux de contenu, XObjects et annotations non widget de la page supprimée restent en tant qu'objets, car un fichier chargé peut partager n'importe lequel d'entre eux avec une page qui reste et il n'y a pas de moyen bon marché de prouver le contraire au moment de la suppression. Retirer la référence de l'arbre de pages suffit à produire un fichier correct. Combien d'octets ces restes coûtent encore est une mesure à part, et le graphe de dépendance d'objets et l'analyse des octets retenus est l'outil pour cela

DeletePage ou DeleteLoadedPage ?

Utilisez DeletePage pour toute suppression de page demandée par un utilisateur. THotPDF.DeleteLoadedPage(PageIndex) est la variante légère : elle applique les deux mêmes gardes, décale la liste des pages chargées, réécrit /Kids et /Count, invalide le cache des pages rendues et déclenche OnLoadedDocumentModified. Elle ne touche ni l'arbre de noms, ni les signets, ni l'arbre de structure, ni les annotations des autres pages, et elle ne marque pas l'objet page comme supprimé. ComposeLoadedPagesNUp est le travail qui lui va : elle appende des feuilles fraîchement composées qui dessinent les pages sources comme des form XObjects, puis abandonne chaque originale avec DeleteLoadedPage(0), puisque les feuilles référencent le contenu des pages et pas les objets page. Pour « retire la page 7 de ce contrat » sur un fichier balisé, doté de signets et de liens croisés, DeletePage est l'appel qui laisse chaque référence cohérente, que vous enregistriez avec SaveLoadedDocument ou que vous appendiez avec SaveIncrementalUpdate

Les deux méthodes, le DeletePages par plages, et le chemin de mise à jour incrémentale utilisé plus haut font partie du HotPDF Delphi Component, qui édite des PDF chargés depuis du code Delphi et C++Builder sans runtime de visionneuse sur la machine