HotPDF Delphi Component deletes a page from a loaded PDF through THotPDF.DeletePage, and since version 2.751.0 that call also prunes every document-level reference that still points at the page: named destinations in the /Names /Dests tree, the legacy catalog /Dests dictionary, bookmark /GoTo actions, structure elements under /StructTreeRoot, the ParentTree, OBJR entries for annotations, and link annotations on surviving pages. The page tree is rebuilt last, after nothing else can reach the deleted object
The failure this prevents is easy to reproduce and hard to diagnose. Delete the cover page of a tagged report, save, and open the result: Acrobat shows the right page count, but the "Contents" bookmark now lands nowhere, the accessibility checker reports a structure element with no page, and a strict validator lists a reference to a free object. Nothing in the page tree is wrong. The problem is that a PDF page is not only a leaf of /Pages; it is a target that half the catalog points at, and removing the leaf leaves every one of those pointers dangling
Why is removing a page from /Kids not enough?
Because ISO 32000-1 lets at least seven independent structures hold a reference to a page object, and only one of them is the page tree. Dropping the page from /Kids and decrementing /Count satisfies §7.7.3, and every other reference becomes a pointer to an object that is either freed in the xref or simply absent from the rewritten file. A viewer that follows one of those pointers gets null, and what it does with that null is up to the viewer
- The name tree under
/Names/Dests(§7.7.4, §12.3.2.3) maps names to destination arrays whose first element is the page - The pre-1.2
/Destsdictionary directly in the catalog holds the same kind of arrays keyed by name - Outline items (§12.3.3) reach a page either through an inline
/Destor through an/Aaction with/S /GoToand a/Darray - Structure elements (§14.7.2) carry a
/Pgkey naming the page their marked content lives on, and their/Kkids may be marked-content references and object references (§14.7.4.3) tied to that page - The
ParentTree(§14.7.4.4) maps page and annotation/StructParentsnumbers back to structure elements, and an element can live there without appearing on the/Kchain from the root at all - Link annotations on other pages (§12.5.6.5) carry a
/Destor/GoToaction targeting the page, and the catalog/OpenActionmay do the same
What does THotPDF.DeletePage clean up before it touches the page tree?
THotPDF.DeletePage(PageIndex) on a loaded document runs the whole reference sweep first, then marks the page object deleted with DeleteObj, detaches any widget annotations from the AcroForm field tree, shifts the internal page array, and finally calls RebuildLoadedPageTree to rewrite /Kids, /Count, and each surviving page's /Parent. The sweep visits the catalog in a fixed order: the /Names /Dests name tree, the old-style /Dests dictionary, /OpenAction, the outline tree, /StructTreeRoot with its ParentTree, and last the /Annots arrays of every page that stays. Each step decides whether a reference is removed, retargeted, or left alone according to what the spec allows that structure to do without the page. Two guards apply before any of it runs: DeletePage raises Invalid page number for an out-of-range index and refuses to remove the last page, because a /Pages node with zero kids is not a valid PDF, while DeletePages takes the same one-based "1,3-5,7-" notation as the other loaded-document page operations and iterates from the highest selected index down so the indices you wrote stay valid while it works
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
begin
// Zero-based: drop the cover page. Named destinations,
// bookmarks, structure tree, ParentTree and link
// annotations that pointed at it are pruned before the
// /Pages tree is rebuilt.
Pdf.DeletePage(0);
// One-based range syntax for batches, highest index first
// internally so earlier indices stay valid.
Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
How are named destinations and bookmarks handled differently?
Named destinations are removed and bookmarks are retargeted, because a name that no longer exists is an acceptable outcome while a bookmark with no destination is a visible defect. In the /Names /Dests tree HotPDF walks every node, tests each destination, in both the bare array form and the dictionary form with a /D key, against the deleted page, and removes the name/value pair when the first element of the array is that page. A node whose /Names and /Kids both end up empty is marked deleted and unlinked from its parent, so the tree never keeps hollow leaves. The same test runs over the old-style catalog /Dests dictionary, and the catalog /OpenAction is simply dropped if it opened on the deleted page. One boundary here: when a name-tree node loses entries, HotPDF deletes that node's /Limits pair instead of recomputing the new lowest and highest keys, and while viewers resolve names fine without it, a strict conformance checker reading ISO 32000-1 §7.9.6 may flag a non-root node that lacks /Limits
Outline items go the other way. RetargetOutlineDestinations traverses /First and /Next from the outline root, with a visited list and a depth limit of 128 so a corrupt cyclic tree cannot hang the call, and for every /Dest array or /GoTo action /D array aimed at the page it replaces the first element with NearestRetainedPage: the page that followed the deleted one, or the page before it when the deleted page was last. The view parameters after the page reference are left as they were. A bookmark that pointed at a deleted chapter opener therefore lands on the first page of what remains rather than disappearing from the sidebar, which is the behavior reviewers expect from a trimmed document. The destination test matches explicit arrays only, though: an outline item whose /Dest is a name string that used to resolve to the deleted page is not retargeted, because the name-tree entry is gone and the reference now resolves to nothing rather than to a freed object, so the viewer treats it as a dead bookmark. The mechanics of the outline tree itself, /First, /Next, and the non-obvious /Count semantics, are covered in the guide to adding bookmarks and named destinations on a loaded PDF
// Verify the sweep instead of trusting it.
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
ShowMessage('Named destination "cover" was pruned');
// A bookmark that targeted the cover now resolves to the
// page that followed it (zero-based index 0 after the delete).
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
ShowMessage('Bookmark retargeted to the nearest retained page');
What happens to the structure tree and the ParentTree?
Structure elements that exist only because of the deleted page are removed, and elements that span several pages lose their /Pg key but keep their children. PruneStructureElement descends the /K chain from /StructTreeRoot to a depth of 128, handling both the array form and the single-dictionary form of /K that §14.7.2 permits. For each element it first prunes the kids, then evaluates the element itself: if pruning emptied its /K, the element is marked deleted and its parent drops it. If the element's own /Pg names the deleted page and the element still has kids plus a /P parent, only /Pg is removed, because a /Pg on an element is the default page for its marked-content kids and those kids may reference other pages explicitly. Only an element whose /Pg is the deleted page and which has nothing left under it is removed outright
The ParentTree gets the same treatment, and the reason is the one that bit during development: a structure element can be reachable from the ParentTree and nowhere else. The number tree maps /StructParents integers to either a single element or an array of elements, and PruneParentTreeNode runs PruneStructureElement over every value it finds, removes values that were pruned away, deletes a /Nums pair when its value array is empty, and unlinks a node whose /Nums and /Kids are both gone. Pruning only the descendants of /K would have left those orphaned elements pointing at a freed page through /Pg and at freed marked-content references through their /MCR kids. If you extract text in structure order, that matters directly: structure-order text extraction walks exactly these trees, and an element with a null /Pg is a paragraph that silently drops out of the reading order
Which link annotations on surviving pages are removed?
Any link annotation on a retained page whose /Dest array or /GoTo action points at the deleted page is removed together with its structure-tree ownership. RemoveRetainedPageDestinationAnnotations walks the /Annots array of every page other than the target, applies the same destination test used for outlines, marks a matching annotation deleted, drops it from the array, and then calls PruneAnnotationReferencesInStructureTree so the OBJR dictionary whose /Obj named that annotation is removed from its structure element, with the element itself removed if the OBJR was its only kid. Leaving the OBJR in place would violate §14.7.4.3, which requires /Obj to reference an existing object, and would show up in a PDF/UA check as a tagged link with no annotation behind it. Note the asymmetry with bookmarks: links are removed, not retargeted. A cross-reference in body text that said "see page 3" is wrong once page 3 is gone, and pointing it at page 4 would be a lie in a way that a bookmark landing on the nearest chapter is not, so if your workflow needs those links preserved, retarget them yourself before calling DeletePage
Why must a removed /MCR or /OBJR never be registered as free?
Because marked-content references and object references are usually direct dictionaries inside their parent element's /K array, and the incremental change registry resolves a direct object to the nearest indirect object that contains it. When RemoveArrayItem drops a kid from a /K array it frees the in-memory object only if it was a THPDFLink or a non-indirect value, and MarkRemovedObject registers an object for the free list only when its object number is greater than zero. The first version of this sweep did not make that distinction, and the effect in an incremental save was exactly what the registry is designed to do: RegisterIncrementalChange walked from the direct /MCR up to its graph transaction root, which was the retained structure element that owned it, and wrote that element out as null. A document that lost one page came back with tagged content on the other pages silently untagged. The only correct move for a direct kid is to mark its container dirty through TouchContainer so the container is rewritten, and to leave the free list alone
// Incremental update: only the touched containers and the
// freed page object land in the appended section.
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// Retained structure elements whose /K lost a direct /MCR
// are rewritten in place, never written as null.
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
The same caution shapes what DeletePage deliberately does not free on a loaded document. Content streams, XObjects, and the non-widget annotations of the deleted page are left as objects, because a loaded file may share any of them with a page that stays and there is no cheap way to prove otherwise at deletion time. Removing the page-tree reference is enough for correctness; the bytes those objects still occupy are a separate question, and the object dependency graph and retained-bytes analysis is the tool for measuring what a trimmed document still carries
DeletePage versus DeleteLoadedPage: which one should you call?
Call DeletePage for any user-facing page removal, and reserve DeleteLoadedPage for the case where the whole document is being reflowed and no document-level reference is worth keeping. THotPDF.DeleteLoadedPage(PageIndex), added in version 2.508.0, is the lightweight variant: it shifts the internal page array, calls RebuildLoadedKidsArray to rewrite /Kids and /Count, invalidates the rendered-page cache, and fires OnLoadedDocumentModified. It does not walk the name tree, the outlines, the structure tree, or the annotations of other pages, and it does not mark the page object deleted. That is the right tool inside N-up imposition, where HotPDF appends freshly composed sheets and then drops every original page with DeleteLoadedPage(0): the source pages are being replaced wholesale, and the sheet content refers to their resources rather than to the page objects. For the ordinary "remove page 7 from this contract" job, DeletePage is the only call that leaves a tagged, bookmarked, cross-linked document consistent enough to pass a validator, in a full rewrite through SaveLoadedDocument and in an incremental update through SaveIncrementalUpdate alike. Both methods ship in the HotPDF Delphi Component for Delphi and C++Builder, with no external viewer runtime or dependency required