コンテンツにスキップ

実行時のドキュメント構造を基に目次を生成する

コンテンツの形は実行時に決まります。たとえば、データベースから読み込む章、Application Programming Interface(API)のレスポンスから構築するセクション、事前には制御できないループが出力する見出しなどです。同期がずれていく 2 つ目の手書きリストを保守せずに、ドキュメントのアウトラインとクリック可能な目次を、そのコンテンツに正確に一致させたい場面があります。

このレシピでは、アウトラインを動的に構築します。各見出しを書き込むたびに、エンジンからライブのカーソル位置とページを読み戻し(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 までのバックポートマトリックス全体で動作します。

動的な目次では、互いに一致している必要がある要素が 2 つあります。

  • 1 つは アウトライン(ブックマークとも呼ばれます)。読者がナビゲーションサイドバーで目にするツリーであり、各エントリはドキュメント内の位置へジャンプします。
  • もう 1 つは レンダリングされた目次。同じエントリをページ番号付きで一覧表示する、生成済みのページです。

NextPDF は、1 回の呼び出しで両者を同期します。bookmark($title, $level, $y) は、1 つのアウトライン項目 1 つの目次エントリを追加し、どちらも現在のページと現在の垂直位置にバインドされます。2 つのリストを保守する必要はありません。

動的な部分は、位置がどこから来るか です。静的なレシピでは、リテラルの見出しをソース順に渡します。ここでは、見出しを書き込んだ直後に、カーソルがどこに到達したかをエンジンに問い合わせます。

  • getPage() は、現在アクティブなページの 0 始まりのインデックスを返します。最初のページが追加される前は -1 を返します。
  • getNumPages() は、まだフラッシュされていないアクティブなページを含む、ページの総数を返します。
  • getY() は、ページ上端からの距離で測定した、現在の垂直カーソルをユーザー単位で返します。
  • getX()getPageHeight()getMargins() は、見出しと本文の最初の行がまとまって収まるかどうかを判断する必要があるときに、全体像を補います。

これらの値を読み取ってから、bookmark() を呼び出します。自動ページ区切りによって、2 つの見出しの間でカーソルが新しいページに移動することがあります。そのため、位置を推測せずに読み戻すことが、アウトラインの宛先を正しいページに保つ鍵となります。

パターン全体で重要な順序のポイントが 1 つあります。宛先にしたい正確な位置、つまり見出しテキストをレンダリングする直前に 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 に挿入します。ブックマークが 1 つもない場合は、ページを挿入せずに戻ります。
  • getPage(): int - 現在アクティブなページの 0 始まりのインデックス(最初のページの前は -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 - 自動ページ区切りと、その下側マージンのしきい値を制御します。

これは、実行時のリストから 3 つのセクションを書き込む例です。各反復で、ブックマークする前に 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');

セクションごとに 1 行ずつ、ターミナルには次の出力が期待されます。

Bookmarked 'Origins' on page index 0
Bookmarked 'Method' on page index 0
Bookmarked 'Results' on page index 0

このバージョンでは、ネストされた実行時構造から 2 階層のアウトライン(章とセクション)を構築します。書き込む前に位置を読み取り、見出しとその本文の最初の行をまとめて保ち、生成処理を最も具体的な NextPDF 例外に対する try/catch で囲みます。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() の呼び出しが 1 回も実行されなかった場合、addTOC() はページを挿入せずに戻ります。したがって、空の入力に対してレポートをガードする必要はありませんが、目次ページが表示されない点は把握しておく価値があります。
  • 目次は、差し込んだ位置で 1 回だけレンダリングされます。 addTOC(pageIndex: 0) は、目次を最初のページとして挿入します。レンダリングされたエントリのページ番号は各エントリの記録されたページを反映するため、すべての bookmark() の呼び出しが実行された後に目次を差し込んでください。
  • レベルをスキップすると不正な形式に見えます。 連続するブックマークの間では、$level を増やす幅は最大でも 1 にしてください。間にレベル 1 を挟まずにレベル 0 からレベル 2 へ飛ぶと、一部のリーダーで正しくレンダリングされない階層が生成されます。

bookmark() 呼び出しは、1 つのアウトライン項目と 1 つの目次エントリを O(1) 時間で追加し、各位置の読み取り(getPage()getY()getNumPages())はレンダリングコンテキスト上の定数時間のフィールドアクセスであり、走査は発生しません。アウトラインツリーと目次ページは、それぞれ addTOC()save() の時点で 1 回だけ生成されます。数百の見出しを含むレポートでも、2000 ms / 64 MB のバジェット内に十分収まります。生成はインプロセスで実行され、ヘッドレスブラウザもネットワーク呼び出しも使いません。

ブックマークのタイトルと目次ページには、bookmark() に渡した値がレンダリングされます。これらのタイトルに実行時データ(データベースの行から取得した章名や API のフィールドなど)が含まれる場合は、リーダーに表示される他の値と同様に、bookmark() に到達する前に文字列の長さを制限し、サニタイズしてください。検証されていないリクエスト入力からタイトルを構築しないでください。

エンジンは save() に渡された出力パスを検証します。ストリームラッパー(scheme://)と埋め込まれたヌルバイトを拒否し、親ディレクトリを resolve(解決)してパストラバーサルをブロックし、これらのいずれかが見つかった場合は InvalidConfigException を発生させます。自分が制御するパスを渡して、その検証が機能し続けるようにしてください。クライアントから提供された生のファイル名を save() に決して渡さないでください。InvalidConfigException を呼び出し元に報告する際は、詳細はサーバー側でログに記録し、解決済みのパスではなく一般的なメッセージを返してください。

このレシピ単体では、ISO 32000-2 準拠を主張しません。アウトラインと目次のセマンティクス、つまりアウトライン項目のツリーとしてのドキュメントアウトラインと、それらの項目に関連付けられた宛先については、関連する箇条の引用を含む ブックマークと目次を追加する で説明しています。ここで示す動的なパターンが変えるのは、宛先の位置がどこから来るか だけであり、書き込まれる構造は変わりません。

再現性プロファイル - 構造的。 トレーラーの /ID と日付アトムは保存ごとに変化します。構造的な比較では、これらを除去します。このページは、NextPDF がライブのカーソルからアウトラインと目次をどのように生成するかを記載するものであり、包括的な標準準拠を主張するものではありません。