依執行期文件結構產生目錄
你的內容是在執行期才成形的:章節從資料庫載入、各節由 API(Application Programming Interface)的回應建立、標題由一個你無法事先掌控的迴圈產出。你希望文件大綱,以及可點選的目錄,都與這些內容完全一致,而不必另外維護一份會逐漸失準的手寫清單。
這個範例會動態建立文件大綱。每當你寫入一個標題,就從引擎讀回即時游標與頁面位置——getPage()、getY() 與 getNumPages()——再把這些值傳給 bookmark()。書籤會綁定到你在那一刻讀到的位置,因此即使分頁落在你預期之外的地方,文件大綱仍會緊跟著內容。最後,addTOC() 會以同一批項目算繪出真正的目錄頁。
前置需求:一份 Core 安裝環境(composer require nextpdf/core:^3),以及標題結構是你在寫入過程中、而非事先就得知的內容。
本頁說明的是動態、由位置驅動的模式。如果你事先就知道每個標題及其層級,那屬於靜態情境,請先閱讀 新增書籤與目錄一節。這個範例建立在相同的 bookmark() 與 addTOC() 介面之上,不再重複說明。
composer require nextpdf/core:^3不需要任何選用擴充功能。導覽介面(bookmark()、addTOC())與位置存取子(getPage()、getY()、getNumPages())自 1.2.0 起即保持穩定,並可在 8.1 到 8.4 的 backport 矩陣上執行。
概念總覽
標題為「概念總覽」的區段動態目錄包含兩個必須彼此一致的部分:
- 文件大綱(也稱為書籤):讀者在導覽側欄看到的樹狀結構,其中每個項目都會跳到文件中的某個位置。
- 算繪後的目錄:算繪產生的一頁,列出相同的項目及其頁碼。
NextPDF 透過單一呼叫讓兩者保持同步。bookmark($title, $level, $y) 會新增一個文件大綱項目以及一個目錄項目,兩者都綁定到目前頁面與目前垂直位置。你永遠不必維護兩份清單。
動態模式的關鍵在於位置從何而來。靜態範例會依原始碼順序傳入字面上的標題。在這裡,你寫入一個標題後,立刻向引擎詢問游標落在何處:
getPage()會回傳作用中頁面的零基底 Index(索引)。加入第一頁之前,它會回傳-1。getNumPages()會回傳總頁數,包含尚未沖排的作用中頁面。getY()會以使用者單位回傳目前的垂直游標,量測從頁面頂端起算的距離。getX()、getPageHeight()與getMargins()在你需要判斷一個標題與其本文第一行是否能放在一起時,可以補足判斷所需資訊。
你讀取這些值之後,再呼叫 bookmark()。自動分頁可能在兩個標題之間把游標移到新的一頁,因此把位置讀回來、而非逕自假設,正是讓文件大綱目的地落在正確頁面上的關鍵。
整個模式有一個關鍵順序:在你想要的目的地那個確切位置呼叫 bookmark(),也就是緊接在你算繪標題文字之前。如果先寫入標題,之後才加書籤,記錄到的 getY() 會正好落在標題下方。
API 介面
標題為「API 介面」的區段這個範例依賴的方法全都位於 \NextPDF\Core\Document 上:
bookmark(string $title, int $level = 0, float $y = -1): static—— 在$level層級新增一個文件大綱項目與一個目錄項目,並綁定到目前的頁面。當$y = -1時,目的地為目前的游標 Y;傳入非負的 Y 則可釘選一個精確的目的地。addTOC(int $pageIndex = 0, string $title = ''): static—— 以累積的項目算繪出一個目錄頁,並在$pageIndex處接入。沒有任何書籤時,會直接返回而不插入頁面。getPage(): int—— 作用中頁面的零基底索引(第一頁加入前為-1)。getNumPages(): int—— 總頁數,包含尚未沖排的作用中頁面。getY(): float—— 以使用者單位表示的目前游標 Y(自頁面頂端起算的距離)。getX(): float—— 以使用者單位表示的目前游標 X。getPageHeight(): float—— 以使用者單位表示的目前頁面高度。getMargins(): \NextPDF\ValueObjects\Margin—— 目前作用中的邊界(top、right、bottom、left)。setY(float $y): static—— 把游標移到指定的 Y。setAutoPageBreak(bool $enabled, float $margin = 20): static—— 控制自動分頁及其底部邊界門檻。
程式碼範例 —— 快速上手
標題為「程式碼範例 —— 快速上手」的區段這段程式會從一份執行期清單寫出三個小節。每次迭代都會先以 getPage() 讀回即時頁面再加書籤,因此即使發生自動分頁,文件大綱的目的地仍維持正確。
<?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');終端機預期輸出如下,每個小節一行:
Bookmarked 'Origins' on page index 0Bookmarked 'Method' on page index 0Bookmarked 'Results' on page index 0程式碼範例 —— 正式環境
標題為「程式碼範例 —— 正式環境」的區段這個版本會以巢狀的執行期結構驅動兩層文件大綱(章與節),並在寫入前讀取位置,讓標題與其本文第一行保持相連;同時也把產生過程包進 try/catch,以便處理最具體的 NextPDF 例外。PageLayoutException 涵蓋產生端的失敗,例如超出頁面上限。save() 在輸出路徑無法寫入或不安全時會丟出 InvalidConfigException。
<?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()在第一頁加入前會回傳-1。 先加入第一頁,再讀取位置或呼叫bookmark()。範例一開始都會加入一頁。- 在標題之前加書籤,而非之後。
bookmark()搭配$y = -1會記錄目前的getY()。請在即將算繪標題之前呼叫它,讓目的地落在標題上,而不是落在其下方那一行。 - 自動分頁會移動目的地。 當
setAutoPageBreak()開啟時,一次cell()或multiCell()呼叫可能會沖排到新的一頁。請在下一次迭代時重新讀取getPage(),而非把它快取起來。目的地會跟著內容移動,因為bookmark()每次都會讀取即時位置。 - 為標題與其第一行預留空間,讓兩者留在同一頁。 如果標題剛好落在頁尾,但本文第一行卻換到下一頁,閱讀體驗不佳。正式範例會以
getPageHeight()、getMargins()->bottom與getY()計算剩餘高度,當剩下的高度少於門檻時,就強制提早addPage()。 - 對空白文件呼叫
addTOC()不會有任何作用。 若沒有執行過任何bookmark()呼叫,addTOC()會直接返回而不插入頁面。因此並不需要特別防範空白輸入,不過值得知道的是,這時目錄頁不會出現。 - 目錄只會在你接入它的位置算繪一次。
addTOC(pageIndex: 0)會把目錄插入為第一頁。算繪後各項目中的頁碼,反映的是每個項目所記錄的頁面,因此請在所有bookmark()呼叫都執行完之後,再接入目錄。 - 層級跳級看起來會像格式錯誤。 在相鄰的書籤之間,
$level最多只應遞增一級。從層級 0 跳到層級 2 而中間沒有層級 1,可能產生某些閱讀器會算繪錯誤的階層。
每次 bookmark() 呼叫都會以 O(1) 時間附加一個文件大綱項目與一個目錄項目,而每次位置讀取(getPage()、getY()、getNumPages())都是對算繪情境的常數時間欄位存取——不需要走訪。文件大綱樹與目錄頁各自只會具現化一次,分別發生在 addTOC() 與 save() 時。一份有數百個標題的報告,仍可輕鬆維持在 2000 ms/64 MB 的預算之內。產生過程在處理程序內執行:沒有 headless browser、也沒有網路呼叫。
安全性注意事項
標題為「安全性注意事項」的區段書籤標題與目錄頁會算繪你傳給 bookmark() 的值。當這些標題帶有執行期資料——例如資料庫資料列的章節名稱,或 API 欄位——請在字串送達 bookmark() 之前先限制長度並清理內容,就如同你對待任何顯示在閱讀器中的值一樣。請勿用未經驗證的請求輸入來建構標題。
引擎會驗證傳給 save() 的輸出路徑:它會拒絕串流包裝器(scheme://)與內嵌的 null 位元組,並解析(resolve)上層目錄以阻擋路徑穿越,遇到上述任一情況都會丟出 InvalidConfigException。請傳入你能掌控的路徑,讓這項驗證持續有效;絕不要把未經處理、由用戶端提供的檔名交給 save()。當你向呼叫端回報 InvalidConfigException 時,請在伺服器端記錄細節,並回傳一則通用訊息,而非回傳解析後的路徑。
符合性
標題為「符合性」的區段這個範例本身並未主張任何 ISO 32000-2 符合性聲明。文件大綱與目錄的語意——也就是文件大綱是一棵由文件大綱項目組成的樹,並包含與這些項目關聯的目的地——在 新增書籤與目錄一節有所說明,該頁也載有相關條款引用。這裡的動態模式只改變目的地位置從何而來,而不改變寫出的結構。
可重現性設定檔 —— 結構性。 尾段 /ID 與日期原子每次儲存都會變動;結構性比對會剝除這些。本頁說明 NextPDF 如何從即時游標產生文件大綱與目錄;它並未主張任何全面性的標準符合性聲明。
另請參閱
標題為「另請參閱」的區段- 新增書籤與目錄 —— 這則範例的靜態對應版本
- Navigation 模組
- HasPages concern —— 頁面與位置介面
- 建構多頁文件
- 頁首與頁尾