合并外部 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 文件。
composer require nextpdf/core:^3概念总览
标题为“概念总览”的章节PDF 会把页面组织在一棵页面树中,根节点是一个 /Pages 节点,并通过交叉引用表定位每个间接对象。合并两份源文件时,它们的对象编号会发生重叠。两个文件几乎都会包含一个 1 0 obj 对象、一个 /Catalog,以及一个 /Pages 节点。直接拼接这些字节会生成损坏的文件,因为这些引用不再指向编号声称的位置。
PdfMerger 会解决这个问题。它从每个输入中提取页面对象,将每个对象重新编号到同一个地址空间,改写每个页面的 /Parent 引用,使其指向单一的合并后 /Pages 节点,并输出单一目录、单一页面树和单一尾部。输出结果是一份结构全新的文件,而不是把文件简单拼接在一起的结果。
排序规则很直接:页面会按照源文件在输入列表中的出现顺序排列。要附加,就把基础文件放在最前面。要前置,就把新文件放在最前面。没有独立的前置方法,因为输入顺序就是你需要的唯一控制手段。
API 接口
标题为“API 接口”的章节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 防护会把峰值控制在边界内。对于高流量管线,请将 maxFiles 与 maxTotalBytes 设为你的工作负载所需的最小值,这样畸形或过大的批次会快速失败,而不是耗尽内存。典型的小型合并通常会落在 1500 ms 墙钟时间与 64 MB 峰值的预算之内。
安全性注意事项
标题为“安全性注意事项”的章节合并在进程内完成;没有任何文件字节离开主机,也不会发起网络调用。请将每一份外部 PDF 都视为不可信输入:
- 保持边界收紧。
maxFiles与maxTotalBytes是你抵御拒绝服务输入的第一道防线。凡是接受上传的接口,都请把它们设为真实上限,而不是宽松的默认值。 - 先验证再信任。 合并成功只表示字节已被组合在一起,并不表示输入是安全的。请先让不可信输入通过 Core 的检查器。请参阅 解析并检查 PDF 一节,了解一种在进行更繁重处理前,会标记加密、签名与风险标记的受限分流扫描。
- 绝不要把用户输入插入路径。 本示例会写入固定路径或 cookbook 旁路通道。请从服务器控制的值推导输出路径,绝不要从请求字段取得,以避免路径遍历。
- 文件中不要放入任何机密。 不要在要返回给客户端的合并后文件中嵌入凭据、令牌或内部标识符。
符合性
标题为“符合性”的章节本示例本身不做任何规范性标准主张。它通过 Core 的合并接口组合现有文件,并用 MergeResult::isValid() 文件头检查验证结果。PdfMerger 重建的页面树模型,就是 /modules/core/document/ 参考文档中描述的 PDF 2.0 页面树结构。若要对任何输入或输出文件做结构性读取——版本、页数、加密与签名标志——请使用 解析并检查 PDF 中记录的 Core 检查器。
另请参阅
标题为“另请参阅”的章节- Document 模块参考 —— 完整的拆分、合并和文档部件接口。
- 解析并检查 PDF —— 在合并前先对不可信输入进行分流。
- 异常感知的错误处理 —— NextPDF 中
PageLayoutException与WriterException背后的异常层次结构。 - 构建多页文件 —— 编写你接下来要合并的页面。