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
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 :
| Emplacement | Ce qu'il contient | Ce que fait une suppression propre |
|---|---|---|
Page /Annots | Référence indirecte vers l'annotation | Retire la référence ; les annotations survivantes gardent leur ordre |
Élément Link /K | Un OBJR avec /Obj et /Pg | Retire l'élément de contenu OBJR |
| Dictionnaire de l'annotation | Clé entière /StructParent | Retire la clé, puisque l'objet n'est plus un élément de contenu |
Feuille /Nums du ParentTree | Clé associée à une référence vers l'élément Link | Retire la paire clé-valeur |
| Racine de l'arbre de structure | /ParentTreeNextKey | N'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
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
/Annotsde 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,MaxWorketCancellationTokenbornent le travail, et tout échec renvoieFalsesans sortie et avec unErrorMessagenommant 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
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