Retirez sept pages d’un manuel de 200 pages et chaque signet atterrit quelque part de faux. La correction n’est pas de reconstruire le plan depuis une liste plate de titres. PDFiumPas expose TPdfOutlineEditor, qui charge le vrai arbre du plan, vous laisse déplacer et recibler les entrées, puis exécute ApplyPageMap pour décaler chaque destination explicite à travers votre plan de pages
Pourquoi supprimer des pages casse-t-il chaque signet ?
Parce qu’une entrée de plan ne stocke pas un numéro de page. Elle stocke une référence vers un objet page, et quand les objets pages changent la référence pointe soit vers une page qui a bougé, soit vers rien du tout. L’ISO 32000-1 §12.3.2.2 définit une destination explicite comme un tableau dont le premier élément est une référence indirecte vers un dictionnaire de page, suivi d’un nom d’ajustement tel que /Fit ou /XYZ. Supprimez la page et il vous reste une référence flottante ; réordonnez les pages et la référence est toujours valide mais décrit maintenant un autre chapitre. PDFiumPas résout ce tableau en numéro de page au chargement, si bien que TPdfOutlineItem.PageNumber vous donne un index de page en base un qui correspond à l’API publique TPdf plutôt qu’à un numéro d’objet. C’est tout l’intérêt de l’abstraction : votre logique de remappage travaille dans le même système de coordonnées que le plan de pages que vous avez déjà construit quand vous avez scindé, réordonné ou imposé le document. Si vous construisez ce plan, la même convention en base un traverse la scission de documents PDF en plusieurs fichiers et l’imposition n-up et le réordonnancement de pages
Le plan est un arbre doublement lié, pas une liste
La raison pour laquelle vous ne pouvez pas simplement sérialiser un tableau plat de titres est que l’ISO 32000-1 §12.3.3 câble chaque entrée de plan dans cinq liens séparés : /Parent, /Prev, /Next, /First et /Last. Déplacer un seul sous-arbre réécrit donc l’ancien parent, le nouveau parent, les deux frères voisins de chaque côté de la coupe et du point d’insertion, et le pointeur parent du nœud déplacé lui-même. S’en tromper sur un seul et les lecteurs conformes montrent un arbre tronqué, ou bouclent. PDFiumPas garde l’état d’édition comme un tableau en profondeur d’enregistrements TPdfOutlineItem avec un Id entier stable, si bien qu’un sous-arbre est une tranche contiguë et que la chaîne de frères est dérivée, jamais entretenue à la main. TPdfOutlineEditor.Move soulève cette tranche, la réinsère sous le nouveau parent à l’index de frère demandé, et ne réaffecte que la racine du bloc. Il refuse aussi les deux déplacements qui corrompraient le graphe : déplacer une entrée dans son propre sous-arbre, et nommer un parent qui n’existe pas
Pourquoi /Count est-il signé ?
Parce que le signe porte l’état d’expansion, pas la taille. Un /Count positif signifie que l’entrée est ouverte et le nombre est combien de descendants sont actuellement visibles ; un /Count négatif signifie que l’entrée est repliée. PDFiumPas écrit le compte de descendants pour chaque entrée qui a des enfants et le nie quand IsOpen est False, et au chargement il relit l’état comme IsOpen := HasCount and (CountValue > 0). C’est le bug fait main le plus courant des rédacteurs de plans : émettre un compte non signé et forcer silencieusement tout l’arbre ouvert
var
Source, Dest: TMemoryStream;
Editor: TPdfOutlineEditor;
Options: TPdfOutlineEditOptions;
Report: TPdfOutlineValidationReport;
RootId, ChapterId: Integer;
begin
Source := TMemoryStream.Create;
Dest := TMemoryStream.Create;
Editor := nil;
try
Source.LoadFromFile('handbook.pdf');
Options := TPdfOutlineEditOptions.Default; // MaxItems 100000, MaxDepth 64
if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
raise Exception.Create(Report.ErrorMessage);
RootId := Editor[0].Id;
ChapterId := Editor[2].Id;
Editor.Move(ChapterId, RootId, 1); // becomes second child of root
Editor.SetTitle(ChapterId, 'Appendix B');
Editor.SetStyle(ChapterId, [posBold, posItalic]);
Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
Editor.SetExpanded(RootId, False); // writes a negative /Count
Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');
if not Editor.SaveIncremental(Source, Dest, Report) then
raise Exception.Create(Report.ErrorMessage);
Dest.SaveToFile('handbook-edited.pdf');
finally
Editor.Free;
Dest.Free;
Source.Free;
end;
end;
Retarget traite les deux formes que la spécification permet. Passez DestinationInAction à False et PDFiumPas écrit un tableau /Dest direct ; passez True et il écrit une action Go-To, /A << /S /GoTo /D [ page ref suffix ] >>, selon l’ISO 32000-1 §12.6.4.2. Dans les deux cas il d’abord dépouille tout /Dest et /A existants de l’entrée pour que les deux ne puissent coexister et se contredire. Le suffixe vaut /Fit par défaut et doit commencer par un nom PDF, voilà pourquoi un suffixe vide ou malformé lève immédiatement au lieu de produire un tableau de destination qu’aucun lecteur ne peut analyser
Comment ApplyPageMap consomme-t-il un plan de pages ?
ApplyPageMap prend exactement le tableau que votre plan de pages a déjà validé : NewPageNumbers, indexé par page ancienne moins un, portant le nouveau numéro de page en base un ou zéro quand cette page n’a pas survécu. Il parcourt le tableau d’entrées à rebours pour que supprimer un sous-arbre n’invalide jamais un index qu’il n’a pas encore visité, et il rapporte ce qu’il a fait à travers RemappedDestinationCount et RemovedDanglingItemCount
var
NewPageNumbers: array of Integer;
Report: TPdfOutlineValidationReport;
I: Integer;
begin
// One entry per page of the ORIGINAL document
SetLength(NewPageNumbers, OriginalPageCount);
for I := 0 to OriginalPageCount - 1 do
NewPageNumbers[I] := 0; // 0 == this page was dropped
NewPageNumbers[0] := 1; // old page 1 -> new page 1
NewPageNumbers[1] := 2;
NewPageNumbers[9] := 3; // old page 10 -> new page 3
// True: delete the whole dangling subtree. False: keep the item, strip its target
if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
raise Exception.Create(Report.ErrorMessage);
WriteLn(Format('%d remapped, %d dangling items removed',
[Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;
Le drapeau DeleteDangling décide la politique pour une destination mappée à zéro, et les deux branches sont délibérées. Avec True, PDFiumPas supprime l’entrée et son sous-arbre entier, parce qu’un nœud de plan dont la cible a disparu mène d’ordinaire un chapitre qui a disparu avec elle. Avec False, l’entrée survit avec son titre et sa hiérarchie intacts mais son /Dest et son /A retirés, ce que vous voulez quand un humain va la recibler en relecture. Une entrée réellement malformée échoue encore bruyamment au lieu d’être rapiécée : une entrée négative ou une destination pointant au-delà de la fin du tableau fourni renvoie False avec IssueKind réglé à poviInvalidPageMap
Entrées opaques, et le compromis honnête
Toute entrée de plan n’a pas un numéro de page auquel PDFiumPas peut raisonner. Trois sortes sont portées à travers sans touchement : les destinations nommées, les actions qui ne sont pas /S /GoTo, et les clés de dictionnaire inconnues ajoutées par ce qui a produit le fichier. Celles-ci se chargent avec PageNumber égal à zéro, gardent leurs octets d’origine dans l’entrée, et sont réécrites verbatim sauf si vous appelez explicitement Retarget sur elles
- Une destination nommée est une clé dans l’arbre de noms du document, si bien que la remapper correctement signifie résoudre l’arbre et réécrire l’entrée cible, pas deviner au niveau du plan
- Une action
/URI,/Launchou JavaScript n’a aucune sémantique de page et ne doit pas être convertie silencieusement en Go-To - Les clés spécifiques aux fournisseurs et les destinations de structure sont préservées parce que laisser tomber ce que vous ne comprenez pas est ainsi que les allers-retours perdent des données
Le coût est réel et vaut la peine d’être énoncé franchement : ApplyPageMap saute ces entrées entièrement, si bien qu’un document dont les signets utilisent tous des destinations nommées traversera une suppression de pages avec un plan structurellement valide et sémantiquement périmé. C’est le choix délibéré — un lien périmé qu’un relecteur peut attraper vaut mieux qu’un lien faussement sûr que personne ne remarque. Si vous triez les fichiers entrants avant de les éditer, une passe d’inventaire dans un atelier de revue d’ingestion PDF vous dira quels documents tombent dans ce panier
Sauvegarde : révision incrémentielle, puis rechargement indépendant
TPdfOutlineEditor.SaveIncremental ajoute une révision incrémentielle éparse plutôt que de réécrire le fichier. Les entrées qui étaient chargées gardent leur référence d’objet indirecte d’origine y compris la génération exacte, si bien que les références croisées existantes restent valides ; seules les entrées que vous avez ajoutées tirent un numéro frais, alloué à partir de un après le numéro d’objet maximal de la révision. Le catalogue est mis à jour dans la même révision, et une entrée /Outlines manquante y est ajoutée quand la source n’avait aucun plan du tout
Ce qui se passe après l’écriture est la partie qui vaut la peine d’être copiée. PDFiumPas rouvre le flux de destination avec un éditeur complètement indépendant et compare l’arbre rechargé contre celui en mémoire — compte d’entrées, titres, numéros de page, suffixes de destination, forme action contre destination directe, styles, état d’expansion, et relations parent. Toute discordance, ou tout échec de chargement, efface le flux de destination et renvoie poviVerificationFailure au lieu de vous remettre un fichier d’apparence plausible. Les sources chiffrées sont refusées d’emblée avec poviEncryptedInput, puisque de nouveaux titres et destinations créent du contenu de chaîne qui ne peut pas être produit en copiant la remorque /Encrypt vers l’avant
if not Editor.SaveIncremental(Source, Dest, Report) then
case Report.IssueKind of
poviEncryptedInput:
Log('Source is encrypted; outline editing needs an unprotected copy');
poviInvalidDestination:
Log(Format('Item %d %d targets a missing page',
[Report.ObjectNumber, Report.Generation]));
poviVerificationFailure:
Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
else
Log(Report.ErrorMessage);
end;
Traitez le plan comme ce qu’il est — un graphe d’objets lié avec ses propres invariants — et la suppression de pages cesse d’être un désastre de signets pour devenir un mappage de pages que vous remettez à un appel de méthode. TPdfOutlineEditor, ApplyPageMap et le rédacteur incrémentiel vérifié sont livrés dans PDFiumPas depuis la v3.98.0 pour Delphi, C++Builder et Lazarus ; vous pouvez revoir l’API complète et télécharger une version d’essai sur la page produit PDFium Delphi Component