HotPDF Delphi Component supprime une page d'un PDF chargé via THotPDF.DeletePage, et depuis la version 2.751.0 cet appel élague aussi toute référence au niveau du document qui pointe encore vers la page : les destinations nommées de l'arbre /Names /Dests, le dictionnaire /Dests hérité du catalogue, les actions /GoTo des signets, les éléments de structure sous /StructTreeRoot, le ParentTree, les entrées OBJR des annotations, et les annotations de lien des pages survivantes. L'arbre de pages est reconstruit en dernier, une fois que plus rien d'autre ne peut atteindre l'objet supprimé
La panne que cela évite est facile à reproduire et difficile à diagnostiquer. Supprimez la page de couverture d'un rapport balisé, enregistrez, ouvrez le résultat : Acrobat affiche le bon nombre de pages, mais le signet « Contents » n'atterrit plus nulle part, le contrôleur d'accessibilité signale un élément de structure sans page, et un validateur strict liste une référence vers un objet libre. Rien dans l'arbre de pages n'est faux. Le problème est qu'une page PDF n'est pas seulement une feuille de /Pages ; c'est une cible vers laquelle la moitié du catalogue pointe, et retirer la feuille laisse chacun de ces pointeurs pendant
Pourquoi retirer une page de /Kids ne suffit-il pas ?
Parce qu'ISO 32000-1 laisse au moins sept structures indépendantes détenir une référence vers un objet page, et qu'une seule d'entre elles est l'arbre de pages. Retirer la page de /Kids et décrémenter /Count satisfait §7.7.3, et toutes les autres références deviennent des pointeurs vers un objet qui est soit libéré dans le xref, soit simplement absent du fichier réécrit. Une visionneuse qui suit l'un de ces pointeurs obtient null, et ce qu'elle fait de ce null lui appartient
- 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
/Destsd'avant la 1.2, directement dans le catalogue, détient le même genre de tableaux indexés par nom - Les éléments de signet (§12.3.3) atteignent une page soit par un
/Destinline, soit par une action/Aavec/S /GoToet un tableau/D - Les éléments de structure (§14.7.2) portent une clé
/Pgnommant la page où vit leur contenu balisé, et leurs enfants/Kpeuvent être des références de contenu balisé et des références d'objet (§14.7.4.3) liées à cette page - Le
ParentTree(§14.7.4.4) associe les numéros/StructParentsde pages et d'annotations à des éléments de structure, et un élément peut y vivre sans apparaître du tout dans 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
Que nettoie THotPDF.DeletePage avant de toucher à l'arbre de pages ?
THotPDF.DeletePage(PageIndex) sur un document chargé exécute d'abord tout le balayage des références, puis marque l'objet page comme supprimé avec DeleteObj, détache les annotations de widget de l'arbre de champs AcroForm, décale le tableau interne des pages, et appelle enfin RebuildLoadedPageTree pour réécrire /Kids, /Count et le /Parent de chaque page survivante. Le balayage visite le catalogue dans un ordre fixe : l'arbre de noms /Names /Dests, le dictionnaire /Dests à l'ancienne, /OpenAction, l'arbre de signets, /StructTreeRoot avec son ParentTree, et en dernier les tableaux /Annots de chaque page qui reste. Chaque étape décide si une référence est supprimée, retargetée ou laissée telle quelle, selon ce que la spec permet à cette structure de faire sans la page. Deux gardes s'appliquent avant tout cela : DeletePage lève Invalid page number pour un index hors bornes et refuse de supprimer la dernière page, parce qu'un nœud /Pages avec zéro enfant n'est pas un PDF valide, tandis que DeletePages accepte la même notation 1-based "1,3-5,7-" que les autres opérations de page sur document chargé, et itère depuis l'index sélectionné le plus haut vers le bas pour que les index que vous avez écrits restent valides pendant le travail
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// Index zéro : supprimer la page de couverture. Destinations nommées,
// signets, arbre de structure, ParentTree et annotations de lien
// qui pointaient vers elle sont élagués avant que l'arbre
// /Pages ne soit reconstruit.
Pdf.DeletePage(0);
// Syntaxe de plages 1-based pour les lots, index le plus haut traité
// en premier en interne pour que les index antérieurs restent valides.
Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
En quoi les destinations nommées et les signets sont-ils traités différemment ?
Les destinations nommées sont supprimées et les signets sont retargetés, parce qu'un nom qui n'existe plus est un résultat acceptable, tandis qu'un signet sans destination est un défaut visible. Dans l'arbre /Names /Dests, HotPDF parcourt chaque nœud, teste chaque destination, sous la forme tableau nue comme sous la forme dictionnaire avec une clé /D, contre la page supprimée, et retire la paire nom/valeur quand le premier élément du tableau est cette page. Un nœud dont les /Names et /Kids finissent tous deux vides est marqué supprimé et détaché de son parent, pour que l'arbre ne garde jamais de feuilles creuses. Le même test s'exécute sur le dictionnaire /Dests à l'ancienne du catalogue, et l'/OpenAction du catalogue est simplement abandonné s'il ouvrait sur la page supprimée. Une limite ici : quand un nœud d'arbre de noms perd des entrées, HotPDF supprime la paire /Limits de ce nœud au lieu de recalculer la nouvelle clé la plus basse et la plus haute, et même si les visionneuses résolvent très bien les noms sans cela, un contrôleur de conformité strict qui lit ISO 32000-1 §7.9.6 peut signaler un nœud non racine dépourvu de /Limits
Les éléments de signet vont dans l'autre sens. RetargetOutlineDestinations parcourt /First et /Next depuis la racine des signets, avec une liste des vus et une limite de profondeur de 128 pour qu'un arbre cyclique corrompu ne puisse pas bloquer l'appel, et pour chaque tableau /Dest ou tableau /D d'action /GoTo visant la page, il remplace le premier élément par NearestRetainedPage : la page qui suivait la page supprimée, ou la page qui la précède quand la page supprimée était la dernière. Les paramètres de vue après la référence de page sont laissés tels quels. Un signet qui visait l'ouverture d'un chapitre supprimé atterrit donc sur la première page de ce qui reste au lieu de disparaître du panneau latéral, ce qui est le comportement qu'attendent les relecteurs d'un document élagué. Le test de destination ne reconnaît toutefois que les tableaux explicites : un élément de signet dont le /Dest est une chaîne de nom qui résolvait vers la page supprimée n'est pas retargeté, parce que l'entrée de l'arbre de noms a disparu et que la référence ne résout plus vers rien plutôt que vers un objet libéré, donc la visionneuse la traite comme un signet mort. La mécanique de l'arbre de signets lui-même, /First, /Next, et la sémantique peu évidente de /Count, est traitée dans le guide pour ajouter signets et destinations nommées sur un PDF chargé
// Vérifier le balayage au lieu de lui faire confiance.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// Un signet qui visait la couverture résout maintenant vers la
// page qui la suivait (index zéro après la suppression).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
Qu'arrive-t-il à l'arbre de structure et au ParentTree ?
Les éléments de structure qui n'existent qu'à cause de 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. PruneStructureElement descend la chaîne /K depuis /StructTreeRoot jusqu'à une profondeur de 128, en traitant la forme tableau et la forme dictionnaire unique de /K que §14.7.2 autorise. Pour chaque élément, il élague d'abord les enfants, puis évalue l'élément lui-même : si l'élagage a vidé son /K, l'élément est marqué supprimé et son parent l'abandonne. Si le /Pg de l'élément nomme la page supprimée et que l'élément a encore des enfants plus un parent /P, seul /Pg est retiré, parce qu'un /Pg sur un élément est la page par défaut de ses enfants de contenu balisé et que ces enfants peuvent référencer explicitement d'autres pages. Seul un élément dont le /Pg est la page supprimée et sous lequel il ne reste rien est retiré purement et simplement
Le ParentTree reçoit le même traitement, et la raison est celle qui a mordu pendant le développement : un élément de structure peut être atteignable depuis le ParentTree et nulle part ailleurs. L'arbre de numéros associe les entiers /StructParents à un seul élément ou à un tableau d'éléments, et PruneParentTreeNode exécute PruneStructureElement sur chaque valeur trouvée, retire les valeurs qui ont été élaguées, supprime une paire /Nums quand son tableau de valeurs est vide, et détache un nœud dont les /Nums et /Kids ont tous deux disparu. Élaguer seulement les descendants de /K aurait laissé ces éléments orphelins pointant vers une page libérée via /Pg et vers des références de contenu balisé libérées via leurs enfants /MCR. Si vous extrayez du texte dans l'ordre de la structure, cela compte directement : l'extraction de texte en ordre de structure parcourt exactement ces arbres, et un élément au /Pg nul est un paragraphe qui disparaît silencieusement de l'ordre de lecture
Quelles annotations de lien des pages survivantes sont supprimées ?
Toute annotation de lien sur une page conservée dont le tableau /Dest ou l'action /GoTo pointe vers la page supprimée est retirée, avec son rattachement à l'arbre de structure. RemoveRetainedPageDestinationAnnotations parcourt le tableau /Annots de chaque page autre que la cible, applique le même test de destination que pour les signets, marque l'annotation correspondante comme supprimée, la retire du tableau, puis appelle PruneAnnotationReferencesInStructureTree pour que le dictionnaire OBJR dont le /Obj nommait cette annotation soit retiré de son élément de structure, l'élément lui-même étant retiré si l'OBJR était son seul enfant. Laisser l'OBJR en place violerait §14.7.4.3, qui exige que /Obj référence un objet existant, et apparaîtrait dans un contrôle PDF/UA comme un lien balisé sans annotation derrière. Notez l'asymétrie avec les signets : les liens sont supprimés, pas retargetés. Un renvoi dans le corps du texte qui disait « see page 3 » est faux une fois la page 3 disparue, et le faire pointer sur la page 4 serait un mensonge d'une façon qu'un signet atterrissant sur le chapitre le plus proche n'est pas, donc si votre flux de travail a besoin de conserver ces liens, retargetez-les vous-même avant d'appeler DeletePage
Pourquoi un /MCR ou /OBJR retiré ne doit-il jamais être enregistré comme libre ?
Parce que les références de contenu balisé et les références d'objet sont le plus souvent des dictionnaires directs à l'intérieur du tableau /K de leur élément parent, et que le registre de modifications incrémentales résout un objet direct vers l'objet indirect le plus proche qui le contient. Quand RemoveArrayItem retire un enfant d'un tableau /K, il ne libère l'objet en mémoire que s'il s'agissait d'un THPDFLink ou d'une valeur non indirecte, et MarkRemovedObject n'enregistre un objet pour la liste des libres que quand son numéro d'objet est supérieur à zéro. La première version de ce balayage ne faisait pas cette distinction, et l'effet en sauvegarde incrémentale était exactement ce que le registre est conçu pour faire : RegisterIncrementalChange remontait du /MCR direct jusqu'à la racine de transaction de graphe, qui était l'élément de structure conservé qui le possédait, et écrivait cet élément comme null. Un document qui avait perdu une page revenait avec le contenu balisé des autres pages silencieusement débalisé. Le seul geste correct pour un enfant direct est de marquer son conteneur sale via TouchContainer pour que le conteneur soit réécrit, et de laisser la liste des libres tranquille
// Mise à jour incrémentale : seuls les conteneurs touchés et
// l'objet de page libéré arrivent dans la section ajoutée.
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Les éléments de structure conservés dont le /K a perdu un /MCR
// direct sont réécrits en place, jamais écrits comme null.
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
La même prudence façonne ce que DeletePage ne libère délibérément pas sur un document chargé. Les flux de contenu, les XObjects et les annotations non widget de la page supprimée restent des objets, parce qu'un fichier chargé peut partager n'importe lequel d'entre eux avec une page qui reste et qu'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 à la correction ; les octets que ces objets occupent encore sont une question séparée, et le graphe de dépendances d'objets et l'analyse des octets retenus est l'outil pour mesurer ce qu'un document élagué transporte encore
DeletePage ou DeleteLoadedPage : lequel appeler ?
Appelez DeletePage pour toute suppression de page destinée à l'utilisateur, et réservez DeleteLoadedPage au cas où tout le document est remis en page et où aucune référence au niveau du document ne mérite d'être conservée. THotPDF.DeleteLoadedPage(PageIndex), ajouté en version 2.508.0, est la variante légère : il décale le tableau interne des pages, appelle RebuildLoadedKidsArray pour réécrire /Kids et /Count, invalide le cache de pages rendues et déclenche OnLoadedDocumentModified. Il ne parcourt ni l'arbre de noms, ni les signets, ni l'arbre de structure, ni les annotations des autres pages, et ne marque pas l'objet page comme supprimé. C'est le bon outil à l'intérieur d'une imposition N-up, où HotPDF ajoute des planches fraîchement composées puis supprime chaque page d'origine avec DeleteLoadedPage(0) : les pages source sont remplacées en bloc, et le contenu des planches fait référence à leurs ressources plutôt qu'aux objets de page. Pour le travail ordinaire « sortir la page 7 de ce contrat », DeletePage est le seul appel qui laisse un document balisé, signeté et lié de partout assez cohérent pour passer un validateur, aussi bien en réécriture complète via SaveLoadedDocument qu'en mise à jour incrémentale via SaveIncrementalUpdate. Les deux méthodes sont livrées dans HotPDF Delphi Component pour Delphi et C++Builder, sans aucun runtime de visionneuse externe ni dépendance requise