런타임 문서 구조로 목차 생성하기
한눈에 보기
섹션 제목: “한눈에 보기”콘텐츠의 형태는 런타임에 결정됩니다. 데이터베이스에서 로드한 장(chapter), 애플리케이션 프로그래밍 인터페이스(API) 응답으로 구성한 절(section), 미리 제어할 수 없는 루프에서 생성되는 제목 등이 여기에 해당합니다. 동기화가 어긋나기 쉬운 별도의 수동 목록을 유지하지 않고도, 문서 개요와 클릭 가능한 목차가 해당 콘텐츠와 정확히 일치해야 합니다.
이 레시피는 개요를 동적으로 구성합니다. 각 제목을 작성할 때 엔진에서 현재 커서와 페이지를 다시 읽어 옵니다 - 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()는 활성 페이지의 0부터 시작하는 인덱스를 반환합니다. 첫 페이지가 추가되기 전에는-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- 활성 페이지의 0부터 시작하는 인덱스(첫 페이지 전에는-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코드 예제 — 프로덕션
섹션 제목: “코드 예제 — 프로덕션”이 버전은 중첩된 런타임 구조에서 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()->bottom,getY()로 남은 높이를 계산한 다음, 임계값보다 적게 남으면 미리addPage()를 강제합니다. - 빈 문서에서
addTOC()는 아무 작업도 하지 않습니다.bookmark()호출이 한 번도 실행되지 않았다면,addTOC()는 페이지를 삽입하지 않고 반환합니다. 따라서 빈 입력에 대해 별도의 보고서 보호 로직을 둘 필요는 없지만, 목차 페이지가 나타나지 않는다는 점은 알아 두는 것이 좋습니다. - 목차는 삽입하는 위치에서 한 번만 렌더링됩니다.
addTOC(pageIndex: 0)은 목차를 첫 페이지로 삽입합니다. 렌더링된 항목의 페이지 번호는 각 항목의 기록된 페이지를 반영하므로, 모든bookmark()호출이 실행된 후에 목차를 삽입하십시오. - 수준을 건너뛰면 형식이 잘못된 것처럼 보입니다. 연속된 북마크 사이에서
$level을 최대 하나씩만 늘리십시오. 중간 수준 1 없이 수준 0에서 수준 2로 건너뛰면 일부 리더가 잘못 렌더링하는 계층 구조가 생성됩니다.
각 bookmark() 호출은 O(1) 시간에 개요 항목 하나와 목차 항목 하나를 추가하며, 각 위치 읽기(getPage(), getY(), getNumPages())는 렌더링 컨텍스트에 대한 순회 없이 상수 시간 필드 접근으로 처리됩니다. 개요 트리와 목차 페이지는 각각 addTOC()와 save()에서 한 번씩 구체화됩니다. 수백 개의 제목이 있는 보고서도 2000 ms / 64 MB 예산 내에 충분히 들어갑니다. 생성은 프로세스 내에서 실행됩니다. 헤드리스 브라우저도 네트워크 호출도 없습니다.
보안 참고 사항
섹션 제목: “보안 참고 사항”북마크 제목과 목차 페이지는 bookmark()에 전달하는 값을 렌더링합니다. 해당 제목에 런타임 데이터(데이터베이스 행의 장 이름이나 API 필드)가 포함된다면, 리더에 표시되는 다른 값과 마찬가지로 문자열이 bookmark()에 도달하기 전에 길이를 제한하고 정제하십시오. 검증되지 않은 요청 입력으로 제목을 구성하지 마십시오.
엔진은 save()에 전달된 출력 경로를 검증합니다. 스트림 래퍼(scheme://)와 포함된 널 바이트를 거부하고, 부모 디렉터리를 해석하여 경로 탐색을 차단하며, 이 중 하나에라도 해당하면 InvalidConfigException을 발생시킵니다. 직접 제어하는 경로를 전달하여 이 검증이 계속 적용되도록 하십시오. 클라이언트가 제공한 가공하지 않은 파일 이름을 save()에 절대 넘기지 마십시오. InvalidConfigException을 호출자에게 보고할 때는 세부 정보를 서버 측에 기록하고, 해석된 경로 대신 일반적인 메시지를 반환하십시오.
적합성
섹션 제목: “적합성”이 레시피는 자체적인 ISO 32000-2 적합성 주장을 하지 않습니다. 개요 및 목차 의미 체계 - 개요 항목의 트리로서의 문서 개요와 해당 항목에 연결된 대상 - 는 관련 절 인용을 담고 있는 북마크와 목차 추가하기에 설명되어 있습니다. 여기서 다루는 동적 패턴은 작성되는 구조가 아니라 대상 위치가 어디에서 오는지만 변경합니다.
재현성 프로파일 - 구조적. 트레일러 /ID와 날짜 원자는 저장할 때마다 달라지며, 구조적 비교는 이를 제거합니다. 이 페이지는 NextPDF가 실시간 커서로부터 개요와 목차를 어떻게 생성하는지 문서화하며, 포괄적인 표준 적합성 주장을 하지 않습니다.
함께 보기
섹션 제목: “함께 보기”- 북마크와 목차 추가하기 - 이 레시피의 정적 패턴을 다루는 문서
- 내비게이션 모듈
- HasPages concern - 페이지 및 위치 인터페이스
- 여러 페이지 문서 구축하기
- 머리글과 바닥글