Object number 1 is not page 1. That one fact trips up more PDF processing code than any other part of the format, and the reason only shows once you stop looking at what a viewer renders and start looking at the object graph the viewer actually reads
A PDF file is a collection of numbered indirect objects. Each carries an object number and a generation number, and other objects point to it with a reference written as N G R — 3 0 R means the current version of object 3. Pages are objects like any other, but where they sit in the file and which numbers they carry say nothing about display order. Order comes from the /Pages tree. Scan objects numerically and you will assemble pages in the wrong sequence for a sizeable share of real-world files
The tree is also where a lot of less obvious behavior lives. Its shape decides how quickly a viewer can jump to page 8,214, its interior nodes quietly hand page size and fonts down to the leaves beneath them, and the /Count totals that make the whole structure fast are enforced by nothing at all. We will go through all three, starting with order itself
The page tree decides the order
Every PDF has a document catalog (ISO 32000-2 §7.7.2), and the catalog's /Pages entry points at the root node of the page tree. That root is a dictionary with /Type /Pages, a /Kids array of indirect references, and a /Count giving the number of leaf pages beneath it. Display order is a depth-first, left-to-right walk of that tree. That is the whole rule
A minimal three-page file makes it concrete:
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [20 0 R 4 0 R 9 0 R] /Count 3 >>
endobj
% Object 4 is stored third in the file but is page 2 in display order
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
% Object 9 is stored fourth but is page 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
% Object 20 is stored last but is page 1; Kids[0] decides, not object number
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj
The /Kids array reads [20 0 R 4 0 R 9 0 R], so object 20 is page 1, object 4 is page 2 and object 9 is page 3. Code that iterates objects in numeric order and collects everything with /Type /Page gets this file wrong. Worse, the output still looks plausible: right page count, recognizable content on every page, wrong sequence
Generators produce layouts like this for ordinary reasons. A library that reserves object numbers for all pages up front numbers them in creation order, then writes the bytes in whatever order suits its serializer. A merge tool renumbers objects from each source document to avoid collisions, so the page objects end up scattered across the combined object table while the new root /Kids array holds the correct sequence. And incremental updates append new objects at the end of the file with fresh numbers, so a page added in a revision lives near the end of the byte stream even when it belongs at position 1
That last case is the one that bites. A test corpus produced by a single tool can pass completely, and then files from a merge workflow, or documents that went through a few revisions, come out shuffled. Heuristic fixes don't hold, because the failure depends on the generator and on the file's edit history. The real fix is short: start at the catalog, resolve /Pages, walk /Kids recursively and emit leaves in the order you meet them. Our page-order debugging case study follows exactly this symptom through a misdiagnosis to its root cause, with the recursive traversal code and qpdf steps for checking the result
Fan-out is a performance decision
The spec permits two shapes. Simple generators emit a flat tree, one root /Pages node whose /Kids holds nothing but /Page leaves. Nothing forces a writer to nest, and a 10,000-page document with 10,000 leaf references in a single array conforms. The PDF Reference nevertheless recommends a balanced tree for large documents, and mainstream generators follow that advice with a modest fan-out — typically a few dozen kids per intermediate node, where each intermediate node has its own /Kids and its own /Count
The reason is what a viewer has to read before it can show anything. Take a jump straight to page 8,214 of that 10,000-page file. With a flat tree the viewer has to parse the root first, and the root is one enormous array: at roughly eight bytes per indirect reference, an 80 KB object that must be tokenized end to end before entry 8,213 resolves. With a balanced tree of fan-out 32, the same jump reads the root, compares running /Count totals to pick the right child, and descends. Three or four small dictionaries, a few hundred bytes each. That O(log n) random access is what the tree was designed to provide, and it is the entire reason /Count exists on intermediate nodes — a reader can skip a whole subtree without opening a single object inside it
Tree shape also sets the cost of editing. An incremental update that inserts one page must rewrite every node whose /Kids or /Count changed, which means the path from the new leaf's parent up to the root. In a balanced tree that path is a handful of small dictionaries appended to the file. In a flat tree the "path" is the giant root array, duplicated in full on every revision. A contract that goes through thirty review-and-annotate cycles can end up hauling thirty superseded copies of the same 80 KB array in its byte stream
For processing code, nesting has a blunter consequence. You cannot assume the first level of /Kids contains pages, so every child has to be checked: /Type /Pages means recurse, /Type /Page means leaf. Code that stops at the first level works on every flat file it meets, then silently drops whole subtrees the first time a generator decides to nest
Interior nodes carry inherited attributes
Intermediate nodes do more than route lookups. Four page attributes are inheritable (ISO 32000-2 §7.7.3.4): /Resources, /MediaBox, /CropBox and /Rotate. Any of them may sit on a /Pages node, where it applies to every leaf beneath unless a descendant overrides it. A writer producing a report with a landscape appendix can express that layout in the tree itself:
5 0 obj % document root
<< /Type /Pages /Count 6 /Kids [6 0 R 7 0 R] >>
endobj
6 0 obj % report body: portrait A4, body font
<< /Type /Pages /Parent 5 0 R /Count 3
/Kids [30 0 R 31 0 R 32 0 R]
/MediaBox [0 0 595 842]
/Resources << /Font << /F1 8 0 R >> >> >>
endobj
7 0 obj % appendix: landscape A4, rotated, its own font
<< /Type /Pages /Parent 5 0 R /Count 3
/Kids [40 0 R 41 0 R 42 0 R]
/MediaBox [0 0 842 595] /Rotate 90
/Resources << /Font << /F2 9 0 R >> >> >>
endobj
40 0 obj % appendix page: inherits size, rotation, fonts
<< /Type /Page /Parent 7 0 R /Contents 43 0 R >>
endobj
Objects 40 through 42 are nearly empty. Page size, rotation and font resources all arrive from node 7, which keeps the file compact and self-maintaining: add a fourth page under the appendix node and it comes out landscape without anyone touching it
It also means a page's properties are not all on the page. Object 40 has no /MediaBox key, and that is perfectly legal — a reader that treats the missing key as malformed input will reject valid files. The lookup has to walk up the /Parent chain until it finds the entry or reaches the root. That walk needs a cycle guard. A corrupted file can carry a /Parent reference pointing back at a node already visited, and without a visited-object set the lookup spins forever
The same mechanism creates the classic page-move hazard. Say a tool moves object 40 into the report body by editing the two /Kids arrays and repointing /Parent to node 6. The move is structurally valid, yet object 40 now inherits the portrait /MediaBox, no rotation and font /F1 — while its content stream still selects /F2, which no longer resolves. The page shrinks, un-rotates and loses its text in a single edit. Reordering code that holds up writes the resolved values of all four inheritable attributes onto the page dictionary before reparenting it. If you have ever dragged a page in an editor and watched it change size or orientation, this is the mechanism you saw
Flattening: legal, common, occasionally expensive
Plenty of tools go the other way. Minimal writers emit a single-level tree because it is simple, and many merge and split utilities rebuild whatever tree they read into one flat /Kids array, since balanced output is extra work and flat output always conforms. A correct rebuild has to resolve inheritance at the same time. Every attribute a leaf was inheriting must be copied onto the leaf, or hoisted to the new root if it is uniform across the document; skip that step and the output changes geometry exactly the way the page-move case does
For typical documents flattening is harmless. It hurts at scale, in the two ways already described: the root array becomes one large object that every open and every page jump parses in full, and every structural edit rewrites it whole. What flattening does not destroy is sharing through indirect references. A flat tree in which all 10,000 pages point at the same /Resources dictionary object is still deduplicated — the only thing lost is the option to leave the entry off the page and let an ancestor supply it
When /Count lies
/Count is pure bookkeeping. It must equal the number of leaf pages in the node's subtree, and nothing in the file format enforces that. Two corruption patterns account for most of the lying counts seen in the wild
The first is the stale count left behind by an incremental update. An editor inserts a page, rewrites the immediate parent with a new /Kids and an updated /Count, appends both to the file — and never touches the ancestors:
% Original revision
12 0 obj
<< /Type /Pages /Count 9 /Kids [13 0 R 14 0 R 15 0 R] >>
endobj
14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 3
/Kids [50 0 R 51 0 R 52 0 R] >>
endobj
% Appended revision: one page inserted into the middle branch.
% Object 14 is superseded; object 12 is never rewritten
14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 4
/Kids [50 0 R 51 0 R 90 0 R 52 0 R] >>
endobj
The tree now holds ten leaves, but the root still says nine. A viewer that trusts the root shows nine in its page counter. One that uses interior counts to binary-search a page jump computes the wrong index for every page after the insertion point. A full traversal finds ten. Three different answers, one file
There is a quieter way to get bitten by the same file. Code that reads the root count, starts walking the tree, and stops once it has collected that many pages will never see page ten. No exception, no warning, just a document that is one page short. Using /Count as a loop bound or a bounds check is the mistake; the walk has to run until the tree is exhausted, whatever the stored number says
The second pattern is the count that could never be right: negative, zero on a populated node, or absurdly huge. These come from fuzzing, from transmission damage, and occasionally from arithmetic bugs in editors. They are dangerous specifically to code that trusts /Count for allocation. Sizing an array from a /Count of -3 raises a range error at best, and doing it from a /Count of two billion is a denial-of-service allocation. Treat the value as untrusted input, like every other number in the file
Parsers split into two camps over all of this. Strict consumers — preflight tools, PDF/A validators, archival pipelines — compare /Count against the traversal result and reject or flag the file. Interactive viewers are almost universally lenient: they traverse, derive the real count and silently ignore the stored one, which is exactly why a stale-count file can circulate for years without complaint until it meets a stricter parser inside some automated workflow. For library code we think the middle ground is the right default. Use /Count as a hint for preallocation, and for subtree skipping once it has been verified, and let traversal remain the source of truth
In production these failures rarely announce themselves as tree problems. They show up as shuffled pages, a missing page at the end of a merge, or a page that changes size or orientation after a reorder. The debugging case study traces one such report back through the tree. For the surrounding file anatomy (objects, cross-reference data, trailers) the PDF file structure overview is the place to start
The HotPDF Delphi Component deals with all of this internally: it traverses nested trees of any depth, resolves inherited attributes when pages are copied or moved, and checks /Count against the actual leaf count instead of trusting it, so page indices in its API always mean logical pages