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:
- Construct beads, articles and page collections.
- Supply
PageBeads::pageEntry()to each page constructor. - Attach pages to the tree.
- 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.
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.
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;


