Ein Inhaltsverzeichnis zur Laufzeit aus der Dokumentstruktur erzeugen
Auf einen Blick
Abschnitt betitelt „Auf einen Blick“Ihr Inhalt entsteht zur Laufzeit - Kapitel werden aus einer Datenbank geladen, Abschnitte aus einer API-Antwort aufgebaut, Überschriften von einer Schleife ausgegeben, die Sie nicht im Voraus steuern. Sie möchten, dass die Dokumentgliederung und ein anklickbares Inhaltsverzeichnis genau zu diesem Inhalt passen, ohne eine zweite, manuell geschriebene Liste zu pflegen, die nach und nach aus dem Takt gerät.
Dieses Recipe baut die Gliederung dynamisch auf. Während Sie jede Überschrift schreiben, lesen Sie den aktuellen Cursor und die aktuelle Seite aus der Engine zurück - getPage(), getY() und getNumPages() - und übergeben diese Werte an bookmark(). Das Lesezeichen bindet sich an die Position, die Sie genau in diesem Moment auslesen, sodass die Gliederung dem Inhalt folgt, selbst wenn Seitenumbrüche an Stellen landen, an denen Sie sie nicht erwartet haben. Am Ende rendert addTOC() aus denselben Einträgen eine echte Inhaltsverzeichnis-Seite.
Voraussetzungen: eine Core-Installation (composer require nextpdf/core:^3) und Inhalt, dessen Überschriftenstruktur Sie erst beim Schreiben erkennen, nicht vorher.
Diese Seite behandelt das dynamische, positionsgesteuerte Muster. Für den statischen Fall, in dem Sie jede Überschrift und ihre Ebene im Voraus kennen, lesen Sie zuerst Lesezeichen und ein Inhaltsverzeichnis hinzufügen. Dieses Recipe baut auf derselben bookmark()- und addTOC()-Schnittstelle auf und wiederholt sie nicht.
Installation
Abschnitt betitelt „Installation“composer require nextpdf/core:^3Es ist keine optionale Erweiterung erforderlich. Die Navigationsschnittstelle (bookmark(), addTOC()) und die Positions-Accessoren (getPage(), getY(), getNumPages()) sind seit 1.2.0 stabil und laufen über die gesamte Backport-Matrix von 8.1 bis 8.4.
Konzeptioneller Überblick
Abschnitt betitelt „Konzeptioneller Überblick“Ein dynamisches Inhaltsverzeichnis hat zwei Hälften, die zusammenpassen müssen:
- Die Gliederung (auch Lesezeichen genannt): der Baum, den der Leser in der Navigations-Seitenleiste sieht und in dem jeder Eintrag zu einer Position im Dokument springt.
- Das gerenderte Inhaltsverzeichnis: eine erzeugte Seite, die dieselben Einträge mit ihren Seitenzahlen auflistet.
NextPDF hält beide über einen einzigen Aufruf synchron. bookmark($title, $level, $y) fügt einen Gliederungseintrag und einen Inhaltsverzeichnis-Eintrag hinzu, beide an die aktuelle Seite und die aktuelle vertikale Position gebunden. Sie pflegen nie zwei Listen.
Der dynamische Teil ist, woher die Position kommt. Ein statisches Recipe übergibt konkrete Überschriften in Quellreihenfolge. Hier schreiben Sie eine Überschrift und fragen die Engine sofort, wo der Cursor gelandet ist:
getPage()liefert den nullbasierten Index der aktiven Seite zurück. Bevor die erste Seite hinzugefügt wird, liefert es-1zurück.getNumPages()liefert die Gesamtzahl der Seiten zurück, einschließlich der aktiven Seite, die noch nicht geleert wurde.getY()liefert den aktuellen vertikalen Cursor in Benutzereinheiten zurück, gemessen als Abstand vom oberen Seitenrand.getX(),getPageHeight()undgetMargins()runden das Bild ab, wenn Sie entscheiden müssen, ob eine Überschrift und ihre erste Zeile Fließtext zusammen auf die Seite passen.
Sie lesen diese Werte und rufen dann bookmark() auf. Der automatische Seitenumbruch kann den Cursor zwischen zwei Überschriften auf eine neue Seite verschieben; deshalb hält das Zurücklesen der Position - statt sie anzunehmen - das Gliederungsziel auf der richtigen Seite.
Ein Reihenfolgepunkt steuert das gesamte Muster: Rufen Sie bookmark() genau an dem Punkt auf, an dem das Ziel liegen soll, also unmittelbar bevor Sie den Überschriftentext rendern. Wenn Sie die Überschrift zuerst schreiben und das Lesezeichen danach setzen, liegt das aufgezeichnete getY() direkt unter der Überschrift.
API-Oberfläche
Abschnitt betitelt „API-Oberfläche“Die Methoden, auf die sich dieses Recipe stützt, alle auf \NextPDF\Core\Document:
bookmark(string $title, int $level = 0, float $y = -1): static- fügt einen Gliederungseintrag und einen Inhaltsverzeichnis-Eintrag auf$levelhinzu, gebunden an die aktuelle Seite. Mit$y = -1ist das Ziel das aktuelle Cursor-Y; übergeben Sie ein nicht-negatives Y, um ein präzises Ziel festzulegen.addTOC(int $pageIndex = 0, string $title = ''): static- rendert aus den gesammelten Einträgen eine Inhaltsverzeichnis-Seite und fügt sie an$pageIndexein. Kehrt zurück, ohne eine Seite einzufügen, wenn kein Lesezeichen vorhanden ist.getPage(): int- nullbasierter Index der aktiven Seite (-1vor der ersten Seite).getNumPages(): int- Gesamtzahl der Seiten, einschließlich der aktiven, noch nicht geleerten Seite.getY(): float- aktuelles Cursor-Y in Benutzereinheiten (Abstand vom oberen Seitenrand).getX(): float- aktuelles Cursor-X in Benutzereinheiten.getPageHeight(): float- Höhe der aktuellen Seite in Benutzereinheiten.getMargins(): \NextPDF\ValueObjects\Margin- die aktiven Ränder (top,right,bottom,left).setY(float $y): static- bewegt den Cursor auf ein explizites Y.setAutoPageBreak(bool $enabled, float $margin = 20): static- steuert den automatischen Seitenumbruch und seinen Schwellenwert für den unteren Rand.
Codebeispiel — Schnellstart
Abschnitt betitelt „Codebeispiel — Schnellstart“Dieses Beispiel schreibt drei Abschnitte aus einer Laufzeitliste. Jede Iteration liest die aktuelle Seite mit getPage() zurück, bevor sie ein Lesezeichen setzt, sodass das Gliederungsziel selbst nach einem automatischen Seitenumbruch korrekt bleibt.
<?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');Erwartete Terminalausgabe mit einer Zeile pro Abschnitt:
Bookmarked 'Origins' on page index 0Bookmarked 'Method' on page index 0Bookmarked 'Results' on page index 0Codebeispiel — Produktion
Abschnitt betitelt „Codebeispiel — Produktion“Diese Version steuert eine zweistufige Gliederung (Kapitel und Abschnitte) aus einer verschachtelten Laufzeitstruktur, hält eine Überschrift mit ihrer ersten Textzeile zusammen, indem sie die Position vor dem Schreiben liest, und fängt bei der Erzeugung mit try/catch die spezifischsten NextPDF-Ausnahmen ab. PageLayoutException deckt einen Fehler bei der Seitenerzeugung ab, etwa das Überschreiten der Seitengrenze. save() löst InvalidConfigException für einen nicht beschreibbaren oder unsicheren Ausgabepfad aus.
<?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);}Grenzfälle & Fallstricke
Abschnitt betitelt „Grenzfälle & Fallstricke“getPage()liefert-1vor der ersten Seite. Fügen Sie die erste Seite hinzu, bevor Sie die Position lesen oderbookmark()aufrufen. Die Beispiele fügen vorab eine Seite hinzu.- Setzen Sie das Lesezeichen vor der Überschrift, nicht danach.
bookmark()mit$y = -1zeichnet das aktuellegetY()auf. Rufen Sie es unmittelbar vor dem Rendern der Überschrift auf, damit das Ziel auf der Überschrift landet und nicht auf der Zeile darunter. - Automatische Seitenumbrüche verschieben das Ziel. Wenn
setAutoPageBreak()aktiviert ist, kann eincell()- odermultiCell()-Aufruf auf eine neue Seite umbrechen. Lesen SiegetPage()in der nächsten Iteration erneut, statt es zwischenzuspeichern. Das Ziel folgt dem Inhalt, weilbookmark()jedes Mal die aktuelle Position liest. - Reservieren Sie Platz für eine Überschrift und ihre erste Zeile zusammen. Eine Überschrift, die am Seitenfuß noch passt, während ihr Text auf die nächste Seite umbricht, liest sich schlecht. Das Produktionsbeispiel berechnet die verbleibende Höhe aus
getPageHeight(),getMargins()->bottomundgetY()und erzwingt einen frühenaddPage(), wenn weniger als der Schwellenwert übrig ist. addTOC()auf einem leeren Dokument tut nichts. Wenn keinbookmark()-Aufruf erfolgt ist, kehrtaddTOC()zurück, ohne eine Seite einzufügen. Den Report gegen leere Eingaben abzusichern, ist daher nicht erforderlich. Sie sollten jedoch wissen, dass die Inhaltsverzeichnis-Seite dann nicht erscheint.- Das Inhaltsverzeichnis wird einmal gerendert, an der Position, an der Sie es einfügen.
addTOC(pageIndex: 0)fügt das Inhaltsverzeichnis als erste Seite ein. Die Seitenzahlen in den gerenderten Einträgen spiegeln die aufgezeichnete Seite jedes Eintrags wider; fügen Sie das Inhaltsverzeichnis daher erst ein, nachdem jederbookmark()-Aufruf erfolgt ist. - Ebenensprünge sehen fehlerhaft aus. Erhöhen Sie
$levelzwischen aufeinanderfolgenden Lesezeichen um höchstens eins. Ein Sprung von Ebene 0 auf Ebene 2 ohne dazwischenliegende Ebene 1 erzeugt eine Hierarchie, die manche Reader falsch darstellen.
Performance
Abschnitt betitelt „Performance“Jeder bookmark()-Aufruf hängt einen Gliederungseintrag und einen Inhaltsverzeichnis-Eintrag in O(1)-Zeit an, und jedes Lesen der Position (getPage(), getY(), getNumPages()) ist ein Feldzugriff in konstanter Zeit auf den Rendering-Kontext - ohne Traversierung. Der Gliederungsbaum und die Inhaltsverzeichnis-Seite werden jeweils einmal materialisiert, bei addTOC() beziehungsweise bei save(). Ein Report mit Hunderten von Überschriften bleibt deutlich innerhalb eines Budgets von 2000 ms / 64 MB. Die Erzeugung läuft im Prozess: ohne headless Chrome und ohne Netzwerkaufruf.
Sicherheitshinweise
Abschnitt betitelt „Sicherheitshinweise“Lesezeichentitel und die Inhaltsverzeichnis-Seite rendern die Werte, die Sie an bookmark() übergeben. Wenn diese Titel Laufzeitdaten enthalten - einen Kapitelnamen aus einer Datenbankzeile oder ein API-Feld -, begrenzen Sie die Länge und bereinigen Sie die Zeichenkette, bevor sie bookmark() erreicht, genau wie bei jedem Wert, der im Reader angezeigt wird. Bauen Sie Titel nicht aus unvalidierten Anfrageeingaben zusammen.
Die Engine validiert den an save() übergebenen Ausgabepfad: Sie lehnt Stream-Wrapper (scheme://) und eingebettete Null-Bytes ab und löst den übergeordneten Ordner auf, um Path Traversal zu blockieren; in all diesen Fällen löst sie InvalidConfigException aus. Halten Sie diese Validierung funktionsfähig, indem Sie einen Pfad übergeben, den Sie kontrollieren; übergeben Sie save() niemals einen rohen, vom Client gelieferten Dateinamen. Wenn Sie eine InvalidConfigException an einen Aufrufer melden, protokollieren Sie die Details serverseitig und geben Sie eine generische Meldung statt des aufgelösten Pfads zurück.
Konformität
Abschnitt betitelt „Konformität“Dieses Recipe erhebt keinen eigenen Konformitätsanspruch nach ISO 32000-2. Die Gliederungs- und Inhaltsverzeichnis-Semantik - die Dokumentgliederung als Baum von Gliederungseinträgen und die mit diesen Einträgen verknüpften Ziele - ist in Lesezeichen und ein Inhaltsverzeichnis hinzufügen beschrieben, das die einschlägigen Klausel-Zitate enthält. Das dynamische Muster hier ändert nur, woher die Zielposition kommt, nicht die Struktur, die geschrieben wird.
Reproduzierbarkeitsprofil - strukturell. Das Trailer-/ID und die Datums-Atome variieren bei jedem Speichern; ein struktureller Vergleich entfernt diese. Diese Seite dokumentiert, wie NextPDF die Gliederung und das Inhaltsverzeichnis aus dem aktuellen Cursor erzeugt; sie erhebt keinen pauschalen Standardkonformitätsanspruch.
Siehe auch
Abschnitt betitelt „Siehe auch“- Lesezeichen und ein Inhaltsverzeichnis hinzufügen - das statische Gegenstück zu diesem Recipe
- Navigations-Modul
- HasPages-Concern - die Seiten- und Positions-Schnittstelle
- Ein mehrseitiges Dokument erstellen
- Kopf- und Fußzeilen