Article technique

Édition du plan PDF et remappage de pages en Delphi

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

Édition du plan avec PDFiumPas en Delphi : déplacer le chapitre 3 hors de la partie I et sous la racine du document réécrit le pointeur /Parent du nœud déplacé plus les liens /First et /Prev et /Next de frères autour de la coupe et du point d’insertion
Un appel Move réécrit le pointeur parent du sous-arbre soulevé et les liens de frères des deux côtés de la coupe et du point d’insertion

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

Comment PDFiumPas encode l’état d’expansion du plan en Delphi : un /Count positif signifie que l’entrée est ouverte et compte les descendants visibles, un /Count négatif signifie replié, et un compte non signé force chaque lecteur à développer tout l’arbre
Le signe de /Count est l’état d’expansion et la magnitude est le compte de descendants visibles, si bien qu’un compte non signé force 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

Comment ApplyPageMap de PDFiumPas redirige les signets PDF en Delphi : un mappage de pages indexé par page ancienne moins un envoie les destinations survivantes vers leurs nouveaux numéros de page, tandis que les entrées mappées à zéro sont soit supprimées avec leur sous-arbre soit dépouillées de leur cible
Le mappage de pages est indexé par page ancienne moins un, et une entrée zéro supprime soit le sous-arbre flottant soit laisse l’entrée avec sa cible dépouillée

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, /Launch ou 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