Lewati ke konten

Membuat daftar isi dari struktur dokumen pada runtime

Konten Anda mungkin baru terbentuk pada runtime: bab dari basis data, bagian dari respons Application Programming Interface (API), atau judul dari perulangan yang tidak dapat Anda ketahui sebelumnya. Anda membutuhkan kerangka dokumen dan daftar isi yang dapat diklik yang persis mengikuti konten tersebut, tanpa memelihara daftar manual kedua yang bisa menyimpang dari keadaan sebenarnya.

Resep ini membangun kerangka secara dinamis. Saat menulis setiap judul, Anda membaca posisi kursor dan halaman aktif langsung dari mesin dengan getPage(), getY(), dan getNumPages(), lalu meneruskan nilai-nilai itu ke bookmark(). Penanda terikat pada posisi yang Anda baca saat itu, sehingga kerangka mengikuti konten bahkan ketika pemutus halaman jatuh di tempat yang tidak terduga. Di akhir, addTOC() merender halaman daftar isi aktual dari entri yang sama.

Prasyarat: instalasi Core (composer require nextpdf/core:^3) dan konten yang struktur judulnya Anda temukan saat menulis, bukan sebelumnya.

Halaman ini membahas pola dinamis berbasis posisi. Untuk kasus statis, ketika Anda sudah mengetahui setiap judul dan tingkatnya sejak awal, baca Menambahkan penanda dan daftar isi terlebih dahulu. Resep ini menggunakan permukaan bookmark() dan addTOC() yang sama dan tidak mengulang dasar-dasar tersebut.

Terminal window
composer require nextpdf/core:^3

Anda tidak memerlukan ekstensi opsional apa pun. Permukaan navigasi (bookmark(), addTOC()) dan pengakses posisi (getPage(), getY(), getNumPages()) telah stabil sejak 1.2.0 dan berjalan di seluruh matriks backport 8.1 hingga 8.4.

Daftar isi dinamis memiliki dua bagian yang harus selaras:

  • Kerangka (disebut juga penanda): pohon yang dilihat pembaca di bilah sisi navigasi, tempat setiap entri melompat ke suatu posisi dalam dokumen.
  • Daftar isi yang dirender: halaman yang dihasilkan yang mencantumkan entri yang sama beserta nomor halamannya.

NextPDF menjaga keduanya tetap selaras melalui satu panggilan. bookmark($title, $level, $y) menambahkan satu item kerangka dan satu entri daftar isi, dan keduanya terikat pada halaman serta posisi vertikal saat ini. Anda tidak memelihara dua daftar.

Bagian dinamisnya adalah dari mana posisi itu berasal. Resep statis meneruskan judul literal sesuai urutan sumbernya. Di sini, Anda menulis sebuah judul, lalu langsung menanyakan ke mesin di mana posisi kursor berada:

  • getPage() mengembalikan indeks halaman aktif yang dimulai dari nol. Sebelum halaman pertama ditambahkan, fungsi ini mengembalikan -1.
  • getNumPages() mengembalikan jumlah total halaman, termasuk halaman aktif yang belum di-flush.
  • getY() mengembalikan kursor vertikal saat ini dalam satuan pengguna, diukur sebagai jarak dari bagian atas halaman.
  • getX(), getPageHeight(), dan getMargins() melengkapi konteks ketika Anda perlu memutuskan apakah sebuah judul dan baris pertama teks isinya muat bersama.

Baca nilai-nilai itu, lalu panggil bookmark(). Pemutusan halaman otomatis dapat memindahkan kursor ke halaman baru di antara dua judul, sehingga membaca kembali posisinya menjaga tujuan kerangka tetap di halaman yang benar.

Urutan pemanggilan adalah kunci pola ini: panggil bookmark() tepat di titik tempat Anda menginginkan tujuannya, yaitu segera sebelum Anda merender teks judul. Jika Anda menulis judul terlebih dahulu dan memberi penanda sesudahnya, getY() yang tercatat berada tepat di bawah judul.

Resep ini mengandalkan metode \NextPDF\Core\Document berikut:

  • bookmark(string $title, int $level = 0, float $y = -1): static - menambahkan item kerangka dan entri daftar isi pada $level, terikat pada halaman saat ini. Dengan $y = -1 tujuannya adalah Y kursor saat ini; teruskan Y non-negatif untuk menyematkan tujuan yang presisi.
  • addTOC(int $pageIndex = 0, string $title = ''): static - merender halaman daftar isi dari entri yang terkumpul dan menyisipkannya pada $pageIndex. Mengembalikan tanpa menyisipkan halaman ketika tidak ada penanda.
  • getPage(): int - indeks halaman aktif yang dimulai dari nol (-1 sebelum halaman pertama).
  • getNumPages(): int - jumlah total halaman, termasuk halaman aktif yang belum di-flush.
  • getY(): float - Y kursor saat ini dalam satuan pengguna (jarak dari bagian atas halaman).
  • getX(): float - X kursor saat ini dalam satuan pengguna.
  • getPageHeight(): float - tinggi halaman saat ini dalam satuan pengguna.
  • getMargins(): \NextPDF\ValueObjects\Margin - margin aktif (top, right, bottom, left).
  • setY(float $y): static - memindahkan kursor ke Y yang eksplisit.
  • setAutoPageBreak(bool $enabled, float $margin = 20): static - mengontrol pemutusan halaman otomatis dan ambang margin bawahnya.

Contoh ini menulis tiga bagian dari daftar runtime. Setiap iterasi membaca halaman aktif dengan getPage() sebelum menambahkan penanda, sehingga tujuan kerangka tetap benar setelah pemutusan halaman otomatis.

<?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');

Keluaran terminal yang diharapkan, dengan satu baris per bagian:

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

Versi ini membangun kerangka dua tingkat (bab dan bagian) dari struktur runtime bersarang. Versi ini memastikan judul tetap bersama baris pertama isinya dengan membaca posisi sebelum menulis, dan membungkus proses pembuatan dalam blok try/catch untuk eksepsi NextPDF yang paling spesifik. PageLayoutException mencakup kegagalan pada tahap pembuatan, seperti melampaui batas atas halaman. save() memunculkan InvalidConfigException untuk jalur keluaran yang tidak dapat ditulis atau tidak aman.

<?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() mengembalikan -1 sebelum halaman pertama. Tambahkan halaman pertama sebelum Anda membaca posisinya atau memanggil bookmark(). Contoh pada halaman ini menambahkan halaman di awal.
  • Tambahkan penanda sebelum judul, bukan sesudahnya. bookmark() dengan $y = -1 mencatat getY() saat ini. Panggil segera sebelum Anda merender judul agar tujuannya jatuh pada judul, bukan pada baris di bawahnya.
  • Pemutusan halaman otomatis memindahkan tujuan. Ketika setAutoPageBreak() aktif, panggilan cell() atau multiCell() dapat di-flush ke halaman baru. Baca getPage() lagi pada iterasi berikutnya alih-alih menyimpannya dalam cache. Tujuan mengikuti konten karena bookmark() membaca posisi langsung setiap kali.
  • Sisakan ruang agar judul dan baris pertamanya tetap bersama. Judul yang muat di kaki halaman sementara isinya membungkus ke halaman berikutnya sulit dibaca. Contoh produksi menghitung tinggi tersisa dari getPageHeight(), getMargins()->bottom, dan getY(), lalu memaksa addPage() lebih awal ketika sisanya kurang dari ambang batas.
  • addTOC() pada dokumen kosong tidak melakukan apa pun. Jika tidak ada panggilan bookmark() yang dijalankan, addTOC() kembali tanpa menyisipkan halaman. Karena itu, Anda tidak perlu menambahkan pengaman khusus untuk laporan dengan masukan kosong, tetapi perlu diingat bahwa halaman daftar isi tidak akan muncul.
  • Daftar isi dirender satu kali, pada posisi tempat Anda menyisipkannya. addTOC(pageIndex: 0) menyisipkan daftar isi sebagai halaman pertama. Nomor halaman pada entri yang dirender menggunakan halaman yang tercatat untuk masing-masing entri, jadi sisipkan daftar isi setelah setiap panggilan bookmark() dijalankan.
  • Lompatan tingkat terlihat keliru. Naikkan $level paling banyak satu antar penanda berurutan. Melompat dari tingkat 0 ke tingkat 2 tanpa tingkat 1 di antaranya menghasilkan hierarki yang dirender secara salah oleh sebagian pembaca.

Setiap panggilan bookmark() menambahkan satu item kerangka dan satu entri daftar isi dalam waktu O(1), dan setiap pembacaan posisi (getPage(), getY(), getNumPages()) adalah akses field waktu-konstan pada konteks renderer, tanpa penelusuran. Pohon kerangka dan halaman daftar isi masing-masing dimaterialisasikan satu kali: di addTOC() dan di save() secara berurutan. Laporan dengan ratusan judul tetap berada jauh di dalam anggaran 2000 ms / 64 MB. Proses pembuatan berjalan di dalam proses, tanpa peramban headless dan tanpa panggilan jaringan.

Judul penanda dan halaman daftar isi merender nilai yang Anda teruskan ke bookmark(). Ketika judul-judul itu membawa data runtime, seperti nama bab dari baris basis data atau field API, batasi panjang dan bersihkan string tersebut sebelum diteruskan ke bookmark(), persis seperti yang Anda lakukan terhadap nilai apa pun yang ditampilkan kepada pembaca. Jangan membangun judul dari masukan permintaan yang belum divalidasi.

Mesin memvalidasi jalur keluaran yang diteruskan ke save(): mesin menolak stream wrapper (scheme://) dan bita nol tertanam, serta mengurai direktori induk untuk memblokir penjelajahan jalur, memunculkan InvalidConfigException pada salah satu kondisi tersebut. Jaga validasi itu tetap berfungsi dengan meneruskan jalur yang Anda kendalikan; jangan pernah memberikan save() nama berkas mentah yang dipasok klien. Ketika Anda melaporkan InvalidConfigException ke pemanggil, catat detailnya di sisi server dan kembalikan pesan generik alih-alih jalur yang telah diuraikan.

Resep ini tidak membuat klaim konformitas ISO 32000-2 tersendiri. Semantik kerangka dan daftar isi, termasuk kerangka dokumen sebagai pohon item kerangka dan tujuan yang terkait dengan item tersebut, dijelaskan di Menambahkan penanda dan daftar isi, yang memuat kutipan klausa yang relevan. Pola dinamis di sini hanya mengubah dari mana posisi tujuan berasal, bukan struktur yang ditulis.

Profil reproduksibilitas - struktural. Atom /ID dan tanggal pada trailer bervariasi pada setiap penyimpanan; perbandingan struktural menghapus nilai-nilai tersebut. Halaman ini mendokumentasikan bagaimana NextPDF menghasilkan kerangka dan daftar isi dari kursor langsung; halaman ini tidak menyatakan klaim konformitas standar menyeluruh.