Aller au contenu

Générer une table des matières depuis la structure du document à l'exécution

Ton contenu prend forme à l’exécution : des chapitres chargés depuis une base de données, des sections construites à partir d’une réponse d’API, des titres produits par une boucle dont tu ne maîtrises pas le contenu à l’avance. Tu veux que le plan du document et une table des matières cliquable reflètent exactement ce contenu, sans maintenir une seconde liste écrite à la main qui finit par se désynchroniser.

Ce recipe construit le plan dynamiquement. À mesure que tu écris chaque titre, tu relis le curseur et la page courante depuis le moteur : getPage(), getY() et getNumPages() ; puis tu transmets ces valeurs à bookmark(). Le signet est lié à la position lue à cet instant précis, si bien que le plan suit le contenu même quand les sauts de page surviennent là où tu ne les attendais pas. À la fin, addTOC() rend une vraie page de table des matières à partir des mêmes entrées.

Prérequis : une installation du cœur (composer require nextpdf/core:^3) et du contenu dont tu découvres la structure de titres pendant l’écriture, pas avant.

Cette page couvre le motif dynamique piloté par la position. Pour le cas statique, où tu connais d’avance chaque titre et son niveau, lis d’abord Ajouter des signets et une table des matières. Ce recipe s’appuie sur la même surface d’API, bookmark() et addTOC(), et ne la réexplique pas.

Fenêtre de terminal
composer require nextpdf/core:^3

Aucune extension optionnelle n’est requise. La surface de navigation (bookmark(), addTOC()) et les accesseurs de position (getPage(), getY(), getNumPages()) sont stables depuis la version 1.2.0 et fonctionnent sur toute la matrice de backport 8.1 à 8.4.

Une table des matières dynamique se compose de deux parties qui doivent rester cohérentes :

  • Le plan (aussi appelé signets) : l’arborescence que le lecteur voit dans la barre latérale de navigation, où chaque entrée mène à une position du document.
  • La table des matières rendue : une page générée qui reprend les mêmes entrées avec leurs numéros de page.

NextPDF maintient les deux synchronisés via un seul appel. bookmark($title, $level, $y) ajoute un élément de plan et une entrée de table des matières, tous deux liés à la page courante et à la position verticale courante. Tu ne maintiens jamais deux listes.

La partie dynamique tient à l’origine de la position. Un recipe statique transmet des titres littéraux dans l’ordre de la source. Ici, tu écris un titre, puis tu demandes aussitôt au moteur où le curseur a atterri :

  • getPage() renvoie l’index de la page active, basé sur zéro. Avant l’ajout de la première page, il renvoie -1.
  • getNumPages() renvoie le nombre total de pages, y compris la page active qui n’a pas encore été vidée.
  • getY() renvoie le curseur vertical courant en unités utilisateur, mesuré comme la distance depuis le haut de la page.
  • getX(), getPageHeight() et getMargins() te donnent le contexte nécessaire quand tu dois décider si un titre et sa première ligne de corps tiennent ensemble.

Tu lis ces valeurs, puis tu appelles bookmark(). Le saut de page automatique peut déplacer le curseur vers une nouvelle page entre deux titres ; relire la position, au lieu de la supposer, maintient la destination du plan sur la bonne page.

Tout le motif repose sur un seul point d’ordonnancement : appelle bookmark() au point exact où tu veux la destination, c’est-à-dire juste avant de rendre le texte du titre. Si tu écris le titre d’abord et poses le signet ensuite, le getY() enregistré se trouvera juste en dessous du titre.

Les méthodes sur lesquelles ce recipe s’appuie, toutes disponibles sur \NextPDF\Core\Document :

  • bookmark(string $title, int $level = 0, float $y = -1): static : ajoute un élément de plan et une entrée de table des matières au $level indiqué, liés à la page courante. Avec $y = -1, la destination est le Y courant du curseur ; passe un Y non négatif pour fixer une destination précise.
  • addTOC(int $pageIndex = 0, string $title = ''): static : rend une page de table des matières à partir des entrées accumulées et l’insère à $pageIndex. Renvoie sans insérer de page quand aucun signet n’existe.
  • getPage(): int : index basé sur zéro de la page active (-1 avant la première page).
  • getNumPages(): int : nombre total de pages, y compris la page active non vidée.
  • getY(): float : Y courant du curseur en unités utilisateur (distance depuis le haut de la page).
  • getX(): float : X courant du curseur en unités utilisateur.
  • getPageHeight(): float : hauteur de la page courante en unités utilisateur.
  • getMargins(): \NextPDF\ValueObjects\Margin : les marges actives (top, right, bottom, left).
  • setY(float $y): static : déplace le curseur vers un Y explicite.
  • setAutoPageBreak(bool $enabled, float $margin = 20): static : contrôle le saut de page automatique et son seuil de marge inférieure.

Cet exemple écrit trois sections à partir d’une liste obtenue à l’exécution. Chaque itération relit la page courante avec getPage() avant de poser le signet, si bien que la destination du plan reste correcte même après un saut de page automatique.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
/** @var list<array{title: string, body: string}> $sections */
$sections = [
['title' => 'Origins', 'body' => 'Runtime content for the first section.'],
['title' => 'Method', 'body' => 'Runtime content for the second section.'],
['title' => 'Results', 'body' => 'Runtime content for the third section.'],
];
$doc = Document::createStandalone();
$doc->addPage();
foreach ($sections as $section) {
// Read the live page back, then bookmark BEFORE rendering the heading,
// so the destination points at the heading, not below it.
$pageIndex = $doc->getPage();
$doc->bookmark($section['title'], level: 0);
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 10, $section['title'], newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->multiCell(0, 7, $section['body']);
$doc->ln(6);
echo "Bookmarked '{$section['title']}' on page index {$pageIndex}\n";
}
// Splice the rendered table of contents in as the first page.
$doc->addTOC(pageIndex: 0, title: 'Contents');
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/dynamic-toc.pdf');

Sortie attendue sur le terminal, avec une ligne par section :

Bookmarked 'Origins' on page index 0
Bookmarked 'Method' on page index 0
Bookmarked 'Results' on page index 0

Cette version construit un plan à deux niveaux (chapitres et sections) à partir d’une structure imbriquée obtenue à l’exécution, évite de séparer un titre de la première ligne de son corps en lisant la position avant d’écrire, et encadre la génération dans un try/catch ciblant les exceptions NextPDF les plus spécifiques. PageLayoutException couvre une défaillance de génération, comme le dépassement du plafond de pages. save() lève InvalidConfigException pour un chemin de sortie non inscriptible ou non sûr.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Exception\InvalidConfigException;
use NextPDF\Exception\PageLayoutException;
/**
* Render a report whose chapter and section structure is known only at runtime,
* building the outline and table of contents from the live cursor position.
*
* @param list<array{title: string, sections: list<array{title: string, body: string}>}> $chapters
*
* @throws PageLayoutException When page generation exceeds an engine limit.
* @throws InvalidConfigException When the output path cannot be written.
*/
function renderDynamicToc(array $chapters, string $outputPath): void
{
$doc = Document::createStandalone();
$doc->setTitle('Runtime Report');
$doc->setPrintHeader(false);
$doc->setPrintFooter(false);
// A 25 mm bottom threshold so a heading does not strand at the page foot.
$doc->setAutoPageBreak(true, margin: 25);
$doc->addPage();
foreach ($chapters as $chapter) {
// Reserve space so the chapter heading and its first section start
// together: if less than 40 user units remain, break first.
$remaining = $doc->getPageHeight() - $doc->getMargins()->bottom - $doc->getY();
if ($remaining < 40.0) {
$doc->addPage();
}
// Bookmark at the destination point, before the heading is drawn.
$doc->bookmark($chapter['title'], level: 0);
$doc->setFont('helvetica', 'B', 18);
$doc->cell(0, 12, $chapter['title'], newLine: true);
$doc->ln(3);
foreach ($chapter['sections'] as $section) {
$doc->bookmark($section['title'], level: 1);
$doc->setFont('helvetica', 'B', 13);
$doc->cell(0, 9, $section['title'], newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->multiCell(0, 7, $section['body']);
$doc->ln(5);
}
}
// Render the table of contents only when at least one bookmark exists.
// addTOC() is a no-op when the entry list is empty, so an empty report
// produces no contents page rather than a blank one.
$doc->addTOC(pageIndex: 0, title: 'Table of Contents');
$doc->save($outputPath);
}
/** @var list<array{title: string, sections: list<array{title: string, body: string}>}> $chapters */
$chapters = [
[
'title' => 'Chapter 1: Overview',
'sections' => [
['title' => 'Scope', 'body' => 'Runtime body text for the scope section.'],
['title' => 'Audience', 'body' => 'Runtime body text for the audience section.'],
],
],
[
'title' => 'Chapter 2: Detail',
'sections' => [
['title' => 'Inputs', 'body' => 'Runtime body text for the inputs section.'],
],
],
];
$output = getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/dynamic-toc.pdf';
try {
renderDynamicToc($chapters, $output);
echo "Wrote {$output}\n";
} catch (PageLayoutException $e) {
// A structural limit was hit during generation; surface the page context.
fwrite(STDERR, 'Layout failure while building the report: ' . $e->getMessage() . "\n");
exit(1);
} catch (InvalidConfigException $e) {
// The output path was rejected (stream wrapper, missing directory, or
// a null byte). Report it without leaking the resolved path to a client.
fwrite(STDERR, 'Output path rejected: ' . $e->getMessage() . "\n");
exit(1);
}
  • getPage() renvoie -1 avant la première page. Ajoute la première page avant de lire la position ou d’appeler bookmark(). Les exemples ajoutent une page dès le départ.
  • Pose le signet avant le titre, pas après. bookmark() avec $y = -1 enregistre le getY() courant. Appelle-le juste avant de rendre le titre pour que la destination atterrisse sur le titre, et non sur la ligne en dessous.
  • Les sauts de page automatiques déplacent la destination. Quand setAutoPageBreak() est activé, un appel à cell() ou multiCell() peut reporter le contenu sur une nouvelle page. Relis getPage() à l’itération suivante plutôt que de le mettre en cache. La destination suit le contenu parce que bookmark() relit la position en temps réel à chaque fois.
  • Réserve l’espace pour un titre et sa première ligne ensemble. Un titre placé au pied de la page alors que son corps déborde sur la page suivante se lit mal. L’exemple de production calcule la hauteur restante à partir de getPageHeight(), getMargins()->bottom et getY(), puis force un addPage() anticipé quand il reste moins qu’un seuil.
  • addTOC() sur un document vide ne fait rien. Si aucun appel à bookmark() n’a été exécuté, addTOC() renvoie sans insérer de page. Il n’est donc pas nécessaire de protéger le rapport contre l’absence d’entrée, même s’il est bon de savoir que la page de table des matières n’apparaîtra pas.
  • La table des matières est rendue une seule fois, à la position où tu l’insères. addTOC(pageIndex: 0) insère la table des matières comme première page. Les numéros de page dans les entrées rendues reflètent la page enregistrée de chaque entrée ; insère donc la table des matières une fois que tous les appels à bookmark() ont été exécutés.
  • Les sauts de niveau peuvent sembler mal formés. Augmente $level d’au plus un entre deux signets successifs. Sauter du niveau 0 au niveau 2 sans un niveau 1 intermédiaire produit une hiérarchie que certains lecteurs affichent incorrectement.

Chaque appel à bookmark() ajoute un élément de plan et une entrée de table des matières en temps O(1), et chaque lecture de position (getPage(), getY(), getNumPages()) est un accès à un champ en temps constant dans le contexte de rendu, sans parcours. L’arborescence du plan et la page de table des matières sont chacune matérialisées une seule fois, respectivement lors de addTOC() et de save(). Un rapport comportant des centaines de titres reste largement dans un budget de 2000 ms / 64 Mo. La génération s’exécute dans le processus : pas de navigateur headless ni d’appel réseau.

Les titres des signets et la page de table des matières affichent les valeurs que tu passes à bookmark(). Quand ces titres contiennent des données obtenues à l’exécution, comme un nom de chapitre issu d’une ligne de base de données ou un champ d’API, borne la longueur et assainis la chaîne avant qu’elle n’atteigne bookmark(), exactement comme tu le ferais pour toute valeur affichée dans le lecteur. Ne construis pas les titres à partir d’une entrée de requête non validée.

Le moteur valide le chemin de sortie passé à save() : il rejette les wrappers de flux (scheme://) et les octets nuls intégrés, puis résout le répertoire parent pour bloquer la traversée de chemin, en levant InvalidConfigException pour chacun de ces cas. Garde cette validation en place en passant un chemin que tu contrôles ; ne transmets jamais à save() un nom de fichier brut fourni par le client. Quand tu signales une InvalidConfigException à un appelant, journalise le détail côté serveur et renvoie un message générique plutôt que le chemin résolu.

Ce recipe n’affirme aucune revendication de conformité ISO 32000-2 qui lui soit propre. La sémantique du plan et de la table des matières - le plan du document comme arborescence d’éléments de plan, et les destinations associées à ces éléments - est décrite dans Ajouter des signets et une table des matières, qui contient les citations de clauses pertinentes. Le motif dynamique présenté ici change seulement l’origine de la position de destination, pas la structure qui est écrite.

Profil de reproductibilité : structurel. Le /ID du trailer et les atomes de date varient à chaque enregistrement ; une comparaison structurelle les retire. Cette page documente comment NextPDF produit le plan et la table des matières à partir du curseur en temps réel ; elle ne formule pas de revendication globale de conformité aux normes.