Technischer Artikel

PDF-Seiten in Delphi löschen ohne hängende Referenzen

Ein getaggter Bericht, dessen Coverseite herausgeschnitten wurde, kann mit der richtigen Seitenanzahl aufgehen und trotzdem an drei Stellen kaputt sein: Das Bookmark „Contents“ macht beim Klicken nichts, der Accessibility-Checker meldet ein Structure Element, das zu keiner Seite gehört, und ein strenger Validator beanstandet eine Referenz auf ein freies Objekt. Am Seitenbaum ist alles in Ordnung. ISO 32000-1 §7.7.3 verlangt nur, dass die Seite aus /Kids fliegt und /Count um eins sinkt, und eine Datei kann diese Klausel erfüllen, während der halbe Katalog weiter auf die fehlende Seite zeigt

Sieben Strukturen können eine Referenz auf eine Seite halten

Nur einer davon ist der Seitenbaum. Die übrigen verteilen sich auf Katalog, Outline, logische Struktur und die Annotationen anderer Seiten, und jede einzelne Referenz löst zu null auf, sobald das Seitenobjekt aus der Datei verschwunden ist. Was ein Viewer mit diesem null anstellt, bleibt ihm selbst überlassen, daher kann dieselbe gekürzte Datei in zwei Readern unterschiedlich reagieren

  • Der Namensbaum unter /Names /Dests (§7.7.4, §12.3.2.3) bildet Namen auf Ziel-Arrays ab, deren erstes Element die Seite ist
  • Das Prä-1.2-/Dests-Dictionary direkt im Katalog hält dieselbe Art von Arrays, mit dem Namen als Schlüssel
  • Outline-Einträge (§12.3.3) erreichen eine Seite über ein inline /Dest oder über eine /A-Action mit /S /GoTo und einem /D-Array
  • Structure Elements (§14.7.2) tragen einen /Pg-Schlüssel, der die Seite nennt, auf der ihr Marked Content liegt, und ihre /K-Kinder können Marked-Content-Referenzen und Objektreferenzen (§14.7.4.3) sein, die an diese Seite gebunden sind
  • Der ParentTree (§14.7.4.4) bildet die /StructParents-Nummern von Seiten und Annotationen zurück auf Structure Elements, und ein Element kann dort sitzen, ohne irgendwo auf der /K-Kette vom Wurzelknoten aus aufzutauchen
  • Link-Annotationen auf anderen Seiten (§12.5.6.5) tragen ein /Dest oder eine /GoTo-Action, die auf die Seite zielt, und das Katalog-/OpenAction kann dasselbe tun
Warum es nicht reicht, eine HotPDF-Seite aus /Kids zu entfernen: ISO 32000-1 lässt den /Names-/Dests-Namensbaum, das Legacy-Katalog-/Dests-Dictionary, Outline-Items, Structure Elements mit /Pg, den ParentTree, Link-Annotationen und /OpenAction alle eine Referenz auf dasselbe Seitenobjekt halten, und nur der Seitenbaum wird neu aufgebaut
Eine PDF-Seite ist ein Ziel, auf das der halbe Katalog zeigt: Das Blatt zu entfernen genügt dem Seitenbaum, während jeder andere Pointer auf null auflöst, also verliert ein gekürzter Bericht sein Inhaltsverzeichnis-Bookmark und fällt durch seine Accessibility-Prüfung

Was THotPDF.DeletePage erledigt, bevor es /Kids anfasst

THotPDF.DeletePage(PageIndex) räumt auf einem geladenen Dokument zuerst jede dieser Strukturen ab und schreibt den Seitenbaum zuletzt um. Der Index ist nullbasiert. Zwei Guards laufen, bevor sich etwas ändert: Ein Index außerhalb des Bereichs löst Invalid page number aus, und der Versuch, die einzige verbleibende Seite zu entfernen, wirft ebenfalls eine Exception, denn ein /Pages-Knoten ohne Kinder beschreibt kein gültiges Dokument

Die Abräumreihenfolge ist fest: der /Names /Dests-Baum, das Legacy-/Dests-Dictionary im Katalog, /OpenAction, die Outline, /StructTreeRoot samt seinem ParentTree und zuletzt die /Annots-Arrays jeder Seite, die bleibt. Erst danach wird das Seitenobjekt als gelöscht markiert, werden seine Widget-Annotationen vom AcroForm-Feldbaum abgehängt und /Kids, /Count und das /Parent jeder überlebenden Seite neu geschrieben. Auf der Form-Seite wird ein Elternfeld, das nach dem Wegfall seines Widgets keine Kinder mehr hat, mit entfernt, aufwärts bis /Fields, sodass ein gekürztes Formular keinen Feldeintrag zurückbehält, hinter dem auf dem Bildschirm nichts mehr steht

Der feste Reference-Sweep, den THotPDF.DeletePage läuft, bevor der Seitenbaum angefasst wird: Guards weisen einen Index außerhalb des Bereichs oder die letzte Seite zurück, dann werden /Names /Dests und das Legacy-/Dests beschnitten, /OpenAction fallengelassen, Outlines auf NearestRetainedPage umgezielt, StructTreeRoot und ParentTree beschnitten, Links auf erhaltenen Seiten entfernt, und RebuildLoadedPageTree läuft zuletzt
Jede Struktur bekommt die Behandlung, die die Spezifikation erlaubt: Namen verschwinden, Bookmarks landen auf der nächsten erhaltenen Seite, Structure Elements verlieren /Pg oder verschwinden, und das /Kids-Umschreiben passiert erst, nachdem nichts anderes mehr das gelöschte Objekt erreichen kann

DeletePages nimmt einen 1-basierten Bereichs-String wie '1,3-5,7-' entgegen, die Notation der anderen Seitenoperationen auf geladenen Dokumenten, und löscht von der höchsten gewählten Seite abwärts. Von oben nach unten bleibt das, was Sie geschrieben haben, gültig, während Seiten unter den Zahlen verschwinden. Ein Bereich, der alle Seiten abdeckt, wirft aus demselben Grund eine Exception wie DeletePage bei der letzten Seite, und die Funktion liefert zurück, wie viele Seiten sie entfernt hat

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // LoadFromFile liefert die Anzahl der geladenen Seiten
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
      // Nullbasiert: Seite 0 ist das Cover
      Pdf.DeletePage(0);
      // 1-basierter Bereich, gezählt gegen das Dokument im jetzigen Zustand
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Namen verschwinden, Bookmarks wandern

Named Destinations, die auf die gelöschte Seite zielen, werden entfernt. Bookmarks, die auf sie zeigen, werden umgezielt. Im /Names /Dests-Baum wird jeder Knoten besucht und jedes Ziel in beiden Formen geprüft, die die Spezifikation erlaubt: das nackte Array und das Dictionary mit /D-Schlüssel; das Name/Wert-Paar fliegt, wenn das erste Element des Arrays die gelöschte Seite ist. Ein Zwischenknoten, der weder /Names noch /Kids übrig hat, wird von seinem Elternknoten abgehängt, damit der Baum keine hohlen Blätter behält. Das Legacy-/Dests-Dictionary im Katalog bekommt dieselbe Prüfung, Schlüssel für Schlüssel, und eine Katalog-/OpenAction, die auf der gelöschten Seite öffnete, wird gestrichen, womit die Startansicht beim Viewer-Default landet

Ein Knoten, der Einträge verliert, verliert auch sein /Limits-Array. Die neuen unteren und oberen Schlüssel werden nicht neu berechnet, und Viewer lösen Namen auch ohne sie auf. §7.9.6 schreibt /Limits an Zwischen- und Blattknoten zwar vor; wenn eine Preflight-Stufe in Ihrer Pipeline diese Regel streng durchsetzt, ziehen Sie die gekürzte Datei im selben Job damit durch

Die Outline wird über /First und /Next mit einer Besuchsliste und einer Tiefengrenze von 128 durchlaufen, sodass eine korrupte /Next-Kette, die sich selbst zum Kreis schließt, den Aufruf nicht aufhängen kann. Für jedes /Dest-Array oder /GoTo-/D-Array, das auf die gelöschte Seite zielt, wird das erste Element durch die Seite ersetzt, die darauf folgte, oder durch die Seite davor, wenn die gelöschte Seite die letzte war. Die Ansichtsparameter nach der Seitenreferenz bleiben, wie sie waren: Ein /XYZ-Sprung behält seine Left-, Top- und Zoom-Werte und wendet sie auf die neue Seite an

Die Asymmetrie ist Absicht. Ein Name, der zu nichts auflöst, ist legal und harmlos; ein GoTo, der ein fehlendes Ziel nennt, macht schlicht nichts. Ein Bookmark mit null-Destination ist ärgerlicher: Es sitzt weiter in der Seitenleiste, sieht klickbar aus und scheitert. Auf der nächsten überlebenden Seite zu landen ist das bessere von zwei unvollkommenen Ergebnissen. Das Umzielen betrifft nur explizite Ziel-Arrays. Ein Outline-Eintrag, dessen /Dest ein Name-String ist, löst über den Namensbaum auf, und sobald der Sweep diesen Namen entfernt hat, führt das Bookmark nirgendwohin — Dokumente, die Named Bookmarks intensiv nutzen, verdienen nach dem Löschen eine Prüfung. Der Check unten liest Namen mit ResolveLoadedNamedDestination, das nur auf das /Names-Array des Wurzelknotens schaut; für einen flachen Namensbaum liefert das eine aussagekräftige Antwort, während ein Baum, der in /Kids unterteilt ist, für jeden Namen -1 zurückgibt. Die Struktur der Outline selbst, inklusive der nicht offensichtlichen /Count-Semantik, behandelt der Leitfaden zu Bookmarks und Named Destinations auf einem geladenen PDF

// Prüfen Sie das Ergebnis, statt es anzunehmen.
// Memo1 ist ein TMemo auf Ihrem eigenen Formular.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination liest nur das /Names-Array des Wurzelknotens,
// daher ist dieser Check für einen flachen Namensbaum aussagekräftig; auf
// einem Baum mit /Kids liefert er für jeden Namen -1, gelöscht oder nicht
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// Ein Bookmark, das aufs Cover zeigte, löst jetzt auf die Seite auf,
// die darauf folgte — nach dem Löschen der nullbasierte Index 0
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

Structure Tree und ParentTree beschneiden

Structure Elements, die es nur für die gelöschte Seite gab, werden entfernt, und Elemente, die sich über mehrere Seiten ziehen, verlieren ihren /Pg-Schlüssel und behalten ihre Kinder. Der Durchlauf startet an /StructTreeRoot, steigt über /K bis zu einer Tiefe von 128 hinab und akzeptiert beide Formen von /K, die §14.7.2 erlaubt: das Array und das einzelne Dictionary. Die Kinder eines Elements werden gestutzt, bevor das Element selbst beurteilt wird. Hat das Stutzen sein /K geleert, wird das Element entfernt und vom Elternknoten fallengelassen. Benennt sein eigenes /Pg die gelöschte Seite, hat aber noch Kinder und einen /P-Elternknoten, geht nur /Pg, denn /Pg an einem Element ist nur die Default-Seite für Marked-Content-Kinder, die keine eigene nennen, und die können durchaus auf anderen Seiten leben. Ein Element, dessen /Pg die gelöschte Seite ist und unter dem nichts mehr liegt, fliegt komplett raus; Marked-Content-Referenz-Dictionarys, deren /Pg die Seite nennt, fallen unter dieselbe letzte Regel

Der ParentTree bekommt dieselbe Behandlung, aus einem Grund, der leicht übersehen wird: Ein Element kann über den ParentTree erreichbar sein und sonst nirgends. Dieser Number Tree bildet /StructParents-Integer auf ein einzelnes Element oder ein Array von Elementen ab. Jeder Wert wird nach den Regeln oben gestutzt, entfernte Werte fallen aus ihren Arrays, ein /Nums-Paar mit leergegangenem Array wird gelöscht, und ein Knoten ohne /Nums und ohne /Kids wird abgehängt. Lassen Sie diesen Durchlauf weg, zeigen die Waisen weiter über /Pg auf eine freigegebene Seite. Wer Text in Tag-Reihenfolge extrahiert, merkt das direkt: Textextraktion in Structure-Order läuft genau über diese Bäume, und ein Element mit null-/Pg ist ein Absatz, der aus der Lesereihenfolge fällt

Warum werden Links auf überlebenden Seiten entfernt statt umgezielt?

Eine Link-Annotation auf einer behaltenen Seite, deren /Dest-Array oder /GoTo-Action auf die gelöschte Seite zielt, wird aus dem /Annots-Array dieser Seite entfernt, und ihr Tag wandert mit raus. Das OBJR-Dictionary, dessen /Obj die Annotation nannte, wird aus seinem Structure Element genommen; war dieses OBJR das einzige Kind des Elements, wird auch das Element entfernt und der ParentTree erneut gestutzt. Das OBJR liegen zu lassen würde §14.7.4.3 brechen, der erwartet, dass /Obj ein reales Objekt referenziert, und ein PDF/UA-Checker sähe ein Link-Element, dessen Objektreferenz ins Leere führt

Bookmarks wandern, Links nicht. Ein Querverweis im Fließtext, der „siehe Seite 3“ sagt, ist falsch, sobald Seite 3 weg ist, und ihn still auf Seite 4 umzubiegen macht aus einem toten Link einen irreführenden. Ein Bookmark, das auf das nächste Kapitel landet, ist Navigation; ein Link im Text ist eine Aussage über den Inhalt. Entfernen ist der sicherere Default. Braucht Ihr Workflow diese Links, schreiben Sie ihre Ziele um, bevor Sie DeletePage aufrufen — nach dem Aufruf sind sie bereits aus den /Annots-Arrays draußen

Inkrementelle Saves und die Falle mit den direkten Kindern

DeletePage verhält sich zwischen BeginIncrementalUpdate und SaveIncrementalUpdate genau gleich. Der angehängte Abschnitt hält die Objekte, die der Sweep verändert hat, plus Free-Einträge für entfernte Objekte mit eigener Objektnummer, etwa die Seite und die gelöschten Link-Annotationen. Alles über dem ursprünglichen %%EOF bleibt Byte für Byte erhalten, wie §7.5.6 es verlangt

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Structure Elements, die ein direktes /MCR-Kind verloren haben,
  // werden in umgeschriebener Form mit kürzerem /K-Array angehängt
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

An dieser Stelle geht ein handgeschriebener Pruner schief. Marked-Content-Referenzen (/Type /MCR) und Objektreferenzen (/Type /OBJR) sind typischerweise direkte Dictionarys, die im /K-Array ihres Eltern-Elements sitzen. Ein direktes Objekt hat keine Objektnummer und keinen Xref-Eintrag, es lässt sich also nicht eigenständig freigeben. Der naheliegende Move ist, jedes entfernte Kind trotzdem freizugeben, und jeder Writer, der ein direktes Objekt auf das indirekte Objekt abbildet, das es hält, gibt dann den Halter frei — nämlich das behaltene Structure Element. Dieses Element geht als null raus, und getaggter Content auf Seiten, die nie angefasst wurden, verliert seine Tags. HotPDF schickt ein entferntes Objekt nur dann auf die Free List, wenn es eine Objektnummer hat; bei einem direkten Kind markiert es das beherbergende Element als geändert, sodass das Element mit entferntem Kind neu geschrieben wird und die Free List sauber bleibt

Warum ein entferntes /MCR- oder OBJR-Kind in einem inkrementellen Update nie als frei registriert werden darf: Es ist ein direktes Dictionary im /K-Array seines Elements, würde man es freigeben, gäbe man das beherbergende Structure Element frei, das als null geschrieben würde und überlebende Seiten de-taggen würde, während der korrekte Weg den Container neu schreibt und die Free List in Ruhe lässt
Nur Objekte mit eigener Objektnummer landen auf der Free List, daher hängt ein inkrementeller Save die umgeschriebenen Container plus Free-Einträge für die Seite und die anderen entfernten indirekten Objekte an

Dieselbe Vorsicht entscheidet, was DeletePage auf einem geladenen Dokument in Ruhe lässt. Die Content Streams, XObjects und Nicht-Widget-Annotationen der gelöschten Seite bleiben als Objekte bestehen, denn eine geladene Datei kann jedes davon mit einer Seite teilen, die bleibt, und beim Löschen gibt es keinen billigen Weg, das Gegenteil zu beweisen. Den Seitenbaum-Verweis zu kappen reicht für eine korrekte Datei. Wie viele Bytes dieser Ballast noch kostet, ist eine separate Messung, und der Objekt-Dependency-Graph mit Retained-Bytes-Analyse ist das Werkzeug dafür

DeletePage oder DeleteLoadedPage?

Nehmen Sie DeletePage für jedes Seitenentfernen, das ein Nutzer verlangt. THotPDF.DeleteLoadedPage(PageIndex) ist die Leichtgewicht-Variante: Sie wendet dieselben zwei Guards an, verschiebt die Liste der geladenen Seiten, schreibt /Kids und /Count um, invalidiert den Cache gerenderter Seiten und feuert OnLoadedDocumentModified. Sie berührt weder Namensbaum noch Outline, Structure Tree oder die Annotationen anderer Seiten, und sie markiert das Seitenobjekt nicht als gelöscht. ComposeLoadedPagesNUp ist der Job, zu dem sie passt: Er hängt frisch komponierte Blätter an, die die Quellseiten als Form XObjects zeichnen, und wirft dann jedes Original mit DeleteLoadedPage(0) raus, weil die Blätter auf den Content der Seiten verweisen, nicht auf die Seitenobjekte. Für „Seite 7 aus diesem Vertrag entfernen“ auf einer getaggten, vermerkten und querverlinkten Datei ist DeletePage der Aufruf, der alle Referenzen konsistent lässt, egal ob Sie mit SaveLoadedDocument speichern oder mit SaveIncrementalUpdate anhängen

Beide Methoden, das bereichsbasierte DeletePages und der oben gezeigte inkrementelle Update-Pfad, gehören zur HotPDF Delphi Component, die geladene PDFs aus Delphi- und C++Builder-Code heraus bearbeitet, ohne eine Viewer-Runtime auf der Maschine