跳到內容

依執行期文件結構產生目錄

你的內容是在執行期才成形的:章節從資料庫載入、各節由 API(Application Programming Interface)的回應建立、標題由一個你無法事先掌控的迴圈產出。你希望文件大綱,以及可點選的目錄,都與這些內容完全一致,而不必另外維護一份會逐漸失準的手寫清單。

這個範例會動態建立文件大綱。每當你寫入一個標題,就從引擎讀回即時游標與頁面位置——getPage()getY()getNumPages()——再把這些值傳給 bookmark()。書籤會綁定到你在那一刻讀到的位置,因此即使分頁落在你預期之外的地方,文件大綱仍會緊跟著內容。最後,addTOC() 會以同一批項目算繪出真正的目錄頁。

前置需求:一份 Core 安裝環境(composer require nextpdf/core:^3),以及標題結構是你在寫入過程中、而非事先就得知的內容。

本頁說明的是動態、由位置驅動的模式。如果你事先就知道每個標題及其層級,那屬於靜態情境,請先閱讀 新增書籤與目錄一節。這個範例建立在相同的 bookmark()addTOC() 介面之上,不再重複說明。

Terminal window
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() 會正好落在標題下方。

這個範例依賴的方法全都位於 \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 —— 目前作用中的邊界(toprightbottomleft)。
  • 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 0
Bookmarked 'Method' on page index 0
Bookmarked '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()->bottomgetY() 計算剩餘高度,當剩下的高度少於門檻時,就強制提早 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 如何從即時游標產生文件大綱與目錄;它並未主張任何全面性的標準符合性聲明。