Создание содержания по структуре документа во время выполнения
Краткий обзор
Заголовок раздела «Краткий обзор»Содержимое может формироваться во время выполнения: главы из базы данных, разделы из ответа интерфейса прикладного программирования (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.
Концептуальный обзор
Заголовок раздела «Концептуальный обзор»Динамическое содержание состоит из двух согласованных частей:
- Первая — это Структура (её также называют закладками): дерево, которое читатель видит на боковой панели навигации; каждая запись ведёт к определённому месту в документе.
- Вторая — это Готовое содержание: сформированная страница с теми же записями и номерами их страниц.
NextPDF синхронизирует обе части одним вызовом. bookmark($title, $level, $y) добавляет один элемент структуры и одну запись содержания, привязанные к текущей странице и текущему вертикальному положению. Вам не нужно вести два списка.
Динамическая часть — это источник положения. Статический рецепт передаёт буквальные заголовки в порядке исходного кода. Здесь вы выводите заголовок, а затем сразу запрашиваете у движка, куда попал курсор:
getPage()возвращает отсчитываемый от нуля индекс активной страницы. До добавления первой страницы он возвращает-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 мс / 64 МБ. Формирование выполняется в процессе, без headless-браузера и сетевых вызовов.
Замечания по безопасности
Заголовок раздела «Замечания по безопасности»Заголовки закладок и страница содержания отображают значения, которые вы передаёте в bookmark(). Когда эти заголовки содержат данные, формируемые во время выполнения, например имя главы из строки базы данных или поле API, ограничьте длину и очистите строку, прежде чем она попадёт в bookmark(), как и любое другое значение, отображаемое в средстве просмотра. Не формируйте заголовки из непроверенных данных запроса.
Движок проверяет выходной путь, переданный в save(): он отклоняет потоковые обёртки (scheme://) и встроенные нулевые байты, а также разрешает родительский каталог, чтобы заблокировать обход пути, вызывая InvalidConfigException при любом из этих условий. Сохраняйте эту проверку рабочей: передавайте путь, который вы контролируете, и никогда не передавайте в save() необработанное имя файла, полученное от клиента. Когда вы сообщаете об InvalidConfigException вызывающей стороне, регистрируйте подробности на стороне сервера и возвращайте общее сообщение, а не разрешённый путь.
Соответствие
Заголовок раздела «Соответствие»Этот рецепт сам по себе не заявляет о соответствии ISO 32000-2. Семантика структуры и содержания, включая структуру документа как дерево элементов структуры и связанные с ними цели, описана в “Добавление закладок и содержания”, где приведены соответствующие ссылки на пункты стандарта. Динамический шаблон здесь меняет только то, откуда берётся положение цели, а не записываемую структуру.
Профиль воспроизводимости - структурный. Значение трейлера /ID и атомы даты меняются при каждом сохранении; структурное сравнение удаляет эти значения. Эта страница документирует, как NextPDF формирует структуру и содержание на основе текущего положения курсора; она не заявляет о всеобъемлющем соответствии стандартам.
См. также
Заголовок раздела «См. также»- Добавление закладок и содержания - статический аналог этого рецепта
- Модуль навигации
- Трейт HasPages - интерфейс работы со страницами и положением
- Создание многостраничного документа
- Колонтитулы