Technical Article

Delete PDF Pages in Delphi Without Dangling References

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 /Dests dictionary 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 /Dest or through an /A action with /S /GoTo and a /D array
  • Structure elements (§14.7.2) carry a /Pg key naming the page their marked content lives on, and their /K kids 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 /StructParents numbers back to structure elements, and an element can live there without appearing on the /K chain from the root at all
  • Link annotations on other pages (§12.5.6.5) carry a /Dest or /GoTo action targeting the page, and the catalog /OpenAction may do the same
Why removing a HotPDF page from /Kids is not enough: ISO 32000-1 lets the /Names /Dests name tree, the legacy catalog /Dests dictionary, outline items, structure elements with /Pg, the ParentTree, link annotations and /OpenAction all hold a reference to the same page object, and only the page tree is rebuilt
A PDF page is a target half the catalog points at: dropping the leaf satisfies the page tree while every other pointer resolves to null, so a trimmed report loses its Contents bookmark and fails its accessibility check

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

The fixed reference sweep THotPDF.DeletePage runs before the page tree is touched: guards reject an out-of-range index or the last page, then /Names /Dests and the legacy /Dests are pruned, /OpenAction dropped, outlines retargeted to NearestRetainedPage, StructTreeRoot and ParentTree pruned, retained-page links removed, and RebuildLoadedPageTree runs last
Each structure gets the treatment the spec allows: names vanish, bookmarks land on the nearest retained page, structure elements lose /Pg or disappear, and the /Kids rewrite happens only after nothing else can reach the deleted object
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

Why a removed /MCR or OBJR kid must never be registered as free in HotPDF: the incremental change registry resolves a direct dictionary to the nearest indirect container, so the first version wrote the retained structure element out as null and silently untagged surviving pages, while TouchContainer now rewrites the container and leaves the free list alone
Freeing the in-memory kid is reserved for THPDFLink or non-indirect values and for object numbers greater than zero, so an incremental save appends only the touched containers and the freed page object
// 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