Technical Article

PDF Page Order Pitfalls: Physical vs Logical Structure

The classic symptom looks like an off-by-one: a page-copying routine asks for page 1 of a three-page document and consistently gets page 2. The indexing logic checks out. The call uses a 0-based logical index, the arithmetic is correct, the boundary conditions are fine. Yet the wrong page comes out every time

The cause is not in the copying code at all. It is in how the loader builds its page array, and it is one of the easiest traps in PDF parsing to fall into

Concept of PDF page order: difference between physical order and logical order
PDF page order: the /Kids array in the Pages tree defines logical sequence, independent of how objects are numbered or stored in the file

Two orderings, one source of confusion

A PDF file is a collection of indirect objects, each identified by an object number. The file structure imposes no obligation on those numbers to reflect reading order. Object 1 can hold page 2; object 20 can hold page 1. What actually defines the reading order is the page tree: a hierarchy of /Pages dictionaries whose /Kids arrays list page references in the sequence a viewer should display them (ISO 32000-1 §7.7.3)

Here is a page tree that exposes the trap:

{ Pages tree root, object 16 }
16 0 obj
<<
  /Type /Pages
  /Count 3
  /Kids [20 0 R   { logical page 1 }
         1 0 R    { logical page 2 }
         4 0 R]   { logical page 3 }
>>
endobj

The file happened to list object 1 and object 4 before object 20 in the byte stream. Any parser that iterated through indirect objects in file order and stamped them into a PageArr as it found page-type dictionaries would end up with object 1 at index 0, object 4 at index 1, and object 20 at index 2. Logical page 1 sits at PageArr[2]. Asking for page index 0 fetches logical page 2 instead

Diagram contrasting object-scan order with Kids-array logic when a Delphi PDF library builds its page array
Object-scan loading stamps PageArr by file position while /Kids alone defines PDF page order, shifting every indexed request by one slot

Confirming the hypothesis

Before changing any code, prove the mismatch rather than assume it. The qpdf command-line tool makes this straightforward:

{ shell }
qpdf --show-pages input.pdf
{ Output reveals Kids order: 20 0 R, then 1 0 R, then 4 0 R }

qpdf --show-object="16 0 R" input.pdf
{ Shows the Pages dictionary with /Kids in reading order }

Extracting each page individually and comparing file sizes confirms the mapping: what an object-scan PageArr[0] produces is the content belonging to logical page 2, and PageArr[2] holds logical page 1. The circular shift is the smoking gun. It also explains why the symptom shows up across many different source documents: any PDF where page objects happen to have lower object numbers than an earlier logical page triggers it

Diagnosis timeline showing how qpdf reveals that the /Kids page order diverges from physical object order behind a page copy routine
Verification turns suspicion into proof: the page tree sequence diverges from physical object order on this document

There is a straightforward reason PDFs end up in this state. An incremental update keeps the object numbers of the objects it changes, but anything it adds, a cover page inserted at the front for instance, gets a new number above everything already in the file, while its reference goes to the start of the Kids array. Tools that merge or reorganize documents renumber objects in whatever order suits them. Some generators simply write pages in an order convenient for content streaming rather than logical page sequence. The PDF format does not require any of them to do otherwise

The correct approach: follow the Kids array

Build the page list by walking the /Kids chain from the catalog root, not by scanning indirect objects. The walk has to recurse, because a large document usually splits its pages across intermediate /Pages nodes, and it should guard against a malformed tree that points back at one of its own ancestors. In the sketch below, TPdfDict and TPdfArray stand for whatever dictionary and array types your parser uses:

type
  TPageList = array of TPdfDict;

procedure CollectPages(Node: TPdfDict; var Pages: TPageList;
  Depth: Integer = 0);
var
  Kids: TPdfArray;
  Child: TPdfDict;
  I: Integer;
begin
  if Depth > 64 then  // a cycle or a pathologically deep tree
    raise EPdfError.Create('Page tree too deep or cyclic');
  Kids := Node.GetArray('Kids');
  if Kids = nil then
    Exit;
  for I := 0 to Kids.Count - 1 do
  begin
    Child := Kids.ResolveDict(I);  // follow the indirect reference
    if Child = nil then
      Continue;
    if Child.GetName('Type') = 'Pages' then
      CollectPages(Child, Pages, Depth + 1)  // intermediate node
    else
    begin
      SetLength(Pages, Length(Pages) + 1);   // leaf: next page in display order
      Pages[High(Pages)] := Child;
    end;
  end;
end;

// Usage: start from the catalog's /Pages entry
SetLength(Pages, 0);
CollectPages(Catalog.ResolveDict('Pages'), Pages);
if Length(Pages) <> Catalog.ResolveDict('Pages').GetInteger('Count') then
  LogWarning('Page tree /Count disagrees with the pages actually found');

Two details are deliberate. A leaf without a /Type entry is still treated as a page, because some writers omit it and viewers open those files anyway. And the final /Count comparison costs nothing but catches truncated or hand-edited trees before a caller indexes into them

If you load documents with HotPDF, this ordering is already resolved when the document loads: page indices follow the page tree's display order, nested /Pages nodes included, so index 0 is always the first page a viewer shows. The walk above matters when your own code parses PDF structure directly

Copying by logical index with CopyPageFromDocument

Because HotPDF resolves page order at load time, logical page indices mean what callers expect. The higher-level CopyPageFromDocument takes a 0-based logical index and copies the correct page into the destination document:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    Source.LoadFromFile('source.pdf');

    Dest.FileName := 'extracted.pdf';
    Dest.BeginDoc;

    { Copy logical page 0 (first page the user sees) }
    Dest.CopyPageFromDocument(Source, 0, 0);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Because the index is a display-order index, CopyPageFromDocument behaves correctly even against documents where physical and logical order diverge. For batch operations, InsertPagesFromDocument accepts an array of logical indices and copies them in one pass

What this reveals about PDF parsing

The PDF specification is explicit: logical page order is defined by the /Kids array of the page tree, not by object numbers or byte offsets (ISO 32000-1 §7.7.3.2). Any parser that uses a different ordering as a shortcut will produce correct results on the majority of documents it sees, because most generators write pages in the natural order and assign sequential object numbers. The mistake hides until someone loads a PDF that was incrementally edited, reorganized by another tool, or generated by software that chose a different layout

Testing only against self-generated PDFs misses this class of problem entirely. Page-order handling needs a test corpus of documents from varied sources: incremental saves, scanned documents with inserted cover pages, PDFs produced by tools that linearize or optimize the object graph differently. Any document whose physical and logical order diverge deserves a permanent place in that corpus

The HotPDF Delphi Component page covers the full API for page operations, including CopyPageFromDocument, InsertPagesFromDocument, and MovePage