Technical Article

PDF Outline Editing and Page Remapping in Delphi

Drop seven pages out of a 200-page handbook and every bookmark lands somewhere wrong. The fix is not rebuilding the outline from a flat title list. PDFiumPas exposes TPdfOutlineEditor, which loads the real outline tree, lets you move and retarget items, then runs ApplyPageMap to shift every explicit destination through your page plan

Why does deleting pages break every bookmark?

Because an outline item does not store a page number. It stores a reference to a page object, and when the page objects change the reference either points at a page that moved or at nothing at all. ISO 32000-1 §12.3.2.2 defines an explicit destination as an array whose first element is an indirect reference to a page dictionary, followed by a fit name such as /Fit or /XYZ. Delete the page and you are left with a dangling reference; reorder the pages and the reference is still valid but now describes a different chapter. PDFiumPas resolves that array back to a page number when it loads, so TPdfOutlineItem.PageNumber gives you a one-based page index that matches the public TPdf API rather than an object number. That is the whole point of the abstraction: your remapping logic works in the same coordinate system as the page plan you already built when you split, reordered, or imposed the document. If you are building that plan, the same one-based convention runs through splitting PDF documents into multiple files and through n-up imposition and page reordering

The outline is a doubly linked tree, not a list

The reason you cannot simply serialise a flat array of titles is that ISO 32000-1 §12.3.3 wires every outline item into five separate links: /Parent, /Prev, /Next, /First, and /Last. Moving a single subtree therefore rewrites the old parent, the new parent, both neighbouring siblings on each side of the cut and the insertion point, and the parent pointer of the moved node itself. Get one of those wrong and conforming readers show a truncated tree, or loop. PDFiumPas keeps the edit state as a depth-first array of TPdfOutlineItem records with a stable integer Id, so a subtree is a contiguous slice and the sibling chain is derived, never hand-maintained. TPdfOutlineEditor.Move lifts that slice, reinserts it under the new parent at the requested sibling index, and reassigns only the root of the block. It also refuses the two moves that would corrupt the graph: moving an item into its own subtree, and naming a parent that does not exist

PDFiumPas outline editing in Delphi: moving Chapter 3 out of Part I and under the document root rewrites the /Parent pointer of the moved node plus the /First and sibling /Prev and /Next links around both the cut and the insertion point
One Move call rewrites the parent pointer of the lifted subtree and the sibling links on both sides of the cut and the insertion point

Why is /Count signed?

Because the sign carries the expanded state, not the size. A positive /Count means the item is open and the number is how many descendants are currently visible; a negative /Count means the item is collapsed. PDFiumPas writes the descendant count for every item that has children and negates it when IsOpen is False, and on load it reads the state back as IsOpen := HasCount and (CountValue > 0). This is the single most common hand-rolled bug in outline writers: emitting an unsigned count and silently forcing the whole tree open

How PDFiumPas encodes outline expansion state in Delphi: a positive /Count means the item is open and counts visible descendants, a negative /Count means collapsed, and an unsigned count forces every reader to expand the whole tree
The sign of /Count is the expanded state and the magnitude is the visible descendant count, so an unsigned count silently forces the whole tree open
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // becomes second child of root
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // writes a negative /Count
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget handles both shapes the specification allows. Pass DestinationInAction as False and PDFiumPas writes a direct /Dest array; pass True and it writes a Go-To action, /A << /S /GoTo /D [ page ref suffix ] >>, per ISO 32000-1 §12.6.4.2. Either way it first strips any existing /Dest and /A from the item so the two cannot coexist and disagree. The suffix defaults to /Fit and must begin with a PDF name, which is why an empty or malformed suffix raises immediately instead of producing a destination array that no reader can parse

How does ApplyPageMap consume a page plan?

ApplyPageMap takes exactly the array your page plan already validated: NewPageNumbers, indexed by old page minus one, holding the new one-based page number or zero when that page did not survive. It walks the item array backwards so that deleting a subtree never invalidates an index it has yet to visit, and it reports what it did through RemappedDestinationCount and RemovedDanglingItemCount

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // One entry per page of the ORIGINAL document
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == this page was dropped

  NewPageNumbers[0] := 1;                // old page 1 -> new page 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // old page 10 -> new page 3

  // True: delete the whole dangling subtree. False: keep the item, strip its target
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

The DeleteDangling flag decides the policy for a destination that mapped to zero, and both branches are deliberate. With True, PDFiumPas deletes the item and its entire subtree, because an outline node whose target vanished usually heads a chapter that vanished with it. With False, the item survives with its title and hierarchy intact but its /Dest and /A removed, which is what you want when a human is going to retarget it in review. Genuinely malformed input still fails loudly rather than being patched: a negative entry or a destination pointing past the end of the supplied map returns False with IssueKind set to poviInvalidPageMap

How PDFiumPas ApplyPageMap redirects PDF bookmarks in Delphi: a page map indexed by old page minus one sends surviving destinations to their new page numbers, while entries that map to zero are either deleted with their subtree or stripped of their target
The page map is indexed by old page minus one, and a zero entry either deletes the dangling subtree or leaves the item with its target stripped

Opaque entries, and the honest trade-off

Not every outline item has a page number PDFiumPas can reason about. Three kinds are carried through untouched: named destinations, actions that are not /S /GoTo, and unknown dictionary keys added by whatever produced the file. These load with PageNumber equal to zero, keep their original bytes in the item, and are written back verbatim unless you explicitly call Retarget on them

  • A named destination is a key into the document name tree, so remapping it correctly means resolving the tree and rewriting the target entry, not guessing at the outline level
  • A /URI, /Launch, or JavaScript action has no page semantics at all and must not be silently converted into a Go-To
  • Vendor-specific keys and structure destinations are preserved because dropping what you do not understand is how round-trips lose data

The cost is real and worth stating plainly: ApplyPageMap skips those items entirely, so a document whose bookmarks all use named destinations will come through a page deletion with its outline structurally valid and semantically stale. That is the deliberate choice — a stale link a reviewer can catch beats a confidently wrong one nobody notices. If you are triaging incoming files before you edit them, an inventory pass in a PDF intake review workbench will tell you which documents fall into that bucket

Saving: incremental revision, then an independent reload

TPdfOutlineEditor.SaveIncremental appends a sparse incremental revision rather than rewriting the file. Items that were loaded keep their original indirect object reference including the exact generation, so existing cross-references stay valid; only items you added draw a fresh number, allocated from one past the revision maximum object number. The catalogue is updated in the same revision, and a missing /Outlines entry is added to it when the source had no outline at all

What happens after the write is the part worth copying. PDFiumPas reopens the destination stream with a completely independent editor and compares the reloaded tree against the in-memory one — item count, titles, page numbers, destination suffixes, action-versus-direct destination form, styles, expanded state, and parent relationships. Any mismatch, or any load failure, clears the destination stream and returns poviVerificationFailure instead of handing you a plausible-looking file. Encrypted sources are refused up front with poviEncryptedInput, since new titles and destinations create string content that cannot be produced by copying the /Encrypt trailer forwards

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Treat the outline as what it is — a linked object graph with its own invariants — and page deletion stops being a bookmark disaster and becomes a page map you hand to one method call. TPdfOutlineEditor, ApplyPageMap, and the verified incremental writer ship in PDFiumPas from v3.98.0 for Delphi, C++Builder, and Lazarus; you can review the full API and download a trial on the PDFium Delphi Component product page