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
/Destsdictionary directly in the catalog holds the same kind of arrays, keyed by name - Outline items (§12.3.3) reach a page 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 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/StructParentsnumbers back to structure elements, and an element can sit there without appearing anywhere on the/Kchain from the root - Link annotations on other pages (§12.5.6.5) carry a
/Destor/GoToaction aimed at the page, and the catalog/OpenActionmay do the same
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
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
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