Generate a table of contents from runtime document structure
At a glance
Section titled “At a glance”Your content may take shape at runtime: chapters from a database, sections from an Application Programming Interface (API) response, or headings from a loop you cannot know in advance. You need the document outline and clickable table of contents to match that content exactly, without maintaining a second hand-written list that can drift out of sync.
This recipe builds the outline dynamically. As you write each heading, you read
the live cursor and page from the engine with getPage(), getY(), and
getNumPages(), then pass those values to bookmark(). The bookmark binds to
the position you read at that moment, so the outline follows the content even
when page breaks land in unexpected places. At the end, addTOC() renders a real
table-of-contents page from the same entries.
Prerequisites: a Core install (composer require nextpdf/core:^3) and content
whose heading structure you discover while writing, not before.
This page covers the dynamic, position-driven pattern. For the static case,
where you know every heading and its level up front, read
Add bookmarks and a table of contents
first. This recipe uses the same bookmark() and addTOC() surface and does
not repeat those basics.
Install
Section titled “Install”composer require nextpdf/core:^3You do not need any optional extension. The navigation surface (bookmark(),
addTOC()) and position accessors (getPage(), getY(), getNumPages()) have
been stable since 1.2.0 and run across the 8.1 through 8.4 backport matrix.
Conceptual overview
Section titled “Conceptual overview”A dynamic table of contents has two parts that must agree:
- The outline (also called bookmarks): the tree the reader sees in the navigation sidebar, where each entry jumps to a position in the document.
- The rendered table of contents: a generated page that lists the same entries with their page numbers.
NextPDF keeps both in sync through a single call. bookmark($title, $level, $y)
adds one outline item and one table-of-contents entry, both bound to the
current page and current vertical position. You do not maintain two lists.
The dynamic part is where the position comes from. A static recipe passes literal headings in source order. Here, you write a heading, then immediately ask the engine where the cursor landed:
getPage()returns the zero-based index of the active page. Before the first page is added it returns-1.getNumPages()returns the total page count, including the active page that has not been flushed yet.getY()returns the current vertical cursor in user units, measured as the distance from the top of the page.getX(),getPageHeight(), andgetMargins()round out the picture when you need to decide whether a heading and its first line of body text fit together.
Read those values, then call bookmark(). Automatic page breaking can move the
cursor to a fresh page between two headings, so reading the position back keeps
the outline destination on the correct page.
One ordering point drives the whole pattern: call bookmark() at the exact
point you want the destination, which is immediately before you render the
heading text. If you write the heading first and bookmark afterwards, the recorded
getY() sits directly below the heading.
API surface
Section titled “API surface”This recipe relies on these \NextPDF\Core\Document methods:
bookmark(string $title, int $level = 0, float $y = -1): static- add an outline item and a table-of-contents entry at$level, bound to the current page. With$y = -1the destination is the current cursor Y; pass a non-negative Y to pin a precise destination.addTOC(int $pageIndex = 0, string $title = ''): static- render a table-of-contents page from the accumulated entries and splice it in at$pageIndex. Returns without inserting a page when no bookmark exists.getPage(): int- zero-based index of the active page (-1before the first page).getNumPages(): int- total page count, including the active unflushed page.getY(): float- current cursor Y in user units (distance from the page top).getX(): float- current cursor X in user units.getPageHeight(): float- height of the current page in user units.getMargins(): \NextPDF\ValueObjects\Margin- the active margins (top,right,bottom,left).setY(float $y): static- move the cursor to an explicit Y.setAutoPageBreak(bool $enabled, float $margin = 20): static- control the automatic page break and its bottom-margin threshold.
Code sample — Quick start
Section titled “Code sample — Quick start”This sample writes three sections from a runtime list. Each iteration reads the
live page with getPage() before bookmarking, so the outline destination stays
correct after an automatic page break.
<?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');Expected terminal output, with one line per section:
Bookmarked 'Origins' on page index 0Bookmarked 'Method' on page index 0Bookmarked 'Results' on page index 0Code sample — Production
Section titled “Code sample — Production”This version builds a two-level outline (chapters and sections) from a nested
runtime structure. It keeps a heading with its first body line by reading the
position before it writes, and it wraps generation in try/catch blocks for
the most specific NextPDF exceptions. PageLayoutException covers a
generation-side failure, such as exceeding the page ceiling. save() raises
InvalidConfigException for an unwritable or unsafe output path.
<?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);}Edge cases & gotchas
Section titled “Edge cases & gotchas”getPage()returns-1before the first page. Add the first page before you read the position or callbookmark(). The samples add a page up front.- Bookmark before the heading, not after.
bookmark()with$y = -1records the currentgetY(). Call it immediately before you render the heading so the destination lands on the heading, not on the line below it. - Automatic page breaks move the destination. When
setAutoPageBreak()is on, acell()ormultiCell()call can flush to a new page. ReadgetPage()again on the next iteration instead of caching it. The destination follows the content becausebookmark()reads the live position each time. - Reserve space for a heading and its first line together. A heading that
fits at the page foot while its body wraps to the next page reads poorly. The
production sample computes the remaining height from
getPageHeight(),getMargins()->bottom, andgetY(), then forces an earlyaddPage()when less than a threshold remains. addTOC()on an empty document does nothing. If nobookmark()call ran,addTOC()returns without inserting a page. Guarding the report against empty input is therefore not required, though it is worth knowing the contents page will not appear.- The table of contents is rendered once, at the position you splice it.
addTOC(pageIndex: 0)inserts the contents as the first page. Page numbers in the rendered entries use each entry’s recorded page, so splice the contents after everybookmark()call has run. - Level skips look malformed. Increase
$levelby at most one between successive bookmarks. Jumping from level 0 to level 2 without an intervening level 1 produces a hierarchy some readers render incorrectly.
Performance
Section titled “Performance”Each bookmark() call appends one outline item and one table-of-contents entry
in O(1) time, and each position read (getPage(), getY(), getNumPages()) is
a constant-time field access on the rendering context, with no traversal. The
outline tree and the contents page are each materialised once: at addTOC() and
at save() respectively. A report with hundreds of headings stays well inside a
2000 ms / 64 MB budget. Generation runs in process, with no headless browser and
no network call.
Security notes
Section titled “Security notes”Bookmark titles and the contents page render the values you pass to
bookmark(). When those titles carry runtime data, such as a chapter name from
a database row or an API field, length-bound and sanitise the string before it
reaches bookmark(), exactly as you would any value displayed in the reader. Do
not build titles from unvalidated request input.
The engine validates the output path passed to save(): it rejects stream
wrappers (scheme://) and embedded null bytes, and it resolves the parent
directory to block path traversal, raising InvalidConfigException on any of
these conditions. Keep that validation working by passing a path you control; never hand
save() a raw client-supplied filename. When you report an
InvalidConfigException to a caller, log the detail server-side and return a
generic message rather than the resolved path.
Conformance
Section titled “Conformance”This recipe asserts no ISO 32000-2 conformance claim of its own. The outline and table-of-contents semantics, including the document outline as a tree of outline items and destinations associated with those items, are described in Add bookmarks and a table of contents, which carries the relevant clause citations. The dynamic pattern here changes only where the destination position comes from, not the structure that gets written.
Reproducibility profile - structural. The trailer /ID and date atoms vary
per save; a structural comparison removes those values. This page documents how NextPDF
produces the outline and contents from the live cursor; it does not assert a
blanket standards-conformance claim.
See also
Section titled “See also”- Add bookmarks and a table of contents - the static counterpart to this recipe
- Navigation module
- HasPages concern - the page and position surface
- Build a multi-page document
- Headers and footers