Le script complet construit trois pages identifiées par i, AA et A-8. Deux articles et une liste de présentation relient ces pages sans modifier leurs instructions de dessin. Les signets montrent une destination explicite, un nom PDF et une chaîne de destination.

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

Des labels indépendants des indices

Les indices des pages restent 0, 1, 2, dans l'ordre de l'arbre. PageLabels compose un vrai NumberTree avec une LabelRange pour chaque début de plage. La plage d'indice zéro est obligatoire ; le writer contrôle les débuts contre les pages réellement présentes dans le catalogue.

PageLabel distingue style, préfixe et premier nombre St, qui vaut au moins un. D, R, r, A, a représentent chiffres décimaux, chiffres romains et lettres. Le style alphabétique répète la même lettre : A…Z, AA…ZZ, puis AAA…ZZZ. Le script commence la deuxième plage à 27, d'où AA.

L'absence de style produit uniquement le préfixe, même si St est fourni ; sans préfixe, le label est une chaîne vide. label() calcule un label de plage et labelsFor($catalog) donne les labels des pages de ce document. Les limites de sortie sont explicites, pour éviter d'allouer un texte gigantesque avec un très grand numéro alphabétique ou romain.

Une destination décrit une vue

LocalDestination compose les huit objets existants XYZ, Fit, FitH, FitV, FitR, FitB, FitBH, FitBV. Le premier élément est une référence vers une page de ce document. La validation vérifie les paramètres et cette appartenance réelle ; une page étrangère ne peut pas devenir une destination locale en étant simplement ajoutée au corps du fichier.

NamedDestinations construit la correspondance de chaînes dans Names.Dests. LegacyDestinations construit celle des noms dans Catalog.Dests, avec la référence indirecte exigée par le catalogue. Les deux espaces sont distincts : dans l'exemple, le nom /details cible la page AA, tandis que la chaîne (details) cible un rectangle de A-8.

NamedDestination exprime cette référence sans convertir les noms en chaînes. toObject() donne la primitive correspondante lorsque l'application construit elle-même une entrée. GoToDestination compose un /GoTo local qui vérifie la résolution du nom au moment de l'écriture. Un DestinationDictionary permet aussi d'associer des attributs explicites à une destination de l'arbre. Le constructeur de GoToDestination accepte aussi une action ou un tableau d'actions next, sans imposer une structure d'arbre à leurs références.

Le calcul de cadrage est une opération séparée :

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

Les dimensions de fenêtre et la position actuelle sont fournies en objets numériques. Les paramètres null conservent leur valeur actuelle ; le zoom XYZ zéro a le même sens. FitB demande explicitement la boîte du contenu et la restreint à la CropBox. Le core n'infère pas les limites du dessin depuis ses octets. La position calculée utilise les axes de l'espace utilisateur par défaut ; les transformations physiques d'écran et la rotation de l'interface relèvent du lecteur. Une boîte sans aire ne définit pas un zoom de cadrage.

Un article traverse des régions de pages

ArticleThread::addBead() choisit l'ordre de lecture. Chaque ArticleBead possède un rectangle ; ses références N et V sont dérivées de cet ordre. Le dernier retourne au premier, et le premier au dernier. Pour un article d'un seul bead, les deux références désignent ce même bead.

Le premier bead possède toujours T, référence vers son article. Les autres peuvent aussi la fournir à partir de PDF 1.2, avec includeThread: true. ArticleThreads construit la référence indirecte Catalog.Threads. Les titres d'articles peuvent être identiques : leur ordre reste celui choisi par l'auteur.

PageBeads fournit séparément l'ordre de dessin du tableau B de chaque page. Le script liste la note avant la continuation de l'article sur AA, tout en conservant les deux anneaux. Pour résoudre la relation cyclique avec P :

  1. Construire les beads, articles et collections de pages.
  2. Fournir PageBeads::pageEntry() au constructeur de chaque page.
  3. Rattacher les pages à l'arbre.
  4. Appeler bindToPage() sur les collections avec ces mêmes pages.

Le writer vérifie la présence unique de chaque bead dans B, son article enregistré et son véritable propriétaire P. Aucun clone de page ou registre global ne remplace cette association.

Transitions et étapes de présentation

PagePresentation distingue la durée d'affichage Dur de la transition entrante Trans. La première page déclare une transition Split de 3,5 secondes, verticale et vers l'extérieur, puis un affichage de 5 secondes. L'absence de durée d'affichage laisse l'avance automatique désactivée ; une durée zéro est immédiate.

Transition vérifie les paramètres propres à son style : Dm pour Split/Blinds, M pour Split/Box/Fly, angles Di selon le style et SS/B pour Fly. Les angles sont mesurés dans le sens inverse des aiguilles d'une montre depuis la droite, contrairement à Page.Rotate. Le Fly de la deuxième page utilise None et une échelle de 0,25. Le style R remplace la page sans effet et ignore la durée de transition.

PresentationSequence construit les NavigationNode de PresSteps en liste doublement liée. Ses extrémités restent ouvertes : elles ne forment pas l'anneau d'un article. Le node primaire peut être choisi au milieu. Le script fournit son entrée à la page, puis lie la séquence après le rattachement à l'arbre.

Une requête forward ou d'accès direct sélectionne NA, puis Next ; une requête backward sélectionne PA, puis Prev. enter() et request() retournent ce plan explicite, sans exécuter ses actions. Le dernier node de l'exemple quitte la page avec une destination locale et ne possède donc aucun Next.

Les relations sont contrôlées dans la vue effective du document, y compris lors d'une écriture incrémentale. Modifier un ancien builder sans le soumettre à la nouvelle révision ne change pas le PDF déjà écrit. Remplacer un objet conserve son identité PDF dans les références historiques ; supprimer un article demande de retirer aussi les tableaux B concernés. Les anciens dictionnaires devenus inaccessibles peuvent rester dans les sections de références précédentes.

La vérification inspecte les actions de navigation déclarées, y compris les chaînes Next, avec un parcours borné. Elle ne déduit pas les effets d'un script opaque. Les cycles d'actions ne sont pas assimilés aux cycles interdits des arbres : la norme décrit leur traitement par le lecteur. La liste typée reste une construction finie, figée lors de son rattachement à une page.

Versions et vérification

Les articles et noms demandent PDF 1.1, les chaînes de destination PDF 1.2, les labels PDF 1.3 et PresSteps PDF 1.5. Les cinq styles Fly/Push/Cover/Uncover/Fade demandent aussi PDF 1.5. Le writer tient compte de la version effective du header et du catalogue.

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

L'oracle indépendant inspecte les trois pages, les labels, les anneaux, P/B, les deux espaces de destinations et les liens PresSteps. Il compare aussi 55 labels et dix cadrages à des calculs Python, puis lit trois révisions d'un autre document pour vérifier les remplacements et suppressions. Le rendu statique montre les régions ; la lecture des articles, les timers et animations restent des fonctions de l'application PDF.

Références : ISO 12.3.2, pages physiques 373–375 ; ISO 12.4.2–12.4.4, pages physiques 382–389 ; GoTo, ISO 12.6.4.2, page physique 426.

Index · Code complet

Voir le résultat PDF

Ce fichier a été généré avec le code de cette documentation. Toutes ses pages sont reproduites ci-dessous ; ouvrir le PDF pour utiliser ses liens et ses signets.

Résultat PDF native-navigation : page 1 sur 3
Page 1 sur 3
Résultat PDF native-navigation : page 2 sur 3
Page 2 sur 3
Résultat PDF native-navigation : page 3 sur 3
Page 3 sur 3
Utiliser le lecteur PDF du navigateur

Ce lecteur dépend du navigateur. Si le document ne s’affiche pas, utiliser « Ouvrir le PDF » ou « Télécharger ».

Code source complet

Code de roukmoute/pdf · Consulter la source au commit documenté ↗

Lire le script commenté : 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;

Source de ce guide au commit documenté ↗