Pull chapter 3 out of a handbook and its cross-reference links go dead, the first page is numbered 1 instead of 29, and a hidden reviewer-notes layer shows on every page. That state lives in the document catalog, not on the pages, and PDFlibPas, the PDF Library for Delphi, carries it along when ExtractPageRanges or CopyPageRanges copies pages
Page-reachable versus catalog-reachable objects
A page copy is a graph walk: start at the page dictionary, follow /Contents, /Resources and /Annots and everything they reference, renumber what turns up and write it into the target. That suits fonts, images, form XObjects and appearance streams, which all hang below the page. Catalog-level state (ISO 32000-1 §7.7.2) points the other way, down at the pages, while the page holds at most an integer key or the bare object without its configuration, so no walk that starts at a page can reach it, however thorough. The table lists the pieces that matter when pages move between documents and what PDFlibPas does with each
| State | Where it lives | What the page holds | On copy in PDFlibPas |
|---|---|---|---|
| Named destinations | Catalog /Dests (PDF 1.1) or the /Dests name tree in /Names, ISO 32000-1 §12.3.2.3 and §7.7.4 | Only the name, inside a link /Dest or a GoTo action /D | Entries aimed at copied pages are retargeted to the copies; a name the target already uses gets a numeric suffix and the links on the copied pages follow it |
| Page labels | /PageLabels number tree, §12.4.2 | Nothing at all; the tree is keyed by page position | Copied pages keep the labels they showed in the source; pages already in the target keep theirs |
| Layer configuration | /OCProperties with /OCGs and the default configuration /D (/BaseState, /ON, /OFF, /Order), §8.11.4 | The group dictionaries, through /Properties or an /OC entry, but no default state | Groups the copied pages use are registered in the target with their default state and appended to the layer order |
| Logical structure | /StructTreeRoot and its /ParentTree, §14.7.4 | An integer /StructParents key | Elements with content on the copied pages are cloned and the parent tree is rebuilt for the copies |
| Form fields | /AcroForm /Fields and /DR | Widget annotations in /Annots | Fields are merged into the target form, and /DR fonts and resources are copied and renumbered with the pages |
Why do links to named destinations stop working after a split?
An explicit destination (ISO 32000-1 §12.3.2) is an array that starts with an indirect reference to the page, so copying the link copies the reference too. A named destination is only a name or a string in the link /Dest or a GoTo action /D; the lookup to an explicit array sits in the catalog, either in the PDF 1.1 /Dests dictionary keyed by name objects or in the /Dests name tree of the name dictionary (§7.7.4) keyed by strings. Producers that emit cross-references before final pagination lean on names, and LaTeX with hyperref writes every section and figure reference as something like section.3.2. A page-only copy keeps the string and loses the lookup, the viewer silently does nothing on the click, and the report tends to arrive weeks later from whoever reads the extract
PDFlibPas reads both containers and writes each entry whose array lands on a copied page into the matching container in the target, creating it when the target has none. When volumes assembled into one book share a name, the target's existing entry keeps it and the incoming one becomes Glossary_2; the /Dest values and GoTo actions on the copied pages are rewritten to match, so each volume still jumps to its own glossary. Entries aimed at pages that stayed behind are left out, since the target has nothing for them to point at
uses
PDFlibrary;
procedure CollectVolumes;
var
PDF: TPDFlib;
Volume1, Volume2: Integer;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile('volume1.pdf', '') <> 1 then
Exit;
Volume1 := PDF.SelectedDocument;
if PDF.LoadFromFile('volume2.pdf', '') <> 1 then
Exit;
Volume2 := PDF.SelectedDocument;
if PDF.NewDocument = 0 then // selected; starts with one blank page
Exit;
if (PDF.CopyPageRanges(Volume1, '1-48') <> 1) or
(PDF.CopyPageRanges(Volume2, '1-36') <> 1) then
Exit;
PDF.DeletePages(1, 1); // drop the starter page
// Both volumes define a destination named 'Glossary'.
// Expected: 'Glossary' still opens the volume 1 glossary,
// 'Glossary_2' opens the volume 2 glossary, and the links on
// the volume 2 pages now name 'Glossary_2'
if PDF.GetNamedDestination('Glossary_2') = 0 then
WriteLn('volume 2 glossary was not carried');
PDF.SaveToFile('collected.pdf');
finally
PDF.Free;
end;
end;
Links, widgets and article beads that reference a page directly are a different case, because the reference sits right there on the copied page. When the page it names was not copied, PDFlibPas points it at a null object, which ISO 32000-1 §7.3.10 treats as null, and the viewer ignores the click. We prefer that over both alternatives. Keeping the reference imports the stray page, and through its /Parent the source page tree; leaving the old object number unmapped makes it collide with whatever object owns that number in the target, so the link jumps somewhere unrelated
Page labels are keyed by position, not by page object
ISO 32000-1 §12.4.2 stores labels in a /PageLabels number tree keyed by zero-based page index; each value gives a style /S, an optional prefix /P and a start value /St, and covers every page up to the next key. Dropping the tree produces the restart-at-1 symptom. Copying it verbatim is worse, because the keys still describe source positions, so the range that began at physical page 13 now starts on whatever sits at index 12 of the new file. PDFlibPas works out the label each copied page displayed in the source and writes the fewest ranges that reproduce it at the new positions, leaving the ranges that govern pages already in the target alone. The tree layout itself, /Nums leaves and /Kids nodes, is covered in the page labels number tree walkthrough
procedure ExtractChapter;
var
PDF: TPDFlib;
I, Count, GroupID: Integer;
Dests: array of TPDFlibNamedDestinationInfo;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
// Chapter 3 is physical pages 41-60; the extract becomes the selected document
if PDF.ExtractPageRanges('41-60') <> 1 then
Exit;
// Front matter i-xii, body numbered from physical page 13.
// Expected: '29', the folio printed on the page
WriteLn('First page label: ', PDF.GetPageLabel(1));
Count := PDF.EnumNamedDestinations(Dests); // empty array: count only
SetLength(Dests, Count);
PDF.EnumNamedDestinations(Dests);
for I := 0 to High(Dests) do
WriteLn(Dests[I].Name, ' -> page ', Dests[I].Page);
for I := 1 to PDF.OptionalContentGroupCount do
begin
GroupID := PDF.GetOptionalContentGroupID(I);
WriteLn(PDF.GetOptionalContentGroupName(GroupID), ' visible: ',
PDF.GetOptionalContentGroupVisible(GroupID));
end;
PDF.SaveToFile('chapter3.pdf');
finally
PDF.Free;
end;
end;
That carry depends on source order, which ExtractPageRanges and CopyPageRanges guarantee by sorting the range and dropping duplicates. With PDF_COPY_PAGE_KEEP_ORDER, CopyPageRangesEx keeps the range as written, and a sequence such as 60, 41–59 has no truthful numbering: page 60 labeled 48 and followed by 29 would misstate where each page came from. PDFlibPas leaves labels to the caller in that mode, which is the right call, since only you know what the packet is meant to be. AddPageLabels takes a one-based start page, a style code (0 for prefix only, 1 decimal, 2 and 3 Roman, 4 and 5 letters), the first number and a prefix; the packet below should read Summary followed by 29 to 47
procedure BuildReviewPacket;
var
PDF: TPDFlib;
Source: Integer;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
Source := PDF.SelectedDocument;
if PDF.NewDocument = 0 then
Exit;
// The chapter summary (page 60) leads, then the chapter body in order
if PDF.CopyPageRangesEx(Source, '60,41-59',
PDF_COPY_PAGE_KEEP_ORDER) <> 1 then
Exit;
PDF.DeletePages(1, 1); // drop the starter page
PDF.AddPageLabels(1, 0, 1, 'Summary'); // style 0: the prefix alone
PDF.AddPageLabels(2, 1, 29, ''); // decimal, matching the printed folios
PDF.SaveToFile('review-packet.pdf');
finally
PDF.Free;
end;
end;
What decides whether a copied layer starts hidden?
Optional content is mostly page-local. Marked content between /OC /Name BDC and EMC resolves through the page /Properties resource to a group dictionary, and annotations or form XObjects can name a group through /OC, so a page copy brings the groups along. Visibility stays behind. ISO 32000-1 §8.11.4 keeps it in the catalog /OCProperties, where the /OCGs array registers every group and the default configuration /D decides through /BaseState, /ON and /OFF which groups start hidden. A group the target never registered has no default state to apply, so its content is drawn like any other page content, and that is how a reviewer-notes layer ends up on every printout
PDFlibPas registers each group the copied pages use in the target /OCGs, appends it to /D /Order for the layer panel and translates its default state: listed in the source /OFF array or covered by a source /BaseState of OFF, it goes onto the target's /OFF list; explicitly ON in the source, it goes onto /ON when the target's base state hides everything. Groups the copied pages never reference stay out, so the extract lists only layers it actually contains, and the visibility loop in the extraction example should report 0 for each layer that was off in the source. Building and toggling layers from scratch is covered in working with optional content groups in Delphi
Deleting pages: the same catalog entries in reverse
Taking a page out of its /Kids array leaves catalog entries still pointing at it. Besides outlines and article threads, DeletePages in PDFlibPas clears the rest:
- Form fields whose widgets all sat on deleted pages leave
/AcroForm /Fields; a field with widgets on surviving pages keeps those and loses only the deleted ones - Named destinations aimed at a deleted page are removed from both the
/Destsdictionary and the name tree - Page labels are renumbered so every remaining page keeps the label it showed: deleting physical page 3 from a document labeled i, ii, iii, iv, 1, 2 leaves i, ii, iv, 1, 2
- Structure elements whose content was on the deleted pages, and marked-content or object references that point there, are pruned from the structure tree
Keeping printed labels instead of closing the gap is deliberate: a label exists to match the folio in the footer, so the page that says iv must still be iv in the viewer, or a reader typing iv into the page box lands one page off. One AddPageLabels call restores contiguous numbering if you want it. The same rule is why the volume example drops its starter page through DeletePages: all 84 copied pages shift down one position and keep their labels, which a raw page-tree edit would not arrange, and the duplex scan collate and interleave article makes the same point about rolling back a failed merge
Tagged structure follows the same rule
Tagged PDF is catalog-reachable state at its most extreme. Structure elements under /StructTreeRoot point down at pages through /Pg, while a page carries only an integer /StructParents key into the /ParentTree number tree (ISO 32000-1 §14.7.4). A generic deep copier would follow every /P and /Pg it meets, so cloning one paragraph element would re-copy its whole parent chain and, through /Pg, pages that were already copied. PDFlibPas instead walks the source tree once, clones only the elements with content on a copied page, retargets their page references at the copies and rebuilds the parent-tree entries for them
When a split or merge has to keep working cross-references, correct folios and hidden layers, copy through CopyPageRanges or ExtractPageRanges and remove pages with DeletePages; the full function reference is on the PDF Library for Delphi product page