拆分 PDF 并提取页码范围
你有一个 PDF,却需要好几个。本示例用 Core 的拆分接口
NextPDF\Document\PdfSplitter 把单个文档切成多个文件。你把源以原始 PDF
字节串传入,并描述你想要哪些页。拆分器通过对象图解析源、把每个所请求范围的可达对象复制进一个全新、重新编号、带有自己页树和交叉引用表的文档,并交回一些结构上完整、能在符合规范的阅读器中加载的
PDF。
这是 合并示例 的逆操作:合并把多个文档组合成一个,拆分把一个文档分解为多个。同一个接口覆盖你最常需要的三项任务:
- 按范围拆分 —— 为你命名的每个页码范围产生一个输出文档。
- 每 N 页拆分 —— 把一个长文件切成固定大小的分段。
- 提取一个范围 —— 把单个连续的页码范围抽取进一个文档。
拆分在进程内运行,无需无头浏览器或网络调用。你需要安装 Core
(composer require nextpdf/core:^3)以及一个可读的 PDF。
composer require nextpdf/core:^3概念总览
标题为“概念总览”的章节一个 PDF 通过以 /Pages 节点为根的页树定位它的各页,并通过其交叉引用数据(一张表或一个流)触达每个间接对象。你不能靠切割字节来提取页:单个页引用了存放在文件别处的共享字体、图像和资源字典,而交叉引用偏移量将不再有效。
PdfSplitter 做真正的工作。对于每个范围,它从所请求的页对象出发遍历对象图,收集可达对象闭包,把那些对象重新编号进一个全新的地址空间,重建一个单页树文档,并按
PDF 2.0 结构发出一张真正的交叉引用表
(ISO 32000-2:2020,交叉引用表 §7.5.4,页树 §7.7.3)。每个输出都是一个自包含的文档,而不是一个片段。
页码从 1 起算且包含两端。一个范围是一个
NextPDF\Document\PageRange 值对象:new PageRange(2, 5) 表示第 2 到第 5
页。构造函数会校验它自己的不变式 —— 它会通过抛出
NextPDF\Exception\PageLayoutException 来拒绝小于 1 的起点或早于起点的终点 ——
因此一个不可能的范围会在构造时失败,而不是在拆分器深处。PageRange::parse() 和
PageRange::all() 在规格说明畸形或总页数非正时会抛出同样的 PageLayoutException。
API 接口
标题为“API 接口”的章节new NextPDF\Document\PdfSplitter() 暴露三个方法。它们都把源当作原始 PDF
字节串接收,绝不是路径。
split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResult为$ranges中的每个PageRange按顺序产生一个输出文档。两个边界参数限制输入大小和范围数量。splitEvery(string $pdfData, int $pagesPerSegment): SplitResult把文档切成每段$pagesPerSegment页的固定大小分段;最后一段保留余数。extractPages(string $pdfData, PageRange $range): SplitDocument提取单个范围,并直接返回那一个文档。
split() 和 splitEvery() 返回一个 NextPDF\Document\SplitResult,一个
readonly 对象,它携带 $documents(分段的列表)、$totalPages
(源中的页数)和 $sourceSize。它提供 count()、document(int $index)
(按从零起算的索引获取一个分段)以及 totalOutputSize()。
每个分段,以及 extractPages() 的返回值,都是一个
NextPDF\Document\SplitDocument:一个 readonly 对象,暴露 $pdfData(分段字节)、
$range、$pageCount、$sizeBytes 以及 isValid() 辅助方法。
isValid() 是一个狭窄的 %PDF 头部合理性检查 —— 当分段字节以 %PDF
开头时它返回 true —— 而不是文档结构或合规性校验;它确认拆分器产生了一个 PDF,而非该文件完全符合规范。
你可以用 new PageRange($start, $end) 直接构建一个 PageRange,或者用
PageRange::parse('1-3,5,7-10') 解析一段人类可读的规格说明,它会返回一个
list<PageRange>,可直接交给 split()。PageRange::all($totalPages)
返回覆盖整个文档的单个范围。
代码示例 —— 快速开始
标题为“代码示例 —— 快速开始”的章节本示例读取一个文件,并把它拆分成两个文档:第 1 到第 3 页,以及第 4 到第 6 页。它略去了错误处理以展示调用形状;下面的生产示例补上了完整的守护。
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Document\PageRange;use NextPDF\Document\PdfSplitter;
$splitter = new PdfSplitter();
$result = $splitter->split( file_get_contents(__DIR__ . '/report.pdf'), [ new PageRange(1, 3), new PageRange(4, 6), ],);
foreach ($result->documents as $i => $segment) { file_put_contents(__DIR__ . sprintf('/part-%d.pdf', $i + 1), $segment->pdfData);}
printf("Split %d-page source into %d document(s).\n", $result->totalPages, $result->count());代码示例 —— 生产
标题为“代码示例 —— 生产”的章节这个自包含程序在内存中构建一个小的多页文档,因此无需外部文件即可运行。它演示了全部三项操作 ——
按范围拆分、每 N
页拆分,以及提取单个范围。它校验并写出按范围分段和被提取的尾部,并把按大小的结果作为计数报告,这样你能看到每种调用形状,而不必写三个近乎相同的写入循环。它捕获拆分接口抛出的异常,并为每个异常带上上下文重新抛出,而不是把它吞掉。把内存中的源替换为你自己的
file_get_contents() 读取或对象存储抓取。
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use InvalidArgumentException;use NextPDF\Core\Document;use NextPDF\Document\Merge\UnsupportedSourceDocumentException;use NextPDF\Document\PageRange;use NextPDF\Document\PdfSplitter;use NextPDF\Document\SplitDocument;use NextPDF\Exception\PageLayoutException;
/** * Build a tiny labelled multi-page PDF so the program is self-contained. * * In your own code, replace this with a read of the PDF you want to split, * for example file_get_contents($path). */function buildSample(int $pages): string{ $doc = Document::createStandalone(); $doc->setTitle('Split sample');
for ($page = 1; $page <= $pages; $page++) { $doc->addPage(); $doc->setFont('helvetica', '', 12); $doc->cell(0, 10, sprintf('Source page %d', $page), newLine: true); }
return $doc->getPdfData();}
$source = buildSample(7);
$splitter = new PdfSplitter();
try { // 1. Split into named ranges: one output per PageRange, in order. $byRange = $splitter->split( $source, PageRange::parse('1-3,4-6'), maxBytes: 50_000_000, maxRanges: 100, );
// 2. Split every 2 pages: segments of [1-2], [3-4], [5-6], [7] (remainder). $bySize = $splitter->splitEvery($source, 2);
// 3. Extract a single range as one document. $tail = $splitter->extractPages($source, new PageRange(7, 7));} catch (InvalidArgumentException $e) { // Raised on an oversized input, an empty range list, or too many ranges. throw new RuntimeException('Split rejected its input: ' . $e->getMessage(), previous: $e);} catch (PageLayoutException $e) { // Raised when a range exceeds the source page count, and also by the // PageRange constructor / PageRange::parse() on an invalid or malformed range. throw new RuntimeException( sprintf('Range out of bounds (page %d): %s', $e->getPageNumber(), $e->getConstraint()), previous: $e, );} catch (UnsupportedSourceDocumentException $e) { // Raised fail-closed on an encrypted, signed, or form-bearing source. throw new RuntimeException('Source cannot be split: ' . $e->getMessage(), previous: $e);}
printf( "Source has %d page(s). By-range produced %d doc(s); by-size produced %d doc(s).\n", $byRange->totalPages, $byRange->count(), $bySize->count(),);
foreach ($byRange->documents as $i => $segment) { emitSegment(sprintf('range-%d', $i + 1), $segment);}
emitSegment('tail', $tail);
/** * Validate a segment and write it to the cookbook side-channel directory, * or to the script directory by default. */function emitSegment(string $name, SplitDocument $segment): void{ if (!$segment->isValid()) { throw new RuntimeException(sprintf('Segment "%s" failed its %%PDF header check.', $name)); }
$dir = getenv('NEXTPDF_COOKBOOK_OUTPUT'); $dir = $dir !== false && $dir !== '' ? $dir : __DIR__; $path = sprintf('%s/%s.pdf', rtrim($dir, '/'), $name);
if (file_put_contents($path, $segment->pdfData) === false) { throw new RuntimeException(sprintf('Could not write segment to "%s".', $path)); }
printf("Wrote %s: pages %d-%d, %d bytes.\n", $name, $segment->range->start, $segment->range->end, $segment->sizeBytes);}期望的标准输出(字节大小取决于构建):
Source has 7 page(s). By-range produced 2 doc(s); by-size produced 4 doc(s).Wrote range-1: pages 1-3, <n> bytes.Wrote range-2: pages 4-6, <n> bytes.Wrote tail: pages 7-7, <n> bytes.边界情况与注意点
标题为“边界情况与注意点”的章节- 源是字节,不是路径。 每个方法都接收一个原始 PDF 字符串。先用
file_get_contents()读取文件,或从对象存储拉取字节。传入路径会让源解析失败。 - 页码从 1 起算且包含两端。
new PageRange(1, 3)覆盖第 1、2、3 页 —— 共三页。小于 1 的起点或早于起点的终点,会从PageRange构造函数本身抛出PageLayoutException。 - 超出末尾的范围是错误,而非截断。 如果一个范围的终点超过源页数,
split()会抛出PageLayoutException;它绝不会悄悄把范围裁到最后一页。如果你的范围是调用方提供的,先检查页数。 splitEvery()保留余数。 最后一段保留剩下的任意页,因此一个 7 页文档每 2 页拆分会产生四个分段:三个 2 页和一个 1 页。$pagesPerSegment必须至少为 1,否则你会得到一个InvalidArgumentException。- 空范围列表会被拒绝。
split()在$ranges === []时会抛出InvalidArgumentException。在调用它之前至少构建一个范围。 - 边界是抛出而非截断。 超过
maxBytes或maxRanges会抛出InvalidArgumentException。拆分器绝不会部分处理一个超大输入,因此请为你的工作负载调好这两个边界。 - 加密、已签名以及带表单的源会以失败关闭方式处理。 一个加密的源
(没有密钥便无法复制)、一个数字签名的源(重新分页会让签名字节范围失效),或一个带交互式表单的源(某个字段的微件可能位于被丢弃的页上而成为孤儿),都会抛出
UnsupportedSourceDocumentException。拆分器宁可拒绝,也不会发出一个损坏或被破坏的文档。拆分表单文档是本版本的一个已知局限。 UnsupportedSourceDocumentException位于Merge命名空间下。 它的完全限定名是NextPDF\Document\Merge\UnsupportedSourceDocumentException。 拆分页上的那个Merge路径不是复制粘贴错误:它是合并与拆分两个接口在某个源无法被安全复制时都会抛出的、单一共享的源文档拒绝异常。从那个命名空间导入它。- 输出在结构上是全新的,而非字节稳定的。 每个分段都是一个带有自己目录、页树和
trailer 的新文档。对同一输入的两次运行在结构上相等,但不保证逐字节相同 —— 这正是
structural可重现性 profile 的由来。
拆分的开销随所有范围中被复制的页数线性变化。是解析源以及复制每个范围的对象闭包,而非拆分器自身的簿记,主导着工作。源以字符串形式保存在内存中,每个分段的字节会一直保留到你写出它们,因此峰值内存随源大小加上你所产生的最大范围而变化。maxBytes
守护让峰值的源那一侧保持有界。对于高吞吐流水线,把 maxBytes 和
maxRanges 设为你工作负载所需的最小值,这样一个畸形或超大的输入会快速失败,而不是耗尽内存。
安全注意
标题为“安全注意”的章节拆分在进程内运行;没有文档字节离开主机,也不进行网络调用。把每个源 PDF 都当作不受信任的输入:
- 把边界收紧。
maxBytes和maxRanges是你抵御拒绝服务输入的第一道防线。对任何接受上传的接口,把它们设为你真实的上限,而不是宽松的默认值。 - 拆分前先分诊。 一个加密或已签名的源会以失败关闭方式处理,但你可以更早检测到这些情况。先把不受信任的输入跑一遍 Core 检查器。参见 解析并检查 PDF,了解在更重的处理之前标记加密、签名和风险标志的有界扫描。
- 绝不要把用户输入插值进路径。 本示例写入一个固定目录或 cookbook 侧信道。从服务器控制的值派生输出路径和分段名,绝不要从请求字段,以避免路径遍历。
- 输出里不放密钥。 不要把分段文件写到某个位置,或以某个名字,把内部标识符暴露给一个本不该看到它们的客户端。
合规性
标题为“合规性”的章节本示例本身不作任何规范性标准主张。它通过 Core
拆分接口分解一个文档,并用 SplitDocument::isValid() 的
%PDF 头部检查对每个分段做合理性检查 ——
这是一个存在性检查,确认拆分器发出了一个 PDF,而非合规性或文档结构校验。PdfSplitter
为每个分段重建的页树和交叉引用结构,正是 /modules/core/document/
参考中描述的 PDF 2.0 结构
(ISO 32000-2:2020,交叉引用表 §7.5.4,页树 §7.7.3)。要对任何输入或输出文档做结构读取,包括版本、页数、加密和签名标志,请使用
解析并检查 PDF 中记录的 Core 检查器。
- Document 模块参考 —— 完整的拆分、合并和文档部件接口。
- 合并外部 PDF —— 逆操作示例:把多个文档组合成一个。
- 解析并检查 PDF —— 在拆分之前分诊不受信任的输入。
- 异常感知的错误处理
——
PageLayoutException和UnsupportedSourceDocumentException背后的 NextPDF 异常层次。