The complete script builds three pages identified by i, AA and A-8. Two articles and a presentation list connect these pages without changing their drawing instructions. Bookmarks demonstrate an explicit destination, a PDF name and a destination string.

php examples/native-navigation.php native-navigation.pdf

Labels independent of indices

Page indices remain 0, 1, 2 in tree order. PageLabels composes a real NumberTree with a LabelRange for each range start. The range at index zero is required; the writer checks starts against pages actually present in the catalog.

PageLabel distinguishes style, prefix and initial number St, which is at least one. D, R, r, A, a represent decimal numbers, Roman numerals and letters. Alphabetic style repeats the same letter: A…Z, AA…ZZ, then AAA…ZZZ. The script starts the second range at 27, giving AA.

Absence of a style produces only the prefix, even if St is supplied; without a prefix, the label is an empty string. label() calculates a range label and labelsFor($catalog) supplies labels for this document's pages. Output limits are explicit to avoid allocating enormous text for a very large alphabetic or Roman number.

A destination describes a view

LocalDestination composes the eight existing objects XYZ, Fit, FitH, FitV, FitR, FitB, FitBH, FitBV. The first element is a reference to a page in this document. Validation checks parameters and actual membership; an outside page cannot become a local destination merely by being added to the file body.

NamedDestinations builds string mappings in Names.Dests. LegacyDestinations builds name mappings in Catalog.Dests, with the indirect reference required by the catalog. The two spaces are distinct: in the example, name /details targets page AA, whereas string (details) targets a rectangle on A-8.

NamedDestination expresses that reference without converting names into strings. toObject() returns the corresponding primitive when the application constructs an entry itself. GoToDestination composes a local /GoTo that verifies name resolution during writing. DestinationDictionary can also associate explicit attributes with a tree destination. The GoToDestination constructor accepts a next action or action array too, without imposing a tree structure on references.

View fitting is a separate operation:

$view = $destination->resolve(
    new DestinationViewport($windowWidth, $windowHeight, $currentView),
    new DestinationGeometry($cropBox, $contentBounds),
);

Window dimensions and current position are supplied as numeric objects. null parameters preserve current values; zero XYZ zoom has the same meaning. FitB explicitly requests the content box and restricts it to CropBox. The core does not infer drawing bounds from bytes. Calculated positions use default user-space axes; physical screen transformations and interface rotation belong to the reader. A box with no area cannot define a fitting zoom.

An article traverses page regions

ArticleThread::addBead() chooses reading order. Each ArticleBead has a rectangle; its N and V references are derived from that order. The last returns to the first, and the first to the last. For a single-bead article, both references target that same bead.

The first bead always has T, a reference to its article. Others may also supply it starting with PDF 1.2 using includeThread: true. ArticleThreads constructs the indirect Catalog.Threads reference. Article titles may be identical: order remains the author's choice.

PageBeads separately supplies drawing order in each page's B array. On AA, the script lists the note before the article continuation while retaining both rings. To resolve the cyclic relationship with P:

  1. Construct beads, articles and page collections.
  2. Supply PageBeads::pageEntry() to each page constructor.
  3. Attach pages to the tree.
  4. Call bindToPage() on the collections with those same pages.

The writer checks that each bead occurs exactly once in B, its article is registered and its true owner is P. No page clone or global registry replaces this association.

Transitions and presentation steps

PagePresentation distinguishes display duration Dur from incoming transition Trans. The first page declares a 3.5-second Split transition, vertical and outward, followed by a 5-second display. Absence of display duration disables automatic advancement; duration zero is immediate.

Transition checks style-specific parameters: Dm for Split/Blinds, M for Split/Box/Fly, Di angles depending on style and SS/B for Fly. Angles are measured counterclockwise from the right, unlike Page.Rotate. The second page's Fly uses None and scale 0.25. Style R replaces the page without an effect and ignores transition duration.

PresentationSequence builds the PresSteps NavigationNode objects as a doubly linked list. Its ends remain open: they do not form an article ring. The primary node can be chosen in the middle. The script supplies its entry to the page, then binds the sequence after attachment to the tree.

A forward or direct-access request selects NA, then Next; a backward request selects PA, then Prev. enter() and request() return that explicit plan without executing actions. The example's last node leaves the page through a local destination and therefore has no Next.

Relationships are checked in the document's effective view, including during incremental writing. Changing an old builder without submitting it to the new revision does not change the written PDF. Replacing an object preserves its PDF identity in historical references; deleting an article also requires removing the affected B arrays. Old unreachable dictionaries may remain in earlier cross-reference sections.

Verification inspects declared navigation actions, including Next chains, with bounded traversal. It does not infer the effects of opaque scripts. Action cycles are not treated as forbidden tree cycles: the specification describes how readers handle them. The typed list remains a finite construction, frozen when attached to a page.

Versions and verification

Articles and names require PDF 1.1, destination strings PDF 1.2, labels PDF 1.3 and PresSteps PDF 1.5. The five Fly/Push/Cover/Uncover/Fade styles also require PDF 1.5. The writer considers the effective header and catalog version.

qpdf --check native-navigation.pdf
php tools/fixtures/native_navigation.php > navigation-vectors.json
python tools/validate_native_navigation.py native-navigation.pdf navigation-vectors.json

The independent oracle inspects all three pages, labels, rings, P/B, both destination spaces and PresSteps links. It also compares 55 labels and ten view fittings with Python calculations, then reads three revisions of another document to verify replacements and deletions. Static rendering shows regions; article reading, timers and animations remain functions of the PDF application.

References: ISO 12.3.2, physical pages 373–375; ISO 12.4.2–12.4.4, physical pages 382–389; GoTo, ISO 12.6.4.2, physical page 426.

Index · Complete code

View the PDF result

This file was generated with the code in this documentation. All pages appear below; open the PDF to use its links and bookmarks.

PDF result native-navigation: page 1 of 3
Page 1 of 3
PDF result native-navigation: page 2 of 3
Page 2 of 3
PDF result native-navigation: page 3 of 3
Page 3 of 3
Use the browser PDF viewer

This viewer depends on your browser. If the document does not appear, use “Open the PDF” or “Download”.

Complete source code

Code from roukmoute/pdf · View the source at the documented commit ↗

Read the commented script: native-navigation.php
<?php

declare(strict_types=1);

use PDF\Document\Destinations\Fit;
use PDF\Document\Destinations\FitR;
use PDF\Document\Destinations\XYZ;
use PDF\Document\DocumentInformation;
use PDF\Document\Fonts\StandardFont;
use PDF\Document\Fonts\Type1Font;
use PDF\Document\Navigation as N;
use PDF\Document\OutlineDictionary;
use PDF\Document\OutlineItem;
use PDF\Document\ResourceDictionary;
use PDF\FileStructure;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operator;
use PDF\Syntax\ContentStreamsResources\Operators as Op;
use PDF\Syntax\DataStructure\Rectangle;
use PDF\Syntax\DataStructure\TextString;
use PDF\Syntax\DocumentStructure\DocumentCatalog;
use PDF\Syntax\DocumentStructure\PageTree\PageObject;
use PDF\Syntax\DocumentStructure\PageTree\PageTreeNode;
use PDF\Syntax\FileStructure\FileBody;
use PDF\Syntax\FileStructure\FileHeader;
use PDF\Syntax\Object\BooleanType;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
use PDF\Syntax\Object\IndirectReference;
use PDF\Syntax\Object\IntegerType;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StreamObject;
use PDF\Syntax\Object\StringObject\LiteralString;

require dirname(__DIR__) . '/vendor/autoload.php';

$n = static fn (int|float $value): NumericType => new NumericType($value);
$rectangle = static fn (int $left, int $bottom, int $right, int $top): Rectangle => new Rectangle($n($left), $n($bottom), $n($right), $n($top));
$pageBox = $rectangle(0, 0, 612, 792);
$resources = new ResourceDictionary(new Entry(new NameType('Font'), new DictionaryType(new Entry(new NameType('F1'), new IndirectReference(new Type1Font(StandardFont::Helvetica))))));
/** @return list<Operator> */
$text = static fn (string $value, int $x, int $y, int $size = 12): array => [new Op\SetNonstrokingRGBColor($n(0.12), $n(0.18), $n(0.24)), new Op\BeginTextObject(), new Op\SetTextFontAndSize(new NameType('F1'), $n($size)), new Op\MoveTextPosition($n($x), $n($y)), new Op\ShowText(new LiteralString($value)), new Op\EndTextObject()];
/** @return list<Operator> */
$region = static fn (int $x, int $y, int $width, int $height): array => [new Op\SetNonstrokingRGBColor($n(0.88), $n(0.95), $n(0.93)), new Op\AppendRectangle($n($x), $n($y), $n($width), $n($height)), new Op\FillPathNonzero()];

// Labels are NumberTree ranges, distinct from zero-based indices.
// The second page starts at 27: A repeats the same letter, giving "AA".
$labels = new N\PageLabels(
    new N\LabelRange(new IntegerType(0), new N\PageLabel(N\LabelNumbering::LowerRoman)),
    new N\LabelRange(new IntegerType(1), new N\PageLabel(N\LabelNumbering::UpperLetters, start: new IntegerType(27))),
    new N\LabelRange(new IntegerType(2), new N\PageLabel(N\LabelNumbering::Decimal, new TextString('A-'), new IntegerType(8))),
);

// The article goes from page0 to page2, then returns to page1.
// N/V are derived from this order and automatically close the ring.
$a = new N\ArticleBead($rectangle(42, 440, 570, 624));
$b = new N\ArticleBead($rectangle(42, 420, 570, 620), includeThread: true);
$c = new N\ArticleBead($rectangle(42, 250, 570, 422));
$article = (new N\ArticleThread(new DocumentInformation(new Entry(new NameType('Title'), new TextString('Article objet')))))->addBead($a)->addBead($b)->addBead($c);
$note = new N\ArticleBead($rectangle(42, 460, 570, 630));
$secondArticle = (new N\ArticleThread(new DocumentInformation(new Entry(new NameType('Title'), new TextString('Note independante')))))->addBead($note);
$firstBeads = new N\PageBeads($a);
$middleBeads = new N\PageBeads($note, $c);
$lastBeads = new N\PageBeads($b);

// Page B gives drawing order, here Note then article continuation.
// It can therefore differ from the order of each article ring.
$lastPresentation = new N\PagePresentation(transition: new N\Transition(N\TransitionStyle::Dissolve));
$last = new PageObject($pageBox, $resources, new StreamObject(new ContentStreams(
    ...$text('Articles et destinations', 42, 742, 25),
    ...$text('Label A-8 / index 2', 42, 708, 15),
    ...$region(42, 420, 528, 200),
    ...$text('Article : deuxieme bead', 61, 585, 20),
    ...$text('La lecture passe ensuite a la page AA.', 61, 550),
    ...$text('La destination details cadre cette region avec FitR.', 61, 523),
    ...$text('Les signets utilisent soit un nom, soit une chaine.', 42, 358),
    ...$text('Ces deux espaces de destinations peuvent coexister.', 42, 331),
    ...$text('N/V ferment un anneau ; les instructions dessinent ses regions.', 42, 142),
    ...$text('Aucun ordre visuel n est infere depuis les octets du contenu.', 42, 115),
)), $lastBeads->pageEntry(), ...$lastPresentation->entries());

// PresSteps nodes form a list with two ends, not a ring.
// Only the last NA leaves the page, so it has no Next.
$exit = new N\GoToDestination(new N\LocalDestination(new Fit($last)));
$initial = new N\NavigationNode(backward: $exit);
$primary = new N\NavigationNode(new N\GoToDestination(new N\NamedDestination(new LiteralString('lecture'))), duration: new N\PresentationDuration($n(2)));
$terminal = new N\NavigationNode(forward: $exit);
$steps = (new N\PresentationSequence())->addNode($initial)->addNode($primary)->addNode($terminal);
$firstPresentation = new N\PagePresentation(new N\PresentationDuration($n(5)), new N\Transition(N\TransitionStyle::Split, new N\PresentationDuration($n(3.5)), N\TransitionDimension::Vertical, N\TransitionMotion::Outward), $steps);
$first = new PageObject($pageBox, $resources, new StreamObject(new ContentStreams(
    ...$text('Navigation PDF native', 42, 742, 26),
    ...$text('Label i / index 0', 42, 708, 15),
    ...$region(42, 440, 528, 184),
    ...$text('Article : premier bead', 61, 588, 20),
    ...$text('L article continue sur la page A-8, puis sur AA.', 61, 552),
    ...$text('B decrit les regions de cette page dans l ordre de dessin.', 61, 525),
    ...$text('Labels : i, AA, A-8', 42, 374, 18),
    ...$text('A/a utilise A...Z, AA...ZZ, AAA...ZZZ.', 42, 343),
    ...$text('PresSteps : premier <-> primaire <-> dernier', 42, 286, 17),
    ...$text('Le node primaire est choisi explicitement ; entree = NA puis Next.', 42, 254),
    ...$text('En presentation : transition 3.5 s, puis affichage 5 s.', 42, 193),
    ...$text('Les modes de presentation et articles dependent du lecteur PDF.', 42, 142),
    ...$text('Voir les signets pour les destinations nommees et explicites.', 42, 115),
)), $firstBeads->pageEntry(), ...$firstPresentation->entries($primary));

$middlePresentation = new N\PagePresentation(transition: new N\Transition(N\TransitionStyle::Fly, direction: N\TransitionDirection::None, scale: $n(0.25), rectangular: new BooleanType(true)));
$middle = new PageObject($pageBox, $resources, new StreamObject(new ContentStreams(
    ...$text('Ordre de dessin et ordre d article', 42, 742, 23),
    ...$text('Label AA / index 1', 42, 708, 15),
    ...$region(42, 460, 528, 170),
    ...$text('Note independante', 61, 592, 20),
    ...$text('Un article d un bead : N et V referencent ce meme bead.', 61, 554),
    ...$region(42, 250, 528, 172),
    ...$text('Article : troisieme bead', 61, 386, 20),
    ...$text('La suite revient au premier bead sur la page i.', 61, 348),
    ...$text('Cette page B liste Note puis Article, sans changer les anneaux.', 61, 321),
    ...$text('Fly / None / SS 0.25 : transition sans direction de vol.', 42, 191),
    ...$text('Le core declare ces relations ; le lecteur anime la transition.', 42, 142),
    ...$text('L apparence statique reste une suite explicite d operateurs.', 42, 115),
)), $middleBeads->pageEntry(), ...$middlePresentation->entries());

// PageObject must be attached before inspecting its effective entries.
// Collections then bind to existing pages without cloning or a registry.
$pages = (new PageTreeNode())->addKid($first)->addKid($middle)->addKid($last);
$firstBeads->bindToPage($first);
$middleBeads->bindToPage($middle);
$lastBeads->bindToPage($last);
$steps->bindToPage($first);

$names = new N\NamedDestinations(
    new N\NamedDestinationEntry(new LiteralString('lecture'), new N\LocalDestination(new XYZ($first, $n(42), $n(624), $n(0)))),
    new N\NamedDestinationEntry(new LiteralString('details'), new N\DestinationDictionary(new N\LocalDestination(new FitR($last, $n(42), $n(420), $n(570), $n(620))))),
);
$legacy = new N\LegacyDestinations(new N\NamedDestinationEntry(new NameType('details'), new N\LocalDestination(new Fit($middle))));
$outlines = (new OutlineDictionary())
    ->addItem(new OutlineItem(new TextString('Debut : destination explicite'), new N\LocalDestination(new Fit($first))))
    ->addItem(new OutlineItem(new TextString('AA : nom details'), new N\GoToDestination(new N\NamedDestination(new NameType('details')))))
    ->addItem(new OutlineItem(new TextString('A-8 : chaine details'), new N\GoToDestination(new N\NamedDestination(new LiteralString('details')))));
$catalog = new DocumentCatalog($pages, $labels->catalogEntry(), (new N\ArticleThreads($article, $secondArticle))->catalogEntry(), $names->catalogEntry(), $legacy->catalogEntry(), new Entry(new NameType('Outlines'), new IndirectReference($outlines)), new Entry(new NameType('PageMode'), new NameType('UseOutlines')), new Entry(new NameType('OpenAction'), new N\LocalDestination(new Fit($first))));
$file = new FileStructure(new FileHeader('1.7'), new FileBody($catalog), new IndirectReference($catalog));
$path = $argv[1] ?? __DIR__ . '/native-navigation.pdf';
$file->writeTo($path);
echo 'PDF written to ' . $path . PHP_EOL;

Guide source at the documented commit ↗