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

ผสาน PDF ภายนอกหรือเพิ่มหน้าจากเอกสารที่มีอยู่

เมื่อมีไฟล์ PDF หลายไฟล์อยู่บนดิสก์และต้องการรวมให้เหลือ PDF เพียงไฟล์เดียว สูตรนี้จะรวมเอกสารที่มีอยู่เข้าด้วยกันแบบครบวงจรผ่านส่วนเชื่อมต่อการผสานของ Core ซึ่งก็คือ NextPDF\Document\PdfMerger โดยส่งสตริงไบต์ PDF แบบ raw เข้าไป ตัวผสานจะกำหนดหมายเลขใหม่ให้ทุกอ็อบเจ็กต์เพื่อหลีกเลี่ยงการชนกัน สร้าง page tree หนึ่งชุดและตาราง cross-reference หนึ่งตาราง แล้วคืนค่า NextPDF\Document\MergeResult ซึ่งสามารถเขียนลงดิสก์หรือสตรีมไปยังไคลเอนต์ได้

ส่วนเชื่อมต่อเดียวกันนี้รองรับงานหลักสามอย่างที่ใช้บ่อยที่สุด:

  • ผสาน (Merge) รายการ PDF ที่เรียงลำดับไว้ให้เป็นเอกสารเดียว
  • ต่อท้าย (Append) ไฟล์ PDF ที่สองต่อหลัง PDF หลัก
  • ต่อหน้า (Prepend) หน้าเอกสารโดยจัดเอกสารใหม่ไว้เป็นลำดับแรกในลำดับอินพุต

การผสานทำงานภายในกระบวนการโดยไม่ใช้ headless browser หรือการเรียกผ่านเครือข่าย ต้องติดตั้ง Core (composer require nextpdf/core:^3) และมีไฟล์ PDF ที่อ่านได้ตั้งแต่สองไฟล์ขึ้นไป

Terminal window
composer require nextpdf/core:^3

PDF จัดระเบียบหน้าเอกสารไว้ใน page tree ที่มีโหนดรากเป็น /Pages และระบุตำแหน่งของแต่ละ indirect object ผ่านตาราง cross-reference เมื่อรวมเอกสารต้นทางสองฉบับเข้าด้วยกัน หมายเลขอ็อบเจ็กต์ของทั้งสองฉบับจะทับซ้อนกัน แทบทุกครั้งทั้งสองไฟล์จะมีอ็อบเจ็กต์ 1 0 obj โหนด /Catalog และโหนด /Pages หากเพียงนำไบต์มาต่อกัน จะได้ไฟล์ที่เสียหายเพราะการอ้างอิงต่างๆ ไม่ชี้ไปยังอ็อบเจ็กต์ที่ระบุไว้อีกต่อไป

PdfMerger จัดการการทับซ้อนนี้ ตัวผสานจะดึงอ็อบเจ็กต์ของหน้าออกมาจากแต่ละอินพุต กำหนดหมายเลขใหม่ให้ทุกอ็อบเจ็กต์อยู่ใน address space เดียว เขียนการอ้างอิง /Parent ของแต่ละหน้าใหม่ให้ชี้ไปยังโหนด /Pages ที่ผสานแล้วเพียงโหนดเดียว แล้วสร้าง catalog, page tree และ trailer อย่างละหนึ่งชุด ผลลัพธ์จึงเป็นเอกสารที่สร้างโครงสร้างขึ้นใหม่ทั้งหมด ไม่ใช่ไฟล์ที่นำมาต่อกันแบบเย็บรวม

กฎการเรียงลำดับเรียบง่าย: หน้าเอกสารจะปรากฏตามลำดับเดียวกับไฟล์ต้นทางในรายการอินพุต หากต้องการต่อท้าย ให้จัดเอกสารหลักไว้เป็นลำดับแรก หากต้องการต่อหน้า ให้จัดเอกสารใหม่ไว้เป็นลำดับแรก ไม่มีเมธอด prepend แยกต่างหากเพราะลำดับอินพุตเป็นตัวควบคุมเพียงอย่างเดียวที่จำเป็น

new NextPDF\Document\PdfMerger() มีเมธอดให้ใช้สองรายการ

  • merge(list<string> $pdfFiles, int $maxFiles = 100, int $maxTotalBytes = 200_000_000): MergeResult รวมรายการสตริงไบต์ PDF แบบ raw ที่เรียงลำดับไว้ พารามิเตอร์สำหรับกำหนดขอบเขตสองตัวจะจำกัดจำนวนไฟล์และขนาดอินพุตรวม ทั้งสองตัวมีค่าเริ่มต้นที่ปลอดภัยสำหรับการใช้งานจริง ควรปรับให้รัดกุมยิ่งขึ้นตามภาระงานแต่ละประเภท
  • append(string $basePdf, string $appendPdf): MergeResult เป็นตัวห่อหุ้มเพื่อความสะดวกที่รวมเอกสารสองฉบับตามลำดับนั้นโดยตรง มีค่าเทียบเท่ากับ merge([$basePdf, $appendPdf]) ทุกประการ

ทั้งสองเมธอดคืนค่า NextPDF\Document\MergeResult ซึ่งเป็นอ็อบเจ็กต์แบบ readonly ที่บรรจุ $pdfData (ไบต์ที่ผสานแล้ว) $totalPages $sourceCount $mergedSize และตัวช่วย isValid() ที่ตรวจว่าผลลัพธ์ขึ้นต้นด้วยส่วนหัว %PDF

อินพุตเป็นสตริงไบต์แบบ raw ไม่ใช่พาธของไฟล์ ให้อ่านไฟล์เองด้วย file_get_contents() หรือดึงไบต์มาจาก object storage วิธีนี้ทำให้ตัวผสานไม่ผูกติดกับสมมติฐานเกี่ยวกับระบบไฟล์ และช่วยให้ผสานเอกสารที่ไม่เคยแตะดิสก์ได้

หากต้องการนำเข้าหน้าเดียวจาก PDF ภายนอกในรูปของ Form XObject ที่นำกลับมาใช้ซ้ำได้ เช่น เพื่อประทับหน้าหัวจดหมายไว้ด้านหลังเนื้อหาที่สร้างขึ้น ให้ใช้สัญญา (contract) ของตัวนำเข้าข้ามแพ็กเกจ NextPDF\Contracts\ImportedFormObjectInterface ที่ตัวนำเข้าอย่าง nextpdf/artisan นำไปใช้งาน สำหรับการประกอบเอกสารทั้งฉบับและหน้าทั้งหน้า ให้ใช้ส่วนเชื่อมต่อ PdfMerger ที่ระบุไว้ในที่นี้

ตัวอย่างนี้อ่านไฟล์สองไฟล์แล้วเขียนผลลัพธ์ที่ผสานแล้วออกมา ตัวอย่างนี้เว้นการจัดการข้อผิดพลาดไว้เพื่อแสดงรูปแบบการเรียกใช้ ตัวอย่างสำหรับการใช้งานจริงด้านล่างเพิ่มมาตรการป้องกันไว้ครบถ้วน

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Document\PdfMerger;
$merger = new PdfMerger();
$result = $merger->merge([
file_get_contents(__DIR__ . '/cover.pdf'),
file_get_contents(__DIR__ . '/body.pdf'),
file_get_contents(__DIR__ . '/appendix.pdf'),
]);
file_put_contents(__DIR__ . '/combined.pdf', $result->pdfData);
printf("Merged %d source(s) into %d page(s).\n", $result->sourceCount, $result->totalPages);

โปรแกรมที่ทำงานได้ครบในตัวเองนี้สร้างเอกสารขนาดเล็กสองฉบับในหน่วยความจำ จึงทำงานได้โดยไม่ต้องใช้ไฟล์ภายนอก โปรแกรมจะผสานเอกสารเหล่านั้น ตรวจสอบความถูกต้องของผลลัพธ์ แล้วเขียนผลลัพธ์ออกมา โปรแกรมจะดักจับ exception สองตัวที่ส่วนเชื่อมต่อการผสานยกขึ้น แล้วส่งต่อ exception แต่ละตัวพร้อมบริบทแทนที่จะกลืนไว้ ให้แทนที่อินพุตในหน่วยความจำด้วยการอ่าน file_get_contents() ของคุณเองหรือการดึงจาก object storage และเชื่อมผลลัพธ์เข้ากับเลเยอร์การตอบสนองหรือการจัดเก็บของคุณ

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Document\MergeResult;
use NextPDF\Document\PdfMerger;
use NextPDF\Exception\PageLayoutException;
use NextPDF\Exception\WriterException;
/**
* Build a tiny labelled PDF so the program is self-contained.
*
* In your own code, replace calls to this helper with reads of the external
* PDFs you want to combine, for example file_get_contents($path).
*/
function buildSample(string $label, int $pages): string
{
$doc = Document::createStandalone();
$doc->setTitle($label);
for ($page = 1; $page <= $pages; $page++) {
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 10, sprintf('%s - page %d', $label, $page), newLine: true);
}
return $doc->getPdfData();
}
// Validate the input set before touching the merger. An empty set is a
// configuration error, not an empty success.
/** @var list<string> $sources Raw PDF byte strings, in output order. */
$sources = [
buildSample('Cover', 1), // first in the list -> first in the output (prepend position)
buildSample('Body', 2),
buildSample('Appendix', 1), // last in the list -> appended after the body
];
if ($sources === []) {
throw new RuntimeException('No source PDFs supplied to merge.');
}
$merger = new PdfMerger();
try {
// Bound the merge deliberately: at most 50 files, 100 MB total input.
$result = $merger->merge($sources, maxFiles: 50, maxTotalBytes: 100_000_000);
} catch (PageLayoutException $e) {
// Raised when the list is empty or an input does not begin with %PDF.
throw new RuntimeException(
sprintf('Merge rejected an input: %s', $e->getConstraint()),
previous: $e,
);
} catch (WriterException $e) {
// Raised when the total input size exceeds the configured byte cap.
throw new RuntimeException(
sprintf('Merge exceeded its size budget at stage "%s".', $e->getWriterState()),
previous: $e,
);
}
if (!$result->isValid()) {
throw new RuntimeException('Merged output failed its structural header check.');
}
emitResult($result);
/**
* Write the merged document to the cookbook side-channel, or to a default file.
*/
function emitResult(MergeResult $result): void
{
printf(
"Merged %d source(s) into %d page(s), %d bytes.\n",
$result->sourceCount,
$result->totalPages,
$result->mergedSize,
);
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$path = $out !== false && $out !== '' ? $out : __DIR__ . '/combined.pdf';
if (file_put_contents($path, $result->pdfData) === false) {
throw new RuntimeException(sprintf('Could not write merged PDF to "%s".', $path));
}
}

เอาต์พุตมาตรฐานที่คาดไว้ (จำนวนหน้ารวมคือผลรวมของจำนวนหน้าจากแต่ละแหล่งที่มา และขนาดไบต์ขึ้นอยู่กับการ build):

Merged 3 source(s) into 4 page(s), <n> bytes.
  • อินพุตเป็นไบต์ ไม่ใช่พาธ merge() รับสตริง PDF แบบ raw ให้อ่านไฟล์ด้วย file_get_contents() ก่อน การส่งสตริงที่เป็นพาธเข้าไปจะทำให้อินพุตไม่ผ่านการตรวจสอบส่วนหัว %PDF และยกข้อยกเว้น PageLayoutException
  • ลำดับคือลำดับของเอาต์พุต หน้าเอกสารจะปรากฏตามลำดับที่ไฟล์ต้นทางปรากฏในรายการ ไม่มีเมธอด prepend: หากต้องการต่อหน้าให้จัดเอกสารใหม่ไว้เป็นลำดับแรก หรือไว้ลำดับสุดท้ายหากต้องการต่อท้าย
  • รายการว่างเป็นข้อผิดพลาด $pdfFiles ที่ว่างเปล่าจะยกข้อยกเว้น PageLayoutException ไม่ใช่คืนผลลัพธ์เปล่า ตรวจสอบความถูกต้องของชุดข้อมูลก่อนเรียกใช้ตัวผสาน
  • อินพุตทุกตัวได้รับการตรวจสอบความถูกต้องตั้งแต่ต้น แต่ละรายการต้องไม่ว่างและขึ้นต้นด้วย %PDF อินพุตตัวแรกที่ไม่ผ่านจะยกข้อยกเว้น PageLayoutException พร้อมข้อจำกัดที่ถูกละเมิด และไม่มีการผสานใดๆ เกิดขึ้น
  • ขอบเขตจะยกข้อยกเว้นแทนที่จะตัดทอน การเกิน maxFiles จะยกข้อยกเว้นผ่านตัวป้องกันทรัพยากรภายใน และการเกิน maxTotalBytes จะยกข้อยกเว้น WriterException ตัวผสานจะไม่ทิ้งไฟล์หรือตัดไบต์ทิ้งไปอย่างเงียบๆ ดังนั้นจึงควรปรับขอบเขตทั้งสองให้เหมาะกับภาระงานของคุณ
  • ผลลัพธ์มีโครงสร้างขึ้นใหม่ ไม่เสถียรในระดับไบต์ เอกสารที่ผสานแล้วมี catalog, page tree และ trailer ชุดใหม่ การรันสองครั้งกับอินพุตเดียวกันจะมีโครงสร้างเหมือนกัน แต่ไม่รับประกันว่าจะเหมือนกันในระดับไบต์ ด้วยเหตุนี้สูตรนี้จึงประกาศโปรไฟล์การทำซ้ำแบบ structural เอาไว้
  • คำอธิบายประกอบระดับหน้าและทรัพยากรที่ใช้ร่วมกัน การผสานจะประกอบอ็อบเจ็กต์ของหน้าเอกสารให้อยู่ในทรีเดียว โครงสร้างระดับเอกสารที่อยู่นอกอ็อบเจ็กต์ของหน้าในไฟล์ต้นทางจะไม่ถูกนำติดมาด้วย เมื่อต้องการนำเข้าหน้าเดียวในรูปของกราฟิกที่นำกลับมาใช้ซ้ำได้พร้อมทรัพยากรของหน้านั้น ให้ใช้เส้นทาง ImportedFormObjectInterface ผ่านตัวนำเข้าอย่าง nextpdf/artisan เป็นต้น

การผสานใช้เวลาเชิงเส้นตามจำนวนหน้ารวม การแจงอ็อบเจ็กต์และการกำหนดหมายเลขใหม่เป็นงานหลัก ไม่ใช่การจัดการระเบียนภายในของตัวผสานเอง หน่วยความจำสูงสุดแปรผันตามจำนวนไบต์อินพุตรวม เพราะแหล่งที่มาทุกแหล่งถูกเก็บไว้ในหน่วยความจำในรูปของสตริงระหว่างที่ประกอบเอาต์พุต ตัวป้องกัน maxTotalBytes ช่วยจำกัดขอบเขตของจุดสูงสุดดังกล่าว สำหรับไปป์ไลน์ที่มีปริมาณงานสูง ให้ตั้งค่า maxFiles และ maxTotalBytes ให้เป็นค่าน้อยที่สุดเท่าที่ภาระงานของคุณจำเป็น เพื่อให้ชุดงานที่ผิดรูปแบบหรือมีขนาดใหญ่เกินไปล้มเหลวอย่างรวดเร็วแทนที่จะใช้หน่วยความจำจนหมด โดยทั่วไปการผสานขนาดเล็กจะอยู่ภายในงบเวลา wall 1500 ms และหน่วยความจำสูงสุด 64 MB

การผสานทำงานภายในกระบวนการ ไบต์ของเอกสารไม่ออกไปนอกโฮสต์ และไม่มีการเรียกผ่านเครือข่าย ให้ถือว่า PDF ภายนอกทุกไฟล์เป็นอินพุตที่ไม่น่าเชื่อถือ:

  • รักษาขอบเขตให้รัดกุม maxFiles และ maxTotalBytes เป็นแนวป้องกันด่านแรกของคุณต่ออินพุตที่ก่อ denial-of-service สำหรับส่วนเชื่อมต่อใดก็ตามที่รับการอัปโหลด ให้ตั้งค่าทั้งสองเป็นเพดานจริงของระบบ ไม่ใช่ค่าเริ่มต้นที่ผ่อนปรน
  • ตรวจสอบความถูกต้องก่อนจะเชื่อถือ การผสานที่สำเร็จหมายความว่าไบต์ถูกนำมารวมกันแล้ว ไม่ได้หมายความว่าอินพุตปลอดภัย ให้ส่งอินพุตที่ไม่น่าเชื่อถือผ่าน Core inspector ก่อน ดู การแจงและตรวจสอบ PDF สำหรับการสแกนคัดกรองแบบมีขอบเขตที่ตั้งค่าสถานะการเข้ารหัสลับ ลายเซ็น และเครื่องหมายความเสี่ยงก่อนการประมวลผลที่หนักกว่า
  • ห้ามนำอินพุตของผู้ใช้มาแทรกในพาธ สูตรนี้เขียนไปยังพาธคงที่หรือ side-channel ของ cookbook ให้กำหนดพาธเอาต์พุตจากค่าที่เซิร์ฟเวอร์ควบคุม ไม่ใช่จากฟิลด์ในคำขอ เพื่อหลีกเลี่ยงการข้ามพาธ (path traversal)
  • ห้ามมีข้อมูลลับในเอกสาร อย่าฝังข้อมูลรับรอง โทเค็น หรือตัวระบุภายในไว้ในเอกสารที่ผสานแล้วซึ่งส่งคืนไปยังไคลเอนต์

สูตรนี้ไม่ได้กล่าวอ้างความสอดคล้องเชิงบรรทัดฐานกับมาตรฐานใดด้วยตัวเอง สูตรนี้ประกอบเอกสารที่มีอยู่ผ่านส่วนเชื่อมต่อการผสานของ Core และตรวจสอบความถูกต้องของผลลัพธ์ด้วยการตรวจสอบส่วนหัว MergeResult::isValid() แบบจำลอง page tree ที่ PdfMerger สร้างขึ้นใหม่คือโครงสร้าง page tree ของ PDF 2.0 ที่อธิบายไว้ในเอกสารอ้างอิง /modules/core/document/ สำหรับการอ่านโครงสร้างของเอกสารอินพุตหรือเอาต์พุตใดๆ รวมถึงเวอร์ชัน จำนวนหน้า การเข้ารหัสลับ และแฟล็กลายเซ็น ให้ใช้ Core inspector ที่ระบุไว้ใน การแจงและตรวจสอบ PDF เพื่อจุดประสงค์ดังกล่าว