Technischer Artikel

Getaggte PDF-Annotationen löschen: OBJR und ParentTree

Entfernen Sie eine Link-Annotation aus einem tagged PDF mit einem rein seitenseitigen Editor, rendert die Seite sauber, während der Strukturbaum noch einen OBJR, einen ParentTree-Eintrag und ein Link-Element für eine Annotation hält, die längst weg ist. RemovePdfPageAnnotations in PDFium Component zieht die Annotation aus der Seite zurück und entfernt auf Wunsch diese Strukturteile gleich mit

Wo eine getaggte Annotation im Strukturbaum verankert ist

Eine getaggte Annotation sitzt an der Schnittstelle von vier Objekten, und eine saubere Löschung muss alle vier besuchen. ISO 32000-1 §14.7.4.3 besagt: Ist der Inhalt eines Strukturelements ein ganzes PDF-Objekt, etwa eine Annotation, benennt das /K-Array des Elements ihn über ein Objektverweis-Wörterbuch (Tabelle 325): /Type /OBJR, ein erforderlicher indirekter Verweis /Obj auf die Annotation und ein optionales /Pg, das die eigene Seite des Elements überschreibt. Das deckt die Vorwärtsrichtung ab, vom Baum zur Seite

Wie eine getaggte Link-Annotation in PDFium Component für Delphi mit dem /Annots-Array der PDF-Seite, dem OBJR-Objektverweis, dem StructParent-Schlüssel und dem ParentTree-Eintrag verbunden ist
Eine getaggte Annotation ist mit vier Objekten verdrahtet, und eine saubere Löschung besucht den Seitenverweis, den OBJR, den StructParent-Schlüssel und das ParentTree-Paar

Der Rückweg läuft über §14.7.4.4. Der Parent Tree ist ein Number Tree (§7.9.7) mit einem Eintrag für jedes Objekt, das ein Inhaltsitem ist; als Schlüssel dient die Ganzzahl, die das Objekt selbst trägt. Eine Annotation trägt /StructParent, und ihr Parent-Tree-Wert ist ein einzelner indirekter Verweis auf das besitzende Element; eine Seite, deren Content-Stream Marked Content enthält, trägt stattdessen /StructParents, und ihr Wert ist ein über MCIDs indiziertes Array. Tabelle 326 erlaubt in einem Objekt höchstens einen der beiden Schlüssel, denn ein Objekt ist entweder insgesamt ein Inhaltsitem oder ein Container für Marked Content. Für Hyperlinks lässt §14.8.4.4.2 ein Link-Element sowohl die sichtbaren Wörter als Marked-Content-Referenzen (MCRs) halten als auch einen OBJR auf die Link-Annotation. Löschen Sie die Annotation, lautet die Bestandsliste:

OrtWas er enthältWas eine saubere Entfernung tut
Seite /AnnotsIndirekter Verweis auf die AnnotationStreicht den Verweis; die übrigen behalten ihre Reihenfolge
Link-Element /KOBJR mit /Obj und /PgEntfernt das OBJR-Inhaltsitem
Annotations-Wörterbuch/StructParent-GanzzahlschlüsselEntfernt den Schlüssel, denn das Objekt ist kein Inhaltsitem mehr
ParentTree-/Nums-BlattSchlüssel, gepaart mit einem Verweis auf das Link-ElementEntfernt das Schlüssel-Wert-Paar
Wurzel des Strukturbaums/ParentTreeNextKeyBleibt unangetastet

Warum überlebt ein Geister-Link seine Annotation?

Ein Editor auf Seitenebene löscht eine Annotation, indem er sie aus /Annots nimmt, und von dort nimmt die Sache einen von zwei Wegen. Gibt das Werkzeug auch das Annotations-Objekt frei, zeigt der /Obj des OBJR nun auf ein undefiniertes Objekt, das §7.3.10 den Reader als null behandeln lässt: Das Link-Element besitzt ein Inhaltsitem, das in nichts auflöst. Lässt das Werkzeug das Objekt in der Datei, löst der OBJR weiterhin auf, aber sein /Pg nennt eine Seite, deren /Annots ihn nicht mehr führt. In beiden Fällen behält das Link-Element seine Marked-Content-Kinder, also bleiben die Wörter „Download the datasheet“ als Link getaggt, und ein Screenreader, der den Baum in Lesereihenfolge durchläuft, kann einen Link ansagen, den nichts auf der Seite auslöst. PDF/UA-Prüfer wie PAC und veraPDF, die gegen ISO 14289-1 validieren, vergleichen, was die Seite listet, mit dem, was der Baum behauptet, also kann eine Abweichung dort zutage treten, selbst wenn die gerenderte Seite tadellos aussieht

Zwei Wege, die nach einer rein seitenseitigen Löschung einen Geister-Link im tagged PDF hinterlassen, und wie PDFium Component sie in Delphi bereinigt
Ob das Annotations-Objekt freigegeben wird oder zurückbleibt, das Link-Element behält seine Wörter, und Screenreader können einen Link ansagen, der nirgendwohin führt

Das Löschen von Inhalt hat seine eigene Variante des Problems. Entfernen Sie die Textobjekte innerhalb von /P <</MCID 4>> BDC … EMC und behalten die Operatoren, enthält die Seite eine leere Marked-Content-Sequenz, deren MCR weiter in einem <P>-Element sitzt; der Reader bekommt einen Absatz ohne Wörter. Entfernen Sie das BDC, aber nicht sein EMC, ist der Content-Stream kaputt. §14.6 verlangt, dass jede Marked-Content-Sequenz innerhalb eines einzigen Content-Streams von BDC oder BMC und einem passenden EMC umschlossen wird, und ein verwaistes EMC lässt jede spätere Sequenz der Seite mit dem falschen öffnenden Operator gepaart zurück

Annotationen mit RemovePdfPageAnnotations entfernen

RemovePdfPageAnnotations in FPdfDocumentStructure nimmt einen Quell-Stream, einen nullbasierten Seitenindex in Seitenbaum-Reihenfolge, die Objektnummern der zu entfernenden Annotationen, einen TPdfAnnotationRemovalOptions-Record und zwei Out-Parameter für die neue Datei und einen TPdfAnnotationRemovalReport entgegen. Mit TPdfAnnotationRemovalOptions.Default fasst sie nur die Seite an: Eine inkrementelle Revision trägt das Seitenwörterbuch mit dem gekürzten /Annots-Array (oder ganz ohne /Annots-Schlüssel, sobald die letzte Annotation geht) plus den unveränderten Catalog, sodass Objektnummern, die Document-ID und alle übrigen Seitenschlüssel überleben. Das Annotations-Objekt behält seinen Körper, und wer seine Nummer noch kennt, kann es weiter lesen. Der Strukturbaum bleibt, wie er war, was das Tagging intakt hält für den Fall, dass Sie das Link-Element gleich auf eine Ersatz-Annotation zeigen lassen wollen. Die Eingabe wird geprüft, bevor irgendetwas geschrieben wird:

  • Jede Nummer muss im /Annots der gewählten Seite stehen und ein /Type /Annot-Objekt benennen; eine Nummer, die in einem Aufruf zweimal auftaucht, wird abgewiesen
  • Widget-Annotationen werden abgewiesen, denn ein Widget gehört zu seinem Formularfeld, und wer es von der Seite zieht, hinterlässt ein Feld, dessen Widget keine Seite mehr zeigt
  • Eine verschlüsselte Quelle wird zurückgewiesen, weil die Struktur-Bearbeitung entschlüsselte Bytes braucht; entschlüsseln Sie die Datei vorher
  • MaxOutputBytes, MaxWork und CancellationToken begrenzen die Arbeit, und jeder Fehlschlag liefert False ohne Ausgabe und mit einer ErrorMessage, die die Phase nennt, in der er stoppte

Bei einem getaggten Dokument sagt Ihnen der Strukturbaum schon, welche Annotations-Nummern zählen. Jedes OBJR-Item in einem ReadPdfDocumentStructure-Snapshot trägt die referenzierte Objektnummer, und seine Seitennummer stammt aus dem eigenen /Pg des OBJR oder, mangels dessen, aus dem des Elements. Die Routine unten sammelt jede Annotation, auf die ein Link-Element auf einer Seite zeigt, und entfernt sie mit beiden Aufräum-Optionen eingeschaltet. Behalten Sie den Seitenfilter: Eine Nummer aus einer anderen Seite lässt den gesamten Batch scheitern

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);

    // Jede Annotation, auf die ein Link-Element dieser Seite zeigt
    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;    // OBJR-Items und ParentTree-Einträge
    Options.RemoveEmptiedStructureNodes := True;  // Elemente, die ohne Inhalt zurückbleiben
    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;

Was mit dem ParentTree-Schlüssel passiert

Mit RemoveOwnedStructurePeers verlässt jeder OBJR, dessen /Obj eine auf dieser Seite entfernte Annotation ist, sein Element durch den Struktur-Editor, womit der Rückwärtsindex im Takt bleibt. Das unter dem /StructParent der Annotation abgelegte Schlüssel-Wert-Paar fliegt aus seinem /Nums-Blatt, und der /StructParent-Schlüssel selbst wird aus dem Annotations-Wörterbuch entfernt, denn Tabelle 326 verlangt ihn nur an Objekten, die strukturelle Inhaltsitems sind. /ParentTreeNextKey wird nicht gesenkt. §14.7.4.4 verlangt nur, dass er größer ist als jeder benutzte Schlüssel, was eine Entfernung nicht brechen kann, und Number-Tree-Schlüssel müssen nicht lückenlos sein, also gibt ein Writer, der /ParentTreeNextKey folgt, den freigewordenen Schlüssel nie wieder heraus. Jede überlebende Annotation behält ihre Objektnummer, ihre Position in /Annots und ihren ParentTree-Schlüssel; die Funktion liest die geschriebene Seite erneut und verwirft das Ergebnis, sollte ein Überlebender fehlen oder den Platz gewechselt haben. Erwartung: Auf einer Seite mit einem getaggten Link sind Report.RemovedAnnotations und Report.RemovedObjectReferences beide 1, und Report.SourceSha256 und Report.OutputSha256 verzeichnen, welche Bytes hineingingen und welche herauskamen

Wann fliegt das geleerte Element gleich mit?

RemoveEmptiedStructureNodes entfernt die Elemente, die die OBJR-Entfernung ohne Inhaltsitems und ohne Kindelemente zurückließ, und zwar von der tiefsten Stelle aus. Es arbeitet in Runden: Jede Runde liest die Struktur erneut, entfernt die inzwischen leeren Elemente und nimmt ihre Eltern in die Kandidaten der nächsten Runde auf, sodass eine Kette von Hüll-Elementen von unten nach oben kollabiert, während die Wurzel des Strukturbaums stets bleibt. Ein Annot-Element, das eine Notiz taggt, hält meist nichts als den OBJR (§14.8.4.4.3 erlaubt das), also leert es sich und geht. Ein Link-Element ist anders. Es besitzt weiterhin die Marked-Content-Referenzen für seine sichtbaren Wörter, ist also nicht leer und überlebt, als Link ohne Annotation. RemovePdfPageAnnotations benennt es nicht um, denn ob diese Wörter Link bleiben sollen, ist eine redaktionelle Frage und keine strukturelle. Für eine reine Löschung schalten Sie beide Optionen ein und stufen danach jedes Link-Element ohne OBJR zu Span herab: Die assistive Technologie liest diese Wörter dann als reinen Text, statt einen Link anzubieten, der nirgendwohin führt

Entscheidungsfluss für geleerte Strukturelemente: Ein Annot-Element kollabiert Runde für Runde, ein Link-Element überlebt, und PDFium Component kann es zu Span herabstufen
Geleerte Hüll-Elemente kollabieren von der tiefsten Stelle aus, während die Wurzel bleibt, und ein Link ohne seinen OBJR lässt sich zu Span herabstufen oder für eine Ersatz-Annotation behalten
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;
    // Der Snapshot-Digest sichert ab, dass die Bytes sich zwischendurch nicht ändern
    if not EditPdfDocumentStructure(Source, Snapshot.SourceSha256, Ops,
      Options, Fixed, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Result := Length(Ops);
  finally
    Source.Free;
  end;
end;

Behalten Sie die Voreinstellungen, wenn die Löschung die halbe Miete einer Ersetzung ist. Ein geleertes Element, das an Ort und Stelle bleibt, trägt weiter seine Rolle, seinen Titel, seinen alternativen Text und seine Element-ID, und hängt ein späterer Schritt mit einer psekAddObjectReference-Operation eine neue Annotation an, was der Annotation einen frischen /StructParent-Schlüssel gibt und /ParentTreeNextKey nach denselben Regeln weiterschiebt, nach denen neue MCR- und OBJR-Items im Parent Tree registriert werden, behält das Element seinen Platz in der Lesereihenfolge. Das Element zu entfernen und neu anzulegen würde diese Werte wegwerfen und ihm eine neue Objektnummer geben

Getaggte Inhalte löschen: Die Zuständigkeit läuft vom BDC bis zu seinem EMC

Annotationen sind keine Seitenobjekte, also laufen Text, Pfade und Bilder über eine andere API: den Batch-Objekt-Editor, PlanStyledPdfObjectEdits und ExecuteStyledPdfObjectEdits in FPdfStyledEdit, dessen TPdfObjectEditOptions RemoveOwnedStructurePeers und RemoveEmptiedStructureNodes in derselben Bedeutung mitbringt. Eine getaggte Marked-Content-Sequenz verschwindet nur dann samt ihrem Struktureintrag, wenn jedes Objekt zwischen ihrem BDC und dem passenden EMC gelöscht wird. Dann verlassen beide Operatoren den Content-Stream, die MCR verlässt ihr Element, und der Slot dieser MCID im ParentTree-Array der Seite wird auf null gesetzt statt zugeschoben, denn jede andere MCID der Seite indiziert in dasselbe Array. Eine Sequenz, die auch überlebenden Inhalt umschließt, bleibt, samt ihrer MCR. Eine Seite, die die entfernte MCID für überlebenden Inhalt wiederverwendet, wird rundherum abgewiesen, denn MCIDs gelten pro Content-Stream der Seite, und den Struktureintrag fallen zu lassen würde den Überlebenden verwaisen lassen. Lässt man die Option auf False, behält der Batch die BDC- und EMC-Paare an der Entfernungsgrenze, sodass der Strukturbaum weiter auflöst und sich in ihm nichts ändert

Die Zuständigkeit muss vom BDC bis zum Ende seines passenden EMC gemessen werden. Unsere erste Version maß sie über den Bytebereich des BDC-Operators allein, denn dort verzeichnet ein geparstes Mark seine Position, und fand keinerlei eingeschlossene Objekte. Ein verwandter Fehler ist, das BDC zur Entfernung zu markieren und seinen Partner aus den Augen zu verlieren, was exakt den oben beschriebenen unausgeglichenen Stream schreibt; der Editor entfernt deshalb stets beide Hälften eines Paares zusammen. Die Routine unten löscht jedes Top-Level-Objekt, das eine MCID trägt. ExecuteStyledPdfObjectEdits lädt Pdf mit dem Ergebnis neu, sofern EditOptions.PreviewOnly nicht gesetzt ist, und Report.RemovedStructurePeers und Report.RemovedStructureNodes zählen, was den Baum verließ; die Funktion gibt diesen Report zurück

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 ist einsbasiert
  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;

Eine entfernte Annotation belegt ihr Objekt in der Datei nach beiden Aufrufen weiter, denn nur der Seitenverweis wird zurückgezogen. Sobald der OBJR weg ist, erreicht nichts im Graphen des Catalogs es mehr, also kann TPdf.SaveAsReachable dieses verwaiste Objekt aus einer neu geschriebenen Kopie hinausfegen; mit den Standardoptionen hält der OBJR die Annotation erreichbar, und die Aufräumaktion behält sie ebenfalls. Fahren Sie die Entfernung mit beiden Struktur-Optionen, wann immer die Löschung endgültig ist, und Seite, Strukturbaum und Parent Tree in der Datei, die PDFium Component für Delphi zurückschreibt, beschreiben alle dieselbe Menge von Annotationen