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 :
- Construire les beads, articles et collections de pages.
- Fournir
PageBeads::pageEntry()au constructeur de chaque page. - Rattacher les pages à l'arbre.
- 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.
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.
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;


