Technical Article

Delete PDF Pages in Delphi Without Dangling References

A tagged report with its cover page cut out can open with the right page count and still be broken in three places: the "Contents" bookmark does nothing when clicked, the accessibility checker reports a structure element that belongs to no page, and a strict validator flags a reference to a free object. The page tree is fine. ISO 32000-1 §7.7.3 asks only that the page leave /Kids and that /Count drop by one, and a file can meet that clause while half of its catalog still points at the missing page

Seven structures can hold a reference to one page

Only one of them is the page tree. The rest are spread across the catalog, the outline, the logical structure and the annotations of other pages, and every one of them resolves to null once the page object is gone from the file. What a viewer does with that null is its own choice, so the same trimmed file can misbehave differently in two readers

  • 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 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 can 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 sit there without appearing anywhere on the /K chain from the root
  • Link annotations on other pages (§12.5.6.5) carry a /Dest or /GoTo action aimed at 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 THotPDF.DeletePage does before it touches /Kids

THotPDF.DeletePage(PageIndex) on a loaded document sweeps every one of those structures first and rewrites the page tree last. The index is zero-based. Two guards run before anything changes: an out-of-range index raises Invalid page number, and a request to remove the only remaining page raises an exception too, because a /Pages node with zero kids does not describe a valid document

The sweep order is fixed: the /Names /Dests tree, the legacy catalog /Dests dictionary, /OpenAction, the outline, /StructTreeRoot together with its ParentTree, and finally the /Annots arrays of every page that stays. Only after that is the page object marked deleted, its widget annotations detached from the AcroForm field tree, and /Kids, /Count and each surviving page's /Parent rewritten. On the form side, a parent field left with no kids after its widget goes is removed too, all the way up to /Fields, so a trimmed form keeps no field entry with nothing on screen behind it

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, outline destinations moved to the nearest retained page, StructTreeRoot and ParentTree pruned, retained-page links removed, and /Kids, /Count and /Parent rewritten 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

DeletePages takes a one-based range string such as '1,3-5,7-', the notation the other loaded-document page operations use, and deletes from the highest selected page downward. Going top-down keeps the numbers you wrote valid while pages vanish beneath them. A range that covers every page raises an exception for the same reason DeletePage refuses the last one, and the function returns how many pages it removed

var
  Pdf: THotPDF;
  Removed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // LoadFromFile returns the number of pages it loaded
    if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
    begin
      // Zero-based: page 0 is the cover
      Pdf.DeletePage(0);
      // One-based range, numbered against the document as it is now
      Removed := Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Names disappear, bookmarks move

Named destinations aimed at the deleted page are removed. Bookmarks aimed at it are retargeted. In the /Names /Dests tree every node is visited and each destination is tested in both forms the spec allows, the bare array and the dictionary with a /D key; the name/value pair goes when the array's first element is the deleted page. An intermediate node left with neither /Names nor /Kids is unlinked from its parent, so the tree keeps no hollow leaves. The legacy catalog /Dests dictionary gets the same test, key by key, and a catalog /OpenAction that opened on the deleted page is dropped, which leaves the opening view to the viewer's default

A node that loses entries also loses its /Limits array. The new lowest and highest keys are not recomputed, and viewers resolve names without them. §7.9.6 does require /Limits on intermediate and leaf nodes, so if a preflight step in your pipeline enforces that rule strictly, run the trimmed file through it as part of the same job

The outline is walked through /First and /Next with a visited list and a depth limit of 128, so a corrupt /Next chain that loops back on itself cannot hang the call. For every /Dest array or /GoTo /D array aimed at the deleted page, the first element is replaced with the page that followed it, or with the page before it when the deleted page was the last one. The view parameters after the page reference stay as they were, so an /XYZ jump keeps its left, top and zoom values and applies them to the new page

The asymmetry is deliberate. A name that resolves to nothing is legal and harmless; a GoTo naming a missing destination simply does nothing. A bookmark with a null destination is worse: it still sits in the sidebar, looks clickable, and fails. Landing on the nearest surviving page is the better of two imperfect outcomes. Retargeting covers explicit destination arrays only. An outline item whose /Dest is a name string resolves through the name tree, and once the sweep has removed that name the bookmark goes nowhere, so documents that use named bookmarks heavily deserve a check after deletion. The check below reads names with ResolveLoadedNamedDestination, which looks only at the root node's /Names array; it gives a meaningful answer for a flat name tree, while a tree split into /Kids returns -1 for every name. The structure of the outline itself, including the non-obvious /Count semantics, is covered in the guide to adding bookmarks and named destinations on a loaded PDF

// Check the result instead of assuming it.
// Memo1 is a TMemo on your own form.
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination reads only the root /Names array,
// so this check is meaningful for a flat name tree; on a tree that
// uses /Kids it returns -1 for every name, deleted or not
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// A bookmark that pointed at the cover now resolves to the page
// that followed it, which is zero-based index 0 after the delete
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
  [Pdf.GetLoadedBookmarkPageIndex('Contents')]));

Pruning the structure tree and the ParentTree

Structure elements that existed only for the deleted page are removed, and elements that span several pages lose their /Pg key but keep their children. The walk starts at /StructTreeRoot, descends /K to a depth of 128, and accepts both the array form and the single-dictionary form of /K that §14.7.2 permits. Each element's kids are pruned before the element itself is judged. If pruning emptied its /K, the element is removed and its parent drops it. If its own /Pg names the deleted page but it still has kids and a /P parent, only /Pg goes, because /Pg on an element is just the default page for marked-content kids that do not name their own, and those kids may well live on other pages. An element whose /Pg is the deleted page and which has nothing under it is removed outright; marked-content reference dictionaries whose /Pg names the page fall under that last rule

The ParentTree gets the same treatment, for a reason that is easy to miss: an element can be reachable from the ParentTree and from nowhere else. That number tree maps /StructParents integers to a single element or an array of elements. Every value is pruned with the rules above, values that were removed drop out of their arrays, a /Nums pair whose array ends up empty is deleted, and a node with neither /Nums nor /Kids is unlinked. Skip this pass and the orphans keep pointing at a freed page through /Pg. For anyone extracting text in tag order this is direct: structure-order text extraction walks exactly these trees, and an element with a null /Pg is a paragraph that drops out of the reading order

Why are links on surviving pages removed rather than retargeted?

A link annotation on a retained page whose /Dest array or /GoTo action targets the deleted page is removed from that page's /Annots, and its tag goes with it. The OBJR dictionary whose /Obj named the annotation is taken out of its structure element; if that OBJR was the element's only kid, the element is removed too, and the ParentTree is pruned again. Leaving the OBJR behind would break §14.7.4.3, which expects /Obj to reference a real object, and a PDF/UA checker would see a Link element whose object reference leads nowhere

Bookmarks move; links do not. A cross-reference in body text that says "see page 3" is wrong once page 3 is gone, and quietly pointing it at page 4 turns a broken link into a misleading one. A bookmark landing on the next chapter is navigation; a body link is a claim about content. Removal is the safer default. If your workflow needs those links to survive, rewrite their destinations before calling DeletePage, because after the call they are already out of the /Annots arrays

Incremental saves and the direct-kid trap

DeletePage behaves the same between BeginIncrementalUpdate and SaveIncrementalUpdate. The appended section holds the objects the sweep changed plus free entries for removed objects that had object numbers of their own, such as the page and the deleted link annotations. Everything above the original %%EOF stays byte-for-byte, as §7.5.6 requires

Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // Structure elements that lost a direct /MCR kid are
  // appended in rewritten form with a shorter /K array
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

This is the spot where a hand-written pruner goes wrong. Marked-content references (/Type /MCR) and object references (/Type /OBJR) are typically direct dictionaries sitting inside their parent element's /K array. A direct object has no object number and no xref entry, so it cannot be freed on its own. The obvious move is to "free" each removed kid anyway, and any writer that maps a direct object to the indirect object holding it will then free the holder, which is the retained structure element. That element goes out as null, and tagged content on pages that were never touched loses its tags. HotPDF sends a removed object to the free list only when it has an object number; for a direct kid it marks the containing element as changed, so the element is rewritten with the kid gone and the free list stays clean

Why a removed /MCR or OBJR kid must never be registered as free in an incremental update: it is a direct dictionary inside its element's /K array, so freeing it would mean freeing the containing structure element, which would be written out as null and untag surviving pages, while the correct move rewrites the container and leaves the free list alone
Only objects with an object number of their own go to the free list, so an incremental save appends the rewritten containers plus free entries for the page and the other removed indirect objects

The same caution decides what DeletePage leaves alone on a loaded document. The deleted page's content streams, XObjects and non-widget annotations stay as objects, because a loaded file may share any of them with a page that remains and there is no cheap way to prove otherwise at deletion time. Dropping the page-tree reference is enough for a correct file. How many bytes those leftovers still cost is a separate measurement, and the object dependency graph and retained-bytes analysis is the tool for it

DeletePage or DeleteLoadedPage?

Use DeletePage for any page removal a user asked for. THotPDF.DeleteLoadedPage(PageIndex) is the lightweight variant: it applies the same two guards, shifts the loaded page list, rewrites /Kids and /Count, invalidates the rendered-page cache and fires OnLoadedDocumentModified. It does not touch the name tree, the outline, the structure tree or the annotations of other pages, and it does not mark the page object deleted. ComposeLoadedPagesNUp is the job it fits: it appends freshly composed sheets that draw the source pages as form XObjects, then drops each original with DeleteLoadedPage(0), since the sheets refer to the pages' content and not to the page objects. For "remove page 7 from this contract" on a tagged, bookmarked, cross-linked file, DeletePage is the call that leaves every reference consistent, whether you save with SaveLoadedDocument or append with SaveIncrementalUpdate

Both methods, the range-based DeletePages, and the incremental update path used above are part of the HotPDF Delphi Component, which edits loaded PDFs from Delphi and C++Builder code without a viewer runtime on the machine