Article technique

Supprimer des annotations PDF balisées : OBJR et ParentTree

Supprimez une annotation Link d'un PDF balisé avec un éditeur qui ne touche qu'à la page, et la page s'affiche correctement tandis que l'arbre de structure conserve encore un OBJR, une entrée du ParentTree et un élément Link pour une annotation disparue. RemovePdfPageAnnotations dans PDFium Component retire l'annotation de la page et, à la demande, supprime avec elle ces pièces de structure

Où une annotation balisée se raccorde à l'arbre de structure

Une annotation balisée siège à la jonction de quatre objets, et une suppression propre doit tous les visiter. L'ISO 32000-1 §14.7.4.3 dit que lorsque le contenu d'un élément de structure est un objet PDF entier, tel qu'une annotation, le tableau /K de l'élément le nomme par un dictionnaire de référence d'objet (tableau 325) : /Type /OBJR, un /Obj indirect requis pointant vers l'annotation, et un /Pg facultatif qui prime sur la page propre de l'élément. Cela couvre le sens direct, de l'arbre vers la page

Comment une annotation Link balisée se raccorde au tableau Annots de la page PDF, à la référence d'objet OBJR, à la clé StructParent et à l'entrée du ParentTree dans PDFium Component pour Delphi
Une annotation balisée se raccorde à quatre objets, et une suppression propre visite la référence de page, l'OBJR, la clé StructParent et la paire du ParentTree

Le chemin inverse relève du §14.7.4.4. L'arbre parent est un arbre de nombres (§7.9.7) avec une entrée pour chaque objet qui est un élément de contenu, indexée par l'entier que cet objet porte. Une annotation porte /StructParent, et la valeur de son arbre parent est une référence indirecte unique vers l'élément propriétaire ; une page dont le flux de contenu porte du contenu marqué porte /StructParents à la place, et sa valeur est un tableau indexé par MCID. Le tableau 326 n'autorise qu'une seule des deux clés dans un objet, car un objet est soit un élément de contenu en entier, soit un conteneur de contenu marqué. Pour les hyperliens, le §14.8.4.4.2 permet à un élément Link de porter à la fois les mots visibles, sous forme de références de contenu marqué (MCR), et un OBJR vers l'annotation de lien. Supprimez l'annotation et voici l'inventaire :

EmplacementCe qu'il contientCe que fait une suppression propre
Page /AnnotsRéférence indirecte vers l'annotationRetire la référence ; les annotations survivantes gardent leur ordre
Élément Link /KUn OBJR avec /Obj et /PgRetire l'élément de contenu OBJR
Dictionnaire de l'annotationClé entière /StructParentRetire la clé, puisque l'objet n'est plus un élément de contenu
Feuille /Nums du ParentTreeClé associée à une référence vers l'élément LinkRetire la paire clé-valeur
Racine de l'arbre de structure/ParentTreeNextKeyN'y touche pas

Comment un lien fantôme survit-il à son annotation ?

Un éditeur au niveau de la page supprime une annotation en la retirant de /Annots, et de là tout emprunte l'un de deux chemins. Si l'outil libère aussi l'objet annotation, le /Obj de l'OBJR pointe désormais vers un objet non défini, que le §7.3.10 dit au lecteur de traiter comme null : l'élément Link possède un élément de contenu qui ne résout rien. Si l'outil laisse l'objet dans le fichier, l'OBJR résout toujours, mais son /Pg nomme une page dont le /Annots ne le liste plus. Dans les deux cas l'élément Link garde ses enfants de contenu marqué, si bien que les mots "Download the datasheet" restent balisés comme un lien, et un lecteur d'écran qui parcourt l'arbre dans l'ordre de lecture peut annoncer un lien qu'aucun élément de la page n'activera. Les vérificateurs PDF/UA tels que PAC et veraPDF, qui valident contre l'ISO 14289-1, comparent ce que la page liste avec ce que l'arbre prétend, si bien qu'un désaccord entre les deux peut y faire surface même quand la page rendue paraît parfaite

Les deux chemins qui laissent un lien fantôme dans un PDF balisé après une suppression limitée à la page, et comment PDFium Component les nettoie en Delphi
Que l'objet annotation soit libéré ou laissé sur place, l'élément Link garde ses mots et les lecteurs d'écran peuvent annoncer un lien qui ne mène nulle part

La suppression de contenu a sa propre version du problème. Retirez les objets de texte à l'intérieur de /P <</MCID 4>> BDC … EMC en gardant les opérateurs, et la page détient une séquence de contenu marqué vide dont la MCR siège toujours dans un élément <P> ; le lecteur obtient un paragraphe sans mots. Retirez le BDC sans son EMC et le flux de contenu est carrément cassé. Le §14.6 exige que chaque séquence de contenu marqué soit encadrée par un BDC ou un BMC et un EMC apparié à l'intérieur d'un même flux de contenu, et un EMC orphelin associe chaque séquence ultérieure de la page au mauvais ouvreur

Supprimer des annotations avec RemovePdfPageAnnotations

RemovePdfPageAnnotations dans FPdfDocumentStructure prend un flux source, un index de page base zéro dans l'ordre de l'arbre des pages, les numéros d'objet des annotations à retirer, un enregistrement TPdfAnnotationRemovalOptions et deux paramètres out pour le nouveau fichier et un TPdfAnnotationRemovalReport. Avec TPdfAnnotationRemovalOptions.Default il ne touche qu'à la page : une seule révision incrémentale transporte le dictionnaire de page avec le tableau /Annots raccourci (ou sans clé /Annots une fois la dernière annotation partie) plus le catalogue inchangé, si bien que les numéros d'objet, l'ID du document et toutes les autres clés de page survivent. L'objet annotation garde son corps, et un appelant qui détient encore son numéro peut le lire. L'arbre de structure est laissé en l'état, ce qui préserve le balisage pour le cas où vous vous apprêtez à pointer cet élément Link vers une annotation de remplacement. L'entrée est vérifiée avant toute écriture :

  • Chaque numéro doit figurer dans le tableau /Annots de la page choisie et nommer un objet /Type /Annot ; un numéro listé deux fois dans un même appel est refusé
  • Les annotations de type widget sont refusées, car un widget appartient à son champ de formulaire et le retirer de la page laisserait un champ dont le widget n'est affiché par aucune page
  • Une source chiffrée est rejetée, car l'édition de structure a besoin d'octets déchiffrés ; déchiffrez le fichier au préalable
  • MaxOutputBytes, MaxWork et CancellationToken bornent le travail, et tout échec renvoie False sans sortie et avec un ErrorMessage nommant la phase où il s'est arrêté

Pour un document balisé, l'arbre de structure vous dit déjà quels numéros d'annotation comptent. Chaque élément OBJR d'un instantané ReadPdfDocumentStructure porte le numéro d'objet référencé, et son numéro de page vient du /Pg propre à l'OBJR ou, à défaut, de celui de l'élément. La routine ci-dessous collecte chaque annotation à laquelle un élément Link d'une page se réfère et les retire avec les deux options de nettoyage activées. Gardez le filtre de page : un numéro pris sur une autre page est refusé pour tout le lot

uses
  System.SysUtils, System.Classes, System.IOUtils,
  FPdfCompress, FPdfDocumentStructure;

function RemoveTaggedLinks(const InName, OutName: string;
  PageIndex: Integer): TPdfAnnotationRemovalReport;
var
  Source: TMemoryStream;
  Pages: TPdfSparsePageDictionaries;
  ReadOptions: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  ReadReport: TPdfStructureEditReport;
  Item: TPdfDocumentStructureContentItem;
  Targets: array of Integer;
  Options: TPdfAnnotationRemovalOptions;
  Report: TPdfAnnotationRemovalReport;
  Output: TBytes;
  N, I, J: Integer;
  Known: Boolean;
begin
  Report := Default(TPdfAnnotationRemovalReport);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile(InName);
    if not ReadPdfSparsePageDictionaries(Source, Pages) or
       (PageIndex < 0) or (PageIndex > High(Pages)) then
      raise Exception.Create('Page index is outside the page tree');
    ReadOptions := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, ReadOptions, Snapshot, ReadReport) then
      raise Exception.Create(ReadReport.ErrorMessage);

    // Chaque annotation vers laquelle un élément Link de cette page pointe
    for N := 0 to High(Snapshot.Nodes) do
      if Snapshot.Nodes[N].RoleName = 'Link' then
        for I := 0 to High(Snapshot.Nodes[N].ContentItems) do
        begin
          Item := Snapshot.Nodes[N].ContentItems[I];
          if (Item.Kind <> psckObjectReference) or
             (Item.PageObjectNumber <> Pages[PageIndex].ObjectNumber) then
            Continue;
          Known := False;
          for J := 0 to High(Targets) do
            if Targets[J] = Item.ReferencedObjectNumber then Known := True;
          if not Known then
          begin
            SetLength(Targets, Length(Targets) + 1);
            Targets[High(Targets)] := Item.ReferencedObjectNumber;
          end;
        end;
    if Length(Targets) = 0 then
      Exit(Report);

    Options := TPdfAnnotationRemovalOptions.Default;
    Options.RemoveOwnedStructurePeers := True;    // éléments OBJR et entrées du ParentTree
    Options.RemoveEmptiedStructureNodes := True;  // éléments restés sans rien à l'intérieur
    if not RemovePdfPageAnnotations(Source, PageIndex, Targets, Options,
      Output, Report) then
      raise Exception.Create(Report.ErrorMessage);
    TFile.WriteAllBytes(OutName, Output);
    Result := Report;
  finally
    Source.Free;
  end;
end;

Que devient la clé du ParentTree

Avec RemoveOwnedStructurePeers posé, chaque OBJR dont le /Obj est une annotation retirée sur cette page quitte son élément par l'éditeur de structure, ce qui maintient l'index inverse à jour. La paire clé-valeur stockée sous le /StructParent de l'annotation est sortie de sa feuille /Nums, et la clé /StructParent elle-même est retirée du dictionnaire de l'annotation, puisque le tableau 326 ne la demande que sur les objets qui sont des éléments de contenu structurel. /ParentTreeNextKey n'est pas abaissé. Le §14.7.4.4 exige seulement qu'il soit supérieur à chaque clé en usage, ce qu'une suppression ne peut casser, et les clés d'arbre de nombres n'ont pas besoin d'être contiguës, si bien qu'un écrivain qui suit /ParentTreeNextKey ne redistribue jamais la clé libérée. Chaque annotation survivante garde son numéro d'objet, sa position dans /Annots et sa clé du ParentTree ; la fonction relit la page qu'elle a écrite et refuse le résultat si une survivante a disparu ou changé de place. Attendu : sur une page avec un seul lien balisé, Report.RemovedAnnotations et Report.RemovedObjectReferences valent tous deux 1, et Report.SourceSha256 et Report.OutputSha256 enregistrent quels octets sont entrés et lesquels sont sortis

Quand l'élément vidé doit-il partir aussi ?

RemoveEmptiedStructureNodes retire les éléments que la suppression d'OBJR a laissés sans élément de contenu ni élément enfant, les plus profonds d'abord. Il fonctionne par passes : chaque passe relit la structure, retire les éléments désormais vides et ajoute leurs parents aux candidats de la passe suivante, si bien qu'une chaîne d'enveloppes s'effondre du bas vers le haut tandis que la racine de l'arbre de structure reste toujours. Un élément Annot qui balise une note adhésive ne détient d'ordinaire rien d'autre que l'OBJR (le §14.8.4.4.3 le permet), donc il se vide et part. Un élément Link est différent. Il possède toujours les références de contenu marqué de ses mots visibles, donc il n'est pas vide et il survit, comme un Link sans annotation. RemovePdfPageAnnotations ne le renomme pas, car savoir si ces mots doivent demeurer un lien est une question éditoriale plutôt que structurelle. Pour une suppression pure, activez les deux options puis rétrogradez en Span tout élément Link resté sans OBJR : la technologie d'assistance lit alors ces mots comme du texte simple au lieu d'offrir un lien qui ne mène nulle part

Flux de décision pour les éléments de structure vidés : un élément Annot s'effondre passe après passe, un élément Link survit, et PDFium Component peut le rétrograder en Span
Les enveloppes vidées s'effondrent les plus profondes d'abord tandis que la racine reste, et un Link resté sans son OBJR peut être rétrogradé en Span ou gardé pour une annotation de remplacement
function DemoteUnlinkedLinks(const Data: TBytes; out Fixed: TBytes): Integer;
var
  Source: TBytesStream;
  Options: TPdfStructureEditOptions;
  Snapshot: TPdfDocumentStructureSnapshot;
  Ops: TPdfStructureEditOperations;
  Report: TPdfStructureEditReport;
  N: Integer;
begin
  Result := 0;
  Fixed := Data;
  Source := TBytesStream.Create(Data);
  try
    Options := TPdfStructureEditOptions.Default;
    if not ReadPdfDocumentStructure(Source, Options, Snapshot, Report) then
      raise Exception.Create(Report.ErrorMessage);
    for N := 0 to High(Snapshot.Nodes) do
      if (Snapshot.Nodes[N].RoleName = 'Link') and
         (Snapshot.Nodes[N].ObjectReferenceCount = 0) and
         (Length(Snapshot.Nodes[N].ContentItems) > 0) then
      begin
        SetLength(Ops, Length(Ops) + 1);
        Ops[High(Ops)] := Default(TPdfStructureEditOperation);
        Ops[High(Ops)].Kind := psekSetRole;
        Ops[High(Ops)].NodeID := Snapshot.Nodes[N].NodeID;
        Ops[High(Ops)].Value := 'Span';
      end;
    if Length(Ops) = 0 then Exit;
    // Le condensat de l'instantané protège contre une modification des octets entre-temps
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Gardez les valeurs par défaut quand la suppression est la moitié d'un remplacement. Un élément vidé laissé en place porte encore son rôle, son titre, son texte alternatif et son ID d'élément, et si une étape ultérieure y attache une nouvelle annotation avec une opération psekAddObjectReference, qui donne à l'annotation une clé /StructParent fraîche et avance /ParentTreeNextKey selon les mêmes règles qui enregistrent les nouveaux éléments MCR et OBJR dans l'arbre parent, l'élément garde sa place dans l'ordre de lecture. Retirer puis recréer cet élément jetterait ces valeurs et lui donnerait un nouveau numéro d'objet

Supprimer du contenu balisé : l'appartenance se mesure du BDC à son EMC

Les annotations ne sont pas des objets de page, donc texte, tracés et images passent par une autre API : l'éditeur d'objets par lot, PlanStyledPdfObjectEdits et ExecuteStyledPdfObjectEdits dans FPdfStyledEdit, dont le TPdfObjectEditOptions porte RemoveOwnedStructurePeers et RemoveEmptiedStructureNodes au même sens. Une séquence de contenu marqué balisée ne disparaît avec son entrée de structure que lorsque chaque objet entre son BDC et le EMC apparié est en cours de suppression. Dans ce cas les deux opérateurs quittent le flux de contenu, la MCR quitte son élément, et l'emplacement de ce MCID dans le tableau ParentTree de la page est mis à null plutôt que refermé, car chaque autre MCID de la page indexe dans le même tableau. Une séquence qui enveloppe aussi du contenu survivant reste, avec sa MCR. Une page qui réutilise le MCID retiré pour du contenu survivant est refusée sans appel, puisque les MCID ont la portée du flux de contenu de la page et que lâcher l'entrée de structure laisserait orphelin le survivant. Avec l'option laissée à False, le lot garde plutôt les paires BDC et EMC à la frontière de la suppression, si bien que l'arbre de structure continue de résoudre et que rien n'y change

L'appartenance doit se mesurer du BDC jusqu'à la fin de son EMC apparié. Notre première version la mesurait sur la plage d'octets du seul opérateur BDC, qui est là où une marque analysée enregistre sa position, et ne trouvait aucun objet enfermé. Une erreur voisine consiste à marquer le BDC pour suppression et à perdre la trace de son partenaire, ce qui écrit exactement le flux déséquilibré décrit plus haut, si bien que l'éditeur retire toujours les deux moitiés d'une paire ensemble. La routine ci-dessous supprime chaque objet de premier niveau portant un MCID. ExecuteStyledPdfObjectEdits recharge Pdf avec le résultat sauf si EditOptions.PreviewOnly est posé, et Report.RemovedStructurePeers et Report.RemovedStructureNodes comptent ce qui a quitté l'arbre ; la fonction renvoie ce rapport

uses
  System.SysUtils, PDFium, FPdfStyledEdit;

function DeleteTaggedRun(Pdf: TPdf; PageIndex, Mcid: Integer): TPdfObjectEditReport;
var
  Ops: TPdfObjectEditOperations;
  NoSemanticEdits: TPdfObjectSemanticEdits;
  Options: TPdfObjectEditOptions;
  Plan: TPdfObjectEditPlan;
  Preview: TBytes;
  Report: TPdfObjectEditReport;
  I: Integer;
begin
  Pdf.PageNumber := PageIndex + 1;          // PageNumber est base un
  for I := 0 to Pdf.ObjectCount - 1 do
    if Pdf.PageObjectMarkedContentID(I) = Mcid then
    begin
      SetLength(Ops, Length(Ops) + 1);
      Ops[High(Ops)] := Default(TPdfObjectEditOperation);
      Ops[High(Ops)].Kind := poekRemove;
      Ops[High(Ops)].ObjectIndex := I;
    end;
  if Length(Ops) = 0 then
    raise Exception.CreateFmt('No object on page %d carries MCID %d',
      [PageIndex + 1, Mcid]);
  NoSemanticEdits := nil;
  Options := TPdfObjectEditOptions.Default;
  Options.RemoveOwnedStructurePeers := True;
  Options.RemoveEmptiedStructureNodes := True;
  Plan := PlanStyledPdfObjectEdits(Pdf, PageIndex, Ops, NoSemanticEdits, Options);
  if not ExecuteStyledPdfObjectEdits(Pdf, Plan, Options, Preview, Report) then
    raise Exception.Create(Report.ErrorMessage);
  Result := Report;
end;

Une annotation retirée occupe toujours son objet dans le fichier après l'un ou l'autre appel, car seule la référence de page est retirée. Une fois l'OBJR parti, rien dans le graphe du catalogue ne l'atteint plus, si bien que TPdf.SaveAsReachable peut balayer cet objet orphelin hors d'une copie réécrite ; avec les options par défaut l'OBJR garde l'annotation atteignable et le balayage la garde aussi. Lancez la suppression avec les deux options de structure activées chaque fois que la suppression est définitive, et la page, l'arbre de structure et l'arbre parent dans le fichier que PDFium Component pour Delphi réécrit décrivent tous le même ensemble d'annotations