跳转到内容

从运行时文档结构生成目录

你的内容是在运行时才成形的:章节从数据库加载,各节由应用程序接口(Application Programming Interface,API)响应创建,标题由一个你无法事先掌控的循环生成。你希望文档大纲与可点击的目录与这些内容完全一致,而不必额外维护一份会逐渐失准的手写清单。

本示例会动态创建文档大纲。每当你写入一个标题,就从引擎读回实时光标与页面位置——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 如何从实时光标生成文档大纲与目录;它并未主张任何全面性的标准符合性声明。