跳到內容

合併外部 PDF,或附加既有文件的頁面

當磁碟上有多個 PDF 檔案,而你只需要一份 PDF 時,可以使用這則範例。這則範例使用 Core 的合併介面 NextPDF\Document\PdfMerger,把多份既有文件依序合併起來。你傳入的是原始 PDF 位元組字串。合併器會重新編號每個物件以避免衝突,建立單一頁面樹與單一交叉參照表,並回傳一個 NextPDF\Document\MergeResult,讓你寫入磁碟或串流至用戶端。

同一個介面涵蓋開發者最常用到的三項任務:

  • 合併(Merge) 將一份排序好的 PDF 清單合併為單一文件。
  • 附加(Append) 將第二份 PDF 接到一份基礎 PDF 之後。
  • 前置(Prepend) 頁面:只要把新文件放在輸入順序的最前面即可。

合併會在處理程序內進行,不啟動 headless 瀏覽器,也不發出網路呼叫。你需要安裝 Core(composer require nextpdf/core:^3),以及兩個以上可讀取的 PDF 檔案。

Terminal window
composer require nextpdf/core:^3

PDF 會把頁面組織在一棵頁面樹中,其根節點是一個 /Pages 節點,並透過交叉參照表定位每個間接物件。當你合併兩份來源文件時,它們的物件編號會重疊。兩個檔案幾乎一定都含有一個 1 0 obj 物件、一個 /Catalog,以及一個 /Pages 節點。直接把這些位元組串接起來會產生一個損毀的檔案,因為那些參照不再指向該編號應對應的位置。

PdfMerger 解決了這個問題。它會從每個輸入擷取頁面物件,把每個物件重新編號到單一位址空間,改寫每個頁面的 /Parent 參照,使其指向單一已合併的 /Pages 節點,並輸出單一型錄、單一頁面樹與單一尾段。輸出的是一份結構上全新的文件,而不是把檔案硬接在一起的串接結果。

排序規則很單純:頁面會依其來源檔案在輸入清單中出現的順序排列。若要附加,把基礎文件放在最前面。若要前置,把新文件放在最前面。沒有獨立的前置方法,因為輸入順序就是你唯一需要的控制方式。

new NextPDF\Document\PdfMerger() 提供兩個方法。

  • merge(list<string> $pdfFiles, int $maxFiles = 100, int $maxTotalBytes = 200_000_000): MergeResult 會合併一份排序好的原始 PDF 位元組字串清單。這兩個界限參數會限制檔案數量與輸入總大小。兩者都預設為安全的正式環境值,而你可以依個別工作負載收緊它們。
  • append(string $basePdf, string $appendPdf): MergeResult 是一個便利包裝方法,會依序合併剛好兩份文件。它等同於 merge([$basePdf, $appendPdf])

兩者都會回傳一個 NextPDF\Document\MergeResult。它是一個 readonly 物件,包含 $pdfData(已合併的位元組)、$totalPages$sourceCount$mergedSize,以及 isValid() 輔助方法,用來確認輸出是否以 %PDF 標頭開頭。

輸入是原始位元組字串,而不是檔案路徑。你要自己用 file_get_contents() 讀取檔案(或從物件儲存體取出位元組)。這讓合併器不受檔案系統假設限制,也讓你能合併完全不落地到磁碟的文件。

如果你需要把外部 PDF 的單一頁面匯入成可重複使用的 Form XObject——例如,把信頭頁印在產生的內容背後——請使用跨套件匯入器契約 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);

這是一支可獨立執行的程式。它在記憶體中建立兩份小型文件,所以執行時不需要任何外部檔案;接著合併它們、驗證結果,並寫出輸出。它會捕捉合併介面拋出的那兩個例外,為每一個補上情境後重新拋出,而不是把它吞掉。把記憶體中的輸入換成你自己的 file_get_contents() 讀取(或物件儲存體擷取),再把輸出接到你的回應或儲存層。

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

預期的 STDOUT(頁面總數為各來源頁數的總和,而位元組大小則取決於建置過程):

Merged 3 source(s) into 4 page(s), <n> bytes.
  • 輸入是位元組,不是路徑。 merge() 接受的是原始 PDF 字串。請先用 file_get_contents() 讀取檔案。傳入路徑字串會讓該輸入無法通過 %PDF 標頭檢查,並拋出 PageLayoutException
  • 順序即輸出順序。 頁面會依其來源檔案在清單中出現的順序排列。沒有前置方法:要前置就把新文件放在最前面,要附加就放在最後面。
  • 空清單是錯誤。 空的 $pdfFiles 會拋出 PageLayoutException,而不是回傳空結果。請在呼叫前先驗證這組輸入。
  • 每個輸入都會先經過驗證。 每一筆都必須非空,且以 %PDF 開頭。第一個未通過的輸入會拋出 PageLayoutException,並帶上被違反的限制條件,且不會合併任何內容。
  • 超出界限時拋出例外,而非截斷。 超過 maxFiles 會透過內部資源防護拋出例外,而超過 maxTotalBytes 則拋出 WriterException。合併器絕不會默默丟棄檔案或裁切位元組,所以請為你的工作負載調整這兩個界限。
  • 輸出在結構上是全新的,而非位元組穩定。 已合併的文件會帶有新的型錄、頁面樹與尾段。對同一組輸入執行兩次,結果在結構上相等,但不保證位元組完全相同,因此這則範例宣告的是 structural 可重現性設定檔。
  • 頁面層級的註解與共用資源。 合併會把頁面物件組合到單一頁面樹中。在來源檔案中、存在於頁面物件之外的文件層級結構,不會一併帶入。當你需要把單一頁面連同其資源匯入成可重複使用的圖形時,請走 ImportedFormObjectInterface 路徑,透過 nextpdf/artisan 之類的匯入器處理。

合併的時間複雜度與總頁數成線性關係,且主要由剖析與物件重新編號主導,而不是合併器自身的管理開銷。尖峰記憶體會隨輸入位元組總量增加,因為組裝輸出時,每個來源都會以字串形式留在記憶體中。maxTotalBytes 防護會把這個尖峰控制在界限內。對於高流量管線,請把 maxFilesmaxTotalBytes 設成你的工作負載所需的最小值,讓畸形或過大的批次快速失敗,而不是耗盡記憶體。典型的小型合併會落在 1500 ms 牆鐘時間與 64 MB 尖峰的預算之內。

合併會在處理程序內進行;沒有任何文件位元組離開主機,也不會發出網路呼叫。請把每一份外部 PDF 都當成不可信的輸入:

  • 維持嚴格界限。 maxFilesmaxTotalBytes 是你抵禦阻斷服務輸入的第一道防線。凡是接受上傳的介面,都請把它們設成你真正的上限,而不是寬鬆的預設值。
  • 先驗證再信任。 合併成功只代表位元組被組合在一起,並不代表輸入是安全的。請先讓不可信的輸入通過 Core 的檢查器。請參閱 剖析並檢視 PDF 一節,了解一種受限分流掃描,可在進行更繁重的處理前標記加密、簽章與風險標記。
  • 絕不將使用者輸入插入路徑。 這則範例會寫入固定路徑或 cookbook 旁路通道。請從伺服器控制的值推導輸出路徑,絕不從請求欄位取得,以避免路徑穿越。
  • 文件中不放任何機密。 不要在你要回傳給用戶端的已合併文件中嵌入憑證、權杖或內部識別碼。

這則範例本身不提出任何規範性標準主張。它透過 Core 的合併介面組合既有文件,並以 MergeResult::isValid() 標頭檢查驗證結果。PdfMerger 所重建的頁面樹模型,就是 /modules/core/document/ 參考文件中描述的 PDF 2.0 頁面樹結構。若要對任何輸入或輸出文件做結構性讀取——版本、頁數、加密與簽章旗標——請使用 剖析並檢視 PDF 中記載的 Core 檢查器。