Die HotPDF Delphi Component löscht eine Seite aus einem geladenen PDF über THotPDF.DeletePage, und seit Version 2.751.0 beschneidet dieser Aufruf auch jede Referenz auf Dokumentebene, die noch auf die Seite zeigt: Named Destinations im /Names-/Dests-Baum, das Legacy-Katalog-/Dests-Dictionary, Bookmark-/GoTo-Aktionen, Structure Elements unter /StructTreeRoot, den ParentTree, OBJR-Einträge für Annotationen und Link-Annotationen auf überlebenden Seiten. Der Seitenbaum wird zuletzt neu aufgebaut, nachdem nichts anderes mehr das gelöschte Objekt erreichen kann
Der Fehlschlag, den das verhindert, ist leicht zu reproduzieren und schwer zu diagnostizieren. Löschen Sie die Titelseite eines getaggten Berichts, speichern und öffnen Sie das Ergebnis: Acrobat zeigt die richtige Seitenzahl, aber das Inhaltsverzeichnis-Bookmark landet jetzt nirgends, der Accessibility-Checker meldet ein Structure Element ohne Seite, und ein strenger Validator listet eine Referenz auf ein freies Objekt. Am Seitenbaum ist nichts falsch. Das Problem ist, dass eine PDF-Seite nicht nur ein Blatt von /Pages ist; sie ist ein Ziel, auf das der halbe Katalog zeigt, und das Blatt zu entfernen lässt jeden dieser Pointer baumeln
Warum reicht es nicht, eine Seite aus /Kids zu entfernen?
Weil ISO 32000-1 mindestens sieben unabhängige Strukturen zulässt, die eine Referenz auf ein Seitenobjekt halten, und nur eine davon der Seitenbaum ist. Die Seite aus /Kids zu nehmen und /Count zu verringern genügt §7.7.3, und jede andere Referenz wird zu einem Pointer auf ein Objekt, das entweder in der xref freigegeben ist oder schlicht in der umgeschriebenen Datei fehlt. Ein Viewer, der einem dieser Pointer folgt, bekommt null, und was er mit dem Null anstellt, ist seine Sache
- Der Name Tree unter
/Names/Dests(§7.7.4, §12.3.2.3) mappt Namen auf Ziel-Arrays, deren erstes Element die Seite ist - Das Pre-1.2-
/Dests-Dictionary direkt im Katalog hält dieselbe Art Arrays, gekeyt nach Name - Outline-Items (§12.3.3) erreichen eine Seite entweder über ein inline
/Destoder über eine/A-Aktion mit/S /GoTound einem/D-Array - Structure Elements (§14.7.2) tragen einen
/Pg-Key, der die Seite benennt, auf der ihr Marked Content lebt, und ihre/K-Kids dürfen Marked-Content-Referenzen und Objektreferenzen (§14.7.4.3) sein, die an diese Seite gebunden sind - Der
ParentTree(§14.7.4.4) mappt Seiten- und Annotation-/StructParents-Nummern zurück auf Structure Elements, und ein Element kann dort leben, ohne überhaupt in der/K-Kette vom Root aus zu erscheinen - Link-Annotationen auf anderen Seiten (§12.5.6.5) tragen eine
/Destoder/GoTo-Aktion, die auf die Seite zielt, und das Katalog-/OpenActionkann dasselbe tun
Was räumt THotPDF.DeletePage auf, bevor es den Seitenbaum anfasst?
THotPDF.DeletePage(PageIndex) auf einem geladenen Dokument läuft zuerst den kompletten Reference-Sweep, markiert dann das Seitenobjekt mit DeleteObj als gelöscht, trennt Widget-Annotationen vom AcroForm-Feldbaum, verschiebt das interne Seiten-Array und ruft schließlich RebuildLoadedPageTree auf, um /Kids, /Count und das /Parent jeder überlebenden Seite umzuschreiben. Der Sweep besucht den Katalog in fester Reihenfolge: den /Names-/Dests-Namensbaum, das altmodische /Dests-Dictionary, /OpenAction, den Outline-Baum, /StructTreeRoot mit seinem ParentTree und zuletzt die /Annots-Arrays jeder Seite, die bleibt. Jeder Schritt entscheidet anhand dessen, was die Spezifikation der Struktur ohne die Seite erlaubt, ob eine Referenz entfernt, umgezielt oder in Ruhe gelassen wird. Zwei Guards greifen, bevor irgendetwas läuft: DeletePage wirft Invalid page number für einen Index außerhalb des Bereichs und verweigert das Entfernen der letzten Seite, denn ein /Pages-Knoten mit null Kids ist kein gültiges PDF, während DeletePages dieselbe 1-basierte "1,3-5,7-"-Notation wie die anderen Seitenoperationen für geladene Dokumente nimmt und vom höchsten gewählten Index abwärts iteriert, sodass die Indizes, die Sie geschrieben haben, während der Arbeit gültig bleiben
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// Zero-basiert: die Titelseite entfernen. Named Destinations,
// Bookmarks, Structure Tree, ParentTree und Link-
// Annotationen, die darauf zeigten, werden beschnitten, bevor
// der /Pages-Baum neu aufgebaut wird.
Pdf.DeletePage(0);
// One-based-Bereichssyntax für Stapel, höchster Index zuerst,
// intern, damit frühere Indizes gültig bleiben.
Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
Wie werden Named Destinations und Bookmarks unterschiedlich behandelt?
Named Destinations werden entfernt und Bookmarks umgezielt, denn ein Name, der nicht mehr existiert, ist ein akzeptables Ergebnis, während ein Bookmark ohne Ziel ein sichtbarer Defekt ist. Im /Names-/Dests-Baum läuft HotPDF jeden Knoten durch, testet jedes Ziel – in der nackten Array-Form und in der Dictionary-Form mit einem /D-Key – gegen die gelöschte Seite und entfernt das Name/Wert-Paar, wenn das erste Element des Arrays diese Seite ist. Ein Knoten, dessen /Names und /Kids beide leer enden, wird als gelöscht markiert und von seinem Elternteil abgekoppelt, also behält der Baum nie hohle Blätter. Denselben Test durchläuft das altmodische Katalog-/Dests-Dictionary, und das Katalog-/OpenAction wird schlicht fallengelassen, wenn es auf der gelöschten Seite öffnete. Eine Grenze an der Stelle: Wenn ein Namensbaum-Knoten Einträge verliert, löscht HotPDF das /Limits-Paar dieses Knotens, statt die neuen untersten und obersten Keys neu zu berechnen, und während Viewer Namen ohne es problemlos auflösen, darf ein strenger Konformitätschecker, der ISO 32000-1 §7.9.6 liest, einen Nicht-Root-Knoten ohne /Limits anmarken
Outline-Items gehen den anderen Weg. RetargetOutlineDestinations durchläuft /First und /Next vom Outline-Root aus, mit einer Besucht-Liste und einer Tiefengrenze von 128, damit ein korrupter zyklischer Baum den Aufruf nicht aufhängen kann, und für jedes /Dest-Array oder /GoTo-Aktion-/D-Array, das auf die Seite zielt, ersetzt es das erste Element durch NearestRetainedPage: die Seite, die auf die gelöschte folgte, oder die Seite davor, wenn die gelöschte die letzte war. Die View-Parameter nach der Seitenreferenz bleiben, wie sie waren. Ein Bookmark, das auf einen gelöschten Kapitelanfang zeigte, landet daher auf der ersten Seite dessen, was bleibt, statt aus der Seitenleiste zu verschwinden – das Verhalten, das Reviewer von einem gekürzten Dokument erwarten. Der Zieltest matcht allerdings nur explizite Arrays: Ein Outline-Item, dessen /Dest eine Namens-Zeichenkette ist, die früher zur gelöschten Seite aufgelöst wurde, wird nicht umgezielt, denn der Namensbaum-Eintrag ist weg, und die Referenz löst jetzt auf nichts auf statt auf ein freigegebenes Objekt, also behandelt der Viewer es als totes Bookmark. Die Mechanik des Outline-Baums selbst, /First, /Next und die nicht offensichtliche /Count-Semantik, behandelt der Leitfaden zum Hinzufügen von Bookmarks und Named Destinations auf einem geladenen PDF
// Den Sweep verifizieren statt ihm zu vertrauen.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// Ein Bookmark, das auf das Cover zeigte, löst jetzt auf die
// darauf folgende Seite auf (nullbasierter Index 0 nach dem Löschen).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
Was passiert mit dem Structure Tree und dem ParentTree?
Structure Elements, die nur wegen der gelöschten Seite existieren, werden entfernt, und Elemente, die sich über mehrere Seiten spannen, verlieren ihren /Pg-Key, behalten aber ihre Kinder. PruneStructureElement steigt die /K-Kette von /StructTreeRoot bis zu einer Tiefe von 128 hinab und behandelt sowohl die Array-Form als auch die Einzel-Dictionary-Form von /K, die §14.7.2 erlaubt. Für jedes Element beschneidet es zuerst die Kids und bewertet dann das Element selbst: Hat das Beschneiden sein /K geleert, wird das Element als gelöscht markiert, und sein Elternteil wirft es heraus. Benennt das eigene /Pg des Elements die gelöschte Seite und hat das Element noch Kids plus ein /P-Elternteil, wird nur /Pg entfernt, denn ein /Pg an einem Element ist die Default-Seite für seine Marked-Content-Kids, und diese Kids dürfen andere Seiten explizit referenzieren. Nur ein Element, dessen /Pg die gelöschte Seite ist und unter dem nichts mehr liegt, wird rundweg entfernt
Der ParentTree bekommt dieselbe Behandlung, und der Grund ist der, der während der Entwicklung gebissen hat: Ein Structure Element kann aus dem ParentTree heraus erreichbar sein und sonst nirgends. Der Number Tree mappt /StructParents-Integer auf entweder ein einzelnes Element oder ein Array von Elementen, und PruneParentTreeNode läuft PruneStructureElement über jeden Wert, den es findet, entfernt beschnittene Werte, löscht ein /Nums-Paar, wenn sein Wert-Array leer ist, und koppelt einen Knoten ab, dessen /Nums und /Kids beide weg sind. Nur die Nachfahren von /K zu beschneiden hätte diese verwaisten Elemente durch /Pg auf eine freigegebene Seite zeigen lassen und durch ihre /MCR-Kids auf freigegebene Marked-Content-Referenzen. Wenn Sie Text in Strukturordnung extrahieren, geht Sie das direkt an: Die Textextraktion in Strukturordnung läuft exakt durch diese Bäume, und ein Element mit null als /Pg ist ein Absatz, der stillschweigend aus der Lesereihenfolge fällt
Welche Link-Annotationen auf überlebenden Seiten werden entfernt?
Jede Link-Annotation auf einer erhaltenen Seite, deren /Dest-Array oder /GoTo-Aktion auf die gelöschte Seite zeigt, wird mitsamt ihrem Structure-Tree-Eigentum entfernt. RemoveRetainedPageDestinationAnnotations läuft das /Annots-Array jeder anderen Seite als der Zielseite durch, wendet denselben Zieltest wie für Outlines an, markiert eine passende Annotation als gelöscht, wirft sie aus dem Array und ruft dann PruneAnnotationReferencesInStructureTree auf, sodass das OBJR-Dictionary, dessen /Obj diese Annotation benannte, aus seinem Structure Element entfernt wird, wobei das Element selbst entfernt wird, wenn das OBJR sein einziges Kid war. Das OBJR an Ort und Stelle zu lassen würde §14.7.4.3 verletzen, der verlangt, dass /Obj ein existierendes Objekt referenziert, und würde in einer PDF/UA-Prüfung als getaggter Link ohne Annotation dahinter auftauchen. Beachten Sie die Asymmetrie zu Bookmarks: Links werden entfernt, nicht umgezielt. Ein Querverweis im Fließtext, der „siehe Seite 3“ sagte, ist falsch, sobald Seite 3 weg ist, und ihn auf Seite 4 zu zeigen wäre eine Lüge in einer Art, wie es ein Bookmark auf dem nächsten Kapitel nicht ist – wenn Ihr Workflow diese Links also behalten muss, zielen Sie sie selbst um, bevor Sie DeletePage aufrufen
Warum darf eine entfernte /MCR oder /OBJR nie als frei registriert werden?
Weil Marked-Content-Referenzen und Objektreferenzen üblicherweise direkte Dictionarys im /K-Array ihres Elternelements sind, und die Incremental-Change-Registry ein direktes Objekt auf das nächstliegende indirekte Objekt auflöst, das es enthält. Wenn RemoveArrayItem ein Kid aus einem /K-Array wirft, gibt es das In-Memory-Objekt nur frei, wenn es ein THPDFLink oder ein nicht-indirekter Wert war, und MarkRemovedObject registriert ein Objekt nur für die Free-Liste, wenn seine Objektnummer größer als null ist. Die erste Version dieses Sweeps traf diese Unterscheidung nicht, und die Wirkung in einem inkrementellen Save war exakt das, wofür die Registry gebaut ist: RegisterIncrementalChange stieg vom direkten /MCR auf zu seiner Graph-Transaktionswurzel, dem erhaltenen Structure Element, das es besaß, und schrieb dieses Element als null heraus. Ein Dokument, das eine Seite verlor, kam mit getaggtem Inhalt auf den anderen Seiten zurück, stillschweigend enttaggt. Der einzige korrekte Zug für ein direktes Kid ist, seinen Container über TouchContainer als dirty zu markieren, damit der Container umgeschrieben wird, und die Free-Liste in Ruhe zu lassen
// Inkrementelles Update: nur die berührten Container und das
// freigegebene Seitenobjekt landen im angehängten Abschnitt.
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Erhaltene Structure Elements, deren /K eine direkte /MCR verlor,
// werden an Ort und Stelle umgeschrieben, nie als null geschrieben.
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
Dieselbe Vorsicht prägt, was DeletePage auf einem geladenen Dokument bewusst nicht freigibt. Content-Streams, XObjects und die Nicht-Widget-Annotationen der gelöschten Seite bleiben als Objekte liegen, denn eine geladene Datei kann jedes davon mit einer Seite teilen, die bleibt, und es gibt keinen billigen Weg, das Gegenteil zum Löschzeitpunkt zu beweisen. Die Seitenbaum-Referenz zu entfernen genügt für die Korrektheit; die Bytes, die diese Objekte noch belegen, sind eine eigene Frage, und die Objekt-Dependency-Graph- und Retained-Bytes-Analyse ist das Werkzeug, um zu messen, was ein gekürztes Dokument noch mit sich trägt
DeletePage gegen DeleteLoadedPage: Welche sollten Sie aufrufen?
Nehmen Sie DeletePage für jede benutzersichtbare Seitenentfernung und heben Sie DeleteLoadedPage für den Fall auf, dass das ganze Dokument neu umbrochen wird und keine Referenz auf Dokumentebene es wert ist, behalten zu werden. THotPDF.DeleteLoadedPage(PageIndex), hinzugekommen in Version 2.508.0, ist die Leichtbau-Variante: Sie verschiebt das interne Seiten-Array, ruft RebuildLoadedKidsArray auf, um /Kids und /Count umzuschreiben, invalidiert den gerenderten Seiten-Cache und feuert OnLoadedDocumentModified. Sie läuft weder den Namensbaum, die Outlines, den Structure Tree noch die Annotationen anderer Seiten durch und markiert das Seitenobjekt nicht als gelöscht. Das ist das richtige Werkzeug in einer N-up-Imposition, wo HotPDF frisch komponierte Bögen anhängt und dann jede Originalseite mit DeleteLoadedPage(0) fallen lässt: Die Quellseiten werden en bloc ersetzt, und der Bogeninhalt bezieht sich auf ihre Ressourcen statt auf die Seitenobjekte. Für die gewöhnliche Aufgabe „Seite 7 aus diesem Vertrag entfernen“ ist DeletePage der einzige Aufruf, der ein getaggtes, mit Bookmarks versehenes, querverlinktes Dokument konsistent genug für einen Validator lässt – sowohl in einem Full Rewrite über SaveLoadedDocument als auch in einem inkrementellen Update über SaveIncrementalUpdate. Beide Methoden kommen in der HotPDF Delphi Component für Delphi und C++Builder daher, ohne externe Viewer-Runtime oder Abhängigkeit