从运行时文档结构生成目录
你的内容是在运行时才成形的:章节从数据库加载,各节由应用程序接口(Application Programming Interface,API)响应创建,标题由一个你无法事先掌控的循环生成。你希望文档大纲与可点击的目录与这些内容完全一致,而不必额外维护一份会逐渐失准的手写清单。
本示例会动态创建文档大纲。每当你写入一个标题,就从引擎读回实时光标与页面位置——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 —— 页面与位置接口
- 构建多页文档
- 页眉与页脚