Optional module · pdftoolkit/pdf-reader 0.1.7

English | Français

A PDF catalog identifies a tree root through /Pages. Its branches are /Pages dictionaries; its leaves are /Page dictionaries. The /Kids array gives descendant order. /Count declares a branch's leaf count, while /Parent connects a descendant to its parent. Reading only the root's /Count therefore does not validate pages. These rules come from ISO 32000-1, 7.7.3 and tables 29 and 30.

Explicitly requested traversal

After obtaining a ReadDocument with DocumentReader::read(), construct the tree reader with this document and traversal ceilings:

use PDF\Reading\PageTreeLimits;
use PDF\Reading\PageTreeReader;

$tree = (new PageTreeReader($document, new PageTreeLimits(
    maxDepth: 64,
    maxNodes: 10_000,
    maxPages: 5_000,
)))->read();

foreach ($tree as $index => $page) {
    printf(
        "Page %d; effective rotation: %d\n",
        $index + 1,
        $page->attributes->rotate->integer(),
    );
}

read() follows children in /Kids order and returns its collection only after checking the entire tree. Each branch must declare the number of leaves actually encountered. The root must not have a /Parent; other branches and pages must identify their immediate parent. Cycles, repeated references and inconsistent structural values raise a ParseException.

ReadPageTree implements Countable and IteratorAggregate. count($tree) gives the validated page count; $tree->page(0) gives the first page. The collection exposes its root reference, document and pages. Each ReadPage retains a source reference, resolved dictionary and their owning document. Calculated PageAttributes are PDF objects: a resource DictionaryType, two Rectangle values and an IntegerType. Traversal does not transform these values into application associative arrays.

A page's effective values

Four entries can be inherited: /Resources, /MediaBox, /CropBox and /Rotate. A value present on the page takes precedence; otherwise the closest ancestor defining it is used. A null entry is treated as absent, as required by PDF dictionary rules. /Resources and /MediaBox are required after this search. /CropBox defaults to /MediaBox; /Rotate defaults to zero and must be a multiple of 90 degrees.

Inheritance selects a complete value. A local /Resources dictionary does not merge its categories with the parent's: even when empty, it replaces inherited resources. The source dictionary remains accessible to distinguish actually written entries from calculated effective values.

PageAttributes::rotate retains the original PDF integer. Its normalizedRotation() method gives an orientation of 0, 90, 180 or 270 degrees: a rotation of -90 remains an IntegerType(-90) and corresponds to 270 degrees. Rectangle coordinates are preserved as well. The reader does not calculate a drawing transformation or screen position.

Other entries remain local to the page. In particular, /Contents is not inherited, nor are /BleedBox, /TrimBox, /ArtBox or /UserUnit. Effective values for those last entries are not calculated by this traversal; the application can inspect their definition and source value through the shared model's objects.

Separate content streams

A page may omit /Contents, in which case it has no content streams. When present, this object identifies a single stream or an array of streams. Array order is part of the document. Splitting between two streams does not necessarily delimit independent drawing operations: under ISO 32000-1, 7.8.2, their effect corresponds to concatenation in that order.

Preserving stream objects and their order allows decoding to be requested later. Decoding produces bytes without interpreting operators, resolving fonts or extracting visible text. Unknown compressed data can remain accessible in encoded form.

use PDF\Reading\PageContentLimits;
use PDF\Reading\PageContentsReader;

$contentsReader = new PageContentsReader($document, new PageContentLimits(
    maxStreams: 128,
));

foreach ($tree as $pageIndex => $page) {
    $contents = $contentsReader->read($page);
    printf("Page %d; streams: %d\n", $pageIndex + 1, count($contents));

    foreach ($contents as $streamIndex => $stream) {
        $encodedBytes = $stream->encodedBytes();
        $decodedBytes = $document->streamData($stream);
        printf(
            "Stream %d; encoded: %d bytes; decoded: %d bytes\n",
            $streamIndex + 1,
            strlen($encodedBytes),
            strlen($decodedBytes),
        );
    }
}

PageContents retains its owning page and a list of EncodedStream values. read() rejects a page belonging to another ReadDocument. An absent or null entry and an empty array yield an empty collection. A nonempty entry must resolve to a stream or an array in which each element resolves to a stream; no invalid element is ignored.

Try the independent fixtures

The executable example uses the three-page tree created with pypdf and qpdf. From a reader checkout:

composer install
php examples/pages.php
php examples/pages.php tests/Fixtures/page-tree/object-streams.pdf

The first page inherits from a branch: its raw rotation is -90 and it has two streams. The second defines its own empty resources and zero rotation; its CropBox comes from the sub-branch and its two streams are referenced by an indirect array. The third uses root attributes, with rotation 90, and has no streams. The qpdf variant represents the same pages in an object stream with different numbers. The fixture description lists the effective values and their origin.

Limit each operation's work

Limit Default Scope
PageTreeLimits::maxDepth 64 Tree depth, including the root.
PageTreeLimits::maxNodes 100,000 Cumulative count of visited branches and leaves.
PageTreeLimits::maxPages 100,000 Leaf count and ceiling for declared counts.
PageContentLimits::maxStreams 1,024 Number of streams in a page's contents.

These ceilings apply to one operation. ReaderLimits and cumulative document budgets continue to control loaded objects, parsed values, retained bytes and decoding. A tree may be deep while its dictionaries have little syntactic nesting: tree depth therefore needs its own ceiling. Traversal uses an explicit stack; it does not recursively use the PHP call stack for every descendant.

Scope of checks

Page traversal validates the structure it visits. It does not render the document or validate all annotations, resources, fonts or actions its pages may reference. These references remain source document data; they do not become imported writer objects.

Architecture · Components · Security controls · README

Guide source at the documented commit ↗