Przejdź do głównej zawartości

Generowanie spisu treści na podstawie struktury dokumentu w czasie wykonywania

Treść może kształtować się dopiero w czasie wykonywania: rozdziały z bazy danych, sekcje z odpowiedzi API albo nagłówki z pętli, których nie da się znać z wyprzedzeniem. Potrzebujesz konspektu dokumentu i klikalnego spisu treści, które dokładnie odpowiadają tej treści, bez utrzymywania drugiej, ręcznie prowadzonej listy, która mogłaby się rozsynchronizować.

Ten przepis buduje konspekt dynamicznie. Podczas zapisywania każdego nagłówka odczytujesz z silnika bieżącą stronę i pozycję kursora za pomocą getPage(), getY() i getNumPages(), a następnie przekazujesz te wartości do bookmark(). Zakładka zostaje powiązana z pozycją odczytaną w tym momencie, dzięki czemu konspekt podąża za treścią nawet wtedy, gdy podziały stron wypadają w nieoczekiwanych miejscach. Na końcu addTOC() renderuje właściwą stronę spisu treści z tych samych wpisów.

Wymagania wstępne: instalacja rdzenia Core (composer require nextpdf/core:^3) oraz treść, której strukturę nagłówków odkrywasz podczas generowania, a nie wcześniej.

Ta strona omawia dynamiczny wzorzec sterowany pozycją. W przypadku statycznym, gdy z góry znasz każdy nagłówek i jego poziom, przeczytaj najpierw Dodawanie zakładek i spisu treści. Ten przepis korzysta z tej samej powierzchni API bookmark() i addTOC() i nie powtarza tych podstaw.

Okno terminala
composer require nextpdf/core:^3

Nie potrzebujesz żadnego opcjonalnego rozszerzenia. Powierzchnia nawigacji (bookmark(), addTOC()) oraz metody dostępu do pozycji (getPage(), getY(), getNumPages()) są stabilne od wersji 1.2.0 i działają w całej macierzy backportów od 8.1 do 8.4.

Dynamiczny spis treści ma dwie części, które muszą pozostawać zgodne:

  • Konspekt (zwany także zakładkami): drzewo widoczne dla czytelnika w bocznym panelu nawigacji, gdzie każdy wpis prowadzi do określonej pozycji w dokumencie.
  • Wyrenderowany spis treści: wygenerowana strona z tymi samymi wpisami i ich numerami stron.

NextPDF utrzymuje oba elementy w synchronizacji za pomocą jednego wywołania. bookmark($title, $level, $y) dodaje jeden element konspektu oraz jeden wpis spisu treści, oba powiązane z bieżącą stroną i bieżącą pozycją pionową. Nie utrzymujesz dwóch list.

Element dynamiczny polega na tym, skąd pochodzi pozycja. W przepisie statycznym przekazujesz jawne nagłówki w kolejności źródłowej. Tutaj zapisujesz nagłówek, a następnie natychmiast pytasz silnik, gdzie znalazł się kursor:

  • getPage() zwraca liczony od zera indeks aktywnej strony. Zanim zostanie dodana pierwsza strona, zwraca -1.
  • getNumPages() zwraca całkowitą liczbę stron, w tym aktywną stronę, która nie została jeszcze wypisana.
  • getY() zwraca bieżącą pionową pozycję kursora w jednostkach użytkownika, mierzoną jako odległość od górnej krawędzi strony.
  • getX(), getPageHeight() i getMargins() uzupełniają zestaw danych, gdy musisz zdecydować, czy nagłówek i pierwszy wiersz jego tekstu głównego mieszczą się razem.

Odczytaj te wartości, a następnie wywołaj bookmark(). Automatyczny podział strony może przenieść kursor na nową stronę między dwoma nagłówkami, więc ponowne odczytanie pozycji utrzymuje cel konspektu na właściwej stronie.

Cały wzorzec sprowadza się do jednej zasady kolejności: wywołaj bookmark() dokładnie w punkcie, w którym chcesz umieścić cel, czyli bezpośrednio przed wyrenderowaniem tekstu nagłówka. Jeśli najpierw zapiszesz nagłówek, a zakładkę utworzysz później, zapisana wartość getY() znajdzie się bezpośrednio pod nagłówkiem.

Ten przepis opiera się na następujących metodach \NextPDF\Core\Document:

  • bookmark(string $title, int $level = 0, float $y = -1): static - dodaje element konspektu i wpis spisu treści na poziomie $level, powiązany z bieżącą stroną. Przy $y = -1 celem jest bieżąca wartość Y kursora; przekaż nieujemne Y, aby przypiąć dokładny cel.
  • addTOC(int $pageIndex = 0, string $title = ''): static - renderuje stronę spisu treści z zebranych wpisów i wstawia ją w pozycji $pageIndex. Wraca bez wstawiania strony, gdy nie istnieje żadna zakładka.
  • getPage(): int - liczony od zera indeks aktywnej strony (-1 przed pierwszą stroną).
  • getNumPages(): int - całkowita liczba stron, w tym aktywna, jeszcze niewypisana strona.
  • getY(): float - bieżąca wartość Y kursora w jednostkach użytkownika (odległość od górnej krawędzi strony).
  • getX(): float - bieżąca wartość X kursora w jednostkach użytkownika.
  • getPageHeight(): float - wysokość bieżącej strony w jednostkach użytkownika.
  • getMargins(): \NextPDF\ValueObjects\Margin - aktywne marginesy (top, right, bottom, left).
  • setY(float $y): static - przesuwa kursor do jawnej wartości Y.
  • setAutoPageBreak(bool $enabled, float $margin = 20): static - steruje automatycznym podziałem strony i jego progiem dolnego marginesu.

Ten przykład zapisuje trzy sekcje z listy generowanej w czasie wykonywania. W każdej iteracji odczytuje bieżącą stronę za pomocą getPage() przed utworzeniem zakładki, dzięki czemu cel konspektu pozostaje poprawny po automatycznym podziale strony.

<?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');

Oczekiwane dane wyjściowe w terminalu, po jednym wierszu na sekcję:

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

Ta wersja tworzy dwupoziomowy konspekt (rozdziały i sekcje) z zagnieżdżonej struktury generowanej w czasie wykonywania. Utrzymuje nagłówek razem z pierwszym wierszem tekstu głównego, odczytując pozycję przed renderowaniem, a generowanie opakowuje w bloki try/catch dla najbardziej szczegółowych wyjątków NextPDF. PageLayoutException obejmuje błąd generowania, taki jak przekroczenie limitu stron. save() zgłasza InvalidConfigException dla niezapisywalnej lub niebezpiecznej ścieżki wyjściowej.

<?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() zwraca -1 przed pierwszą stroną. Dodaj pierwszą stronę przed odczytaniem pozycji lub wywołaniem bookmark(). Przykłady dodają stronę na początku.
  • Twórz zakładkę przed nagłówkiem, a nie po nim. bookmark() z $y = -1 zapisuje bieżącą wartość getY(). Wywołaj ją bezpośrednio przed wyrenderowaniem nagłówka, aby cel trafił na nagłówek, a nie na wiersz pod nim.
  • Automatyczne podziały stron przenoszą cel. Gdy setAutoPageBreak() jest włączone, wywołanie cell() lub multiCell() może umieścić treść na nowej stronie. Odczytaj getPage() ponownie w następnej iteracji, zamiast buforować tę wartość. Cel podąża za treścią, ponieważ bookmark() za każdym razem odczytuje bieżącą pozycję.
  • Zarezerwuj miejsce na nagłówek i jego pierwszy wiersz razem. Nagłówek umieszczony tuż nad stopką, gdy jego treść przechodzi na następną stronę, źle się czyta. Przykład produkcyjny oblicza pozostałą wysokość na podstawie getPageHeight(), getMargins()->bottom i getY(), a następnie wymusza wcześniejsze addPage(), gdy pozostaje mniej niż zadany próg.
  • addTOC() na pustym dokumencie nic nie robi. Jeśli nie wykonano żadnego wywołania bookmark(), addTOC() zwraca bez wstawiania strony. Obsługa pustego wejścia w raporcie nie jest zatem wymagana, choć warto wiedzieć, że strona spisu treści się nie pojawi.
  • Spis treści jest renderowany raz, w pozycji, w której go wstawiasz. addTOC(pageIndex: 0) wstawia spis treści jako pierwszą stronę. Numery stron w wyrenderowanych wpisach używają zapisanej strony każdego wpisu, więc wstaw spis treści po wykonaniu wszystkich wywołań bookmark().
  • Pominięte poziomy zniekształcają strukturę. Zwiększaj $level najwyżej o jeden między kolejnymi zakładkami. Przeskok z poziomu 0 do poziomu 2 bez pośredniego poziomu 1 tworzy hierarchię, którą niektóre czytniki renderują nieprawidłowo.

Każde wywołanie bookmark() dołącza jeden element konspektu i jeden wpis spisu treści w czasie O(1), a każdy odczyt pozycji (getPage(), getY(), getNumPages()) jest dostępem do pola w stałym czasie w kontekście renderowania, bez iteracji. Drzewo konspektu i strona spisu treści są materializowane jednokrotnie: odpowiednio przy addTOC() i przy save(). Raport z setkami nagłówków pozostaje znacznie poniżej budżetu 2000 ms / 64 MB. Generowanie odbywa się w ramach procesu, bez przeglądarki bezgłowej i bez wywołań sieciowych.

Tytuły zakładek i strona spisu treści renderują wartości przekazane do bookmark(). Gdy te tytuły zawierają dane dostępne w czasie wykonywania, takie jak nazwa rozdziału z wiersza bazy danych lub pole API, ogranicz długość i oczyść ciąg, zanim trafi do bookmark(), dokładnie tak jak każdą wartość wyświetlaną w czytniku. Nie buduj tytułów z niezweryfikowanych danych żądania.

Silnik weryfikuje ścieżkę wyjściową przekazaną do save(): odrzuca opakowania strumieni (scheme://) oraz osadzone bajty null, a także rozwiązuje katalog nadrzędny, aby zablokować przechodzenie ścieżek, zgłaszając InvalidConfigException w przypadku któregokolwiek z tych warunków. Zachowaj tę weryfikację, przekazując kontrolowaną ścieżkę; nigdy nie przekazuj save() surowej nazwy pliku dostarczonej przez klienta. Gdy raportujesz InvalidConfigException wywołującemu, zapisz szczegóły po stronie serwera i zwróć ogólny komunikat zamiast rozwiązanej ścieżki.

Ten przepis nie wysuwa własnego twierdzenia o zgodności z ISO 32000-2. Semantyka konspektu i spisu treści, w tym konspekt dokumentu jako drzewo elementów konspektu oraz cele powiązane z tymi elementami, jest opisana w Dodawanie zakładek i spisu treści, gdzie znajdują się odpowiednie cytaty z klauzul. Dynamiczny wzorzec opisany tutaj zmienia tylko to, skąd pochodzi pozycja celu, a nie strukturę, która zostaje zapisana.

Profil odtwarzalności - strukturalny. Element /ID zwiastuna oraz atomy daty zmieniają się przy każdym zapisie; porównanie strukturalne usuwa te wartości. Ta strona dokumentuje, w jaki sposób NextPDF tworzy konspekt i spis treści na podstawie bieżącej pozycji kursora; nie wysuwa ogólnego twierdzenia o zgodności ze standardami.