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
/Destsanté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
/Desten ligne ou par une action/Aavec/S /GoToet un tableau/D - Les éléments de structure (§14.7.2) portent une clé
/Pgqui nomme la page où vit leur contenu marqué, et leurs enfants/Kpeuvent ê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/StructParentsdes 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/Kdepuis la racine - Les annotations de lien des autres pages (§12.5.6.5) portent un
/Destou une action/GoTovisant la page, et le/OpenActiondu catalogue peut faire de même
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
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
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