跳转到内容

合并外部 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 检查器。