ข้ามไปยังเนื้อหา

สร้างสารบัญจากโครงสร้างเอกสารขณะรันไทม์

เนื้อหาของคุณอาจถูกสร้างขึ้นขณะรันไทม์ เช่น บทต่างๆจากฐานข้อมูล ส่วนต่างๆจากการตอบกลับของ 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 ทั้งหมด

สารบัญแบบไดนามิกมีสองส่วนที่ต้องสอดคล้องกัน:

  • ส่วนที่เป็นโครงร่าง (เรียกอีกอย่างว่าบุ๊กมาร์ก): ต้นไม้ที่ผู้อ่านเห็นในแถบนำทางด้านข้าง โดยแต่ละรายการจะกระโดดไปยังตำแหน่งหนึ่งในเอกสาร
  • ส่วนที่เป็นสารบัญที่เรนเดอร์ออกมา: หน้าที่สร้างขึ้นเพื่อแสดงรายการชุดเดียวกันพร้อมหมายเลขหน้า

NextPDF ทำให้ทั้งสองส่วนตรงกันผ่านการเรียกเพียงครั้งเดียว bookmark($title, $level, $y) จะเพิ่มรายการโครงร่างหนึ่งรายการ และ รายการสารบัญหนึ่งรายการ โดยทั้งคู่ผูกกับหน้าปัจจุบันและตำแหน่งแนวตั้งปัจจุบัน คุณจึงไม่ต้องดูแลสองรายการแยกกัน

ส่วนที่เป็นไดนามิกคือ ตำแหน่งมาจากที่ใด สูตรแบบสแตติกจะส่งหัวเรื่องตามตัวอักษรตามลำดับในซอร์ส แต่ในที่นี้ คุณเขียนหัวเรื่อง แล้วถามเอนจินทันทีว่าเคอร์เซอร์อยู่ที่ใด:

  • getPage() จะคืนค่าดัชนีฐานศูนย์ของหน้าที่กำลังใช้งาน ก่อนที่จะเพิ่มหน้าแรก ค่าที่คืนคือ -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 - ระยะขอบที่ใช้งานอยู่ (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 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()->bottom และ getY() แล้วบังคับ addPage() ก่อนกำหนดเมื่อเหลือน้อยกว่าเกณฑ์
  • addTOC() บนเอกสารว่างจะไม่ทำสิ่งใด หากไม่เคยเรียก bookmark() เลย addTOC() จะคืนค่าโดยไม่แทรกหน้า การป้องกันรายงานจากอินพุตว่างจึงไม่จำเป็น แม้ควรทราบไว้ว่าหน้าสารบัญจะไม่ปรากฏ
  • สารบัญถูกเรนเดอร์เพียงครั้งเดียว ณ ตำแหน่งที่คุณแทรกมัน addTOC(pageIndex: 0) จะแทรกสารบัญเป็นหน้าแรก หมายเลขหน้าในรายการที่เรนเดอร์ออกมาจะใช้หน้าที่บันทึกไว้ของแต่ละรายการ ดังนั้นให้แทรกสารบัญหลังจากการเรียก bookmark() ทั้งหมดทำงานเสร็จแล้ว
  • การข้ามระดับดูเหมือนผิดรูปแบบ เพิ่มค่า $level ได้สูงสุดทีละหนึ่งระหว่างบุ๊กมาร์กที่ต่อเนื่องกัน การกระโดดจากระดับ 0 ไปยังระดับ 2 โดยไม่มีระดับ 1 คั่นกลางจะสร้างลำดับชั้นที่โปรแกรมอ่านบางตัวเรนเดอร์ผิด

การเรียก bookmark() แต่ละครั้งจะเพิ่มรายการโครงร่างหนึ่งรายการและรายการสารบัญหนึ่งรายการในเวลา O(1) และการอ่านตำแหน่งแต่ละครั้ง (getPage() getY() getNumPages()) เป็นการเข้าถึงฟิลด์แบบเวลาคงที่บนคอนเท็กซ์การเรนเดอร์ โดยไม่ต้องไล่ผ่านข้อมูล ต้นไม้โครงร่างและหน้าสารบัญแต่ละส่วนถูกสร้างเป็นรูปธรรมเพียงครั้งเดียว: ที่ addTOC() และที่ save() ตามลำดับ รายงานที่มีหัวเรื่องหลายร้อยรายการยังคงอยู่ภายในงบประมาณ 2000 ms / 64 MB ได้อย่างสบาย การสร้างทำงานภายในโปรเซส โดยไม่มีเบราว์เซอร์แบบไร้ส่วนติดต่อและไม่มีการเรียกผ่านเครือข่าย

ชื่อบุ๊กมาร์กและหน้าสารบัญจะเรนเดอร์ค่าที่คุณส่งให้ bookmark() เมื่อชื่อเหล่านั้นมีข้อมูลขณะรันไทม์ เช่น ชื่อบทจากแถวฐานข้อมูลหรือฟิลด์ของ API ให้จำกัดความยาวและทำความสะอาดสตริงก่อนส่งถึง bookmark() เช่นเดียวกับค่าใดๆที่แสดงในโปรแกรมอ่าน อย่าสร้างชื่อจากอินพุตของคำขอที่ยังไม่ได้ตรวจสอบ

เอนจินตรวจสอบเส้นทางเอาต์พุตที่ส่งให้ save() โดยปฏิเสธ stream wrapper (scheme://) และ null byte ที่ฝังอยู่ และแปลงไดเรกทอรีแม่เพื่อสกัดกั้น path traversal โดยจะยก InvalidConfigException ในเงื่อนไขใดเงื่อนไขหนึ่งเหล่านี้ ให้คงการตรวจสอบนั้นไว้ด้วยการส่งเฉพาะเส้นทางที่คุณควบคุม อย่าส่งชื่อไฟล์ดิบที่มาจากไคลเอนต์ให้ save() เป็นอันขาด เมื่อคุณรายงาน InvalidConfigException ไปยังผู้เรียก ให้บันทึกรายละเอียดไว้ฝั่งเซิร์ฟเวอร์และคืนข้อความทั่วไปแทนเส้นทางที่แปลงแล้ว

สูตรนี้ไม่ได้ยืนยันการอ้างความสอดคล้อง ISO 32000-2 ใดๆด้วยตนเอง ความหมายของโครงร่างและสารบัญ รวมถึงโครงร่างเอกสารในฐานะต้นไม้ของรายการโครงร่างและปลายทางที่เชื่อมโยงกับรายการเหล่านั้น อธิบายไว้ใน เพิ่มบุ๊กมาร์กและสารบัญ ซึ่งมีการอ้างอิงข้อกำหนดที่เกี่ยวข้อง รูปแบบไดนามิกในที่นี้เปลี่ยนแปลงเฉพาะ ตำแหน่งปลายทางมาจากที่ใด ไม่ใช่โครงสร้างที่ถูกเขียนลงไป

โปรไฟล์การทำซ้ำได้ - เชิงโครงสร้าง ค่า /ID ในเทรลเลอร์และอะตอมของวันที่จะแปรผันทุกครั้งที่บันทึก การเปรียบเทียบเชิงโครงสร้างจะตัดค่าเหล่านั้นออก หน้านี้บันทึกไว้ว่า NextPDF สร้างโครงร่างและสารบัญจากเคอร์เซอร์แบบสดอย่างไร แต่ไม่ได้ยืนยันการอ้างความสอดคล้องตามมาตรฐานแบบครอบคลุม