Split a PDF and extract page ranges
At a glance
Section titled “At a glance”You have one PDF, and you need several. This recipe carves a single document
into multiple files with the Core split surface,
NextPDF\Document\PdfSplitter. You pass the source as a raw PDF byte string and
describe which pages you want. The splitter parses the source through the object
graph, copies each requested range’s reachable objects into a fresh, renumbered
document with its own page tree and cross-reference table, and hands back
structurally complete PDFs that load in a conforming reader.
This is the inverse of the merge recipe: merge composes many documents into one, split decomposes one document into many. The same surface covers the three tasks you need most often:
- Split by ranges — produce one output document per page range you name.
- Split every N pages — chop a long file into fixed-size segments.
- Extract a range — pull a single contiguous page range into one document.
The split runs in process, without a headless browser or a network call. You
need Core installed (composer require nextpdf/core:^3) and one readable PDF.
Install
Section titled “Install”composer require nextpdf/core:^3Conceptual overview
Section titled “Conceptual overview”A PDF locates its pages through a page tree rooted at a /Pages node, and it
reaches every indirect object through its cross-reference data (a table or a
stream). You cannot extract
pages by slicing bytes: a single page references shared fonts, images, and
resource dictionaries that live elsewhere in the file, and the cross-reference
offsets would no longer be valid.
PdfSplitter does the real work. For each range it walks the object graph from
the requested page objects, collects the reachable object closure, renumbers
those objects into a fresh address space, rebuilds a single-page-tree document,
and emits a real cross-reference table per the PDF 2.0 structure
(ISO 32000-2:2020, cross-reference table §7.5.4, page tree §7.7.3). Each output
is a self-contained document, not a fragment.
Page numbers are 1-based and inclusive. A range is a
NextPDF\Document\PageRange value object: new PageRange(2, 5) means pages 2
through 5. The constructor validates its own invariants — it rejects a start
below 1 or an end before the start by raising
NextPDF\Exception\PageLayoutException — so an impossible range fails at
construction, not deep inside the splitter. PageRange::parse() and
PageRange::all() raise the same PageLayoutException on a malformed
specification or a non-positive page total.
API surface
Section titled “API surface”new NextPDF\Document\PdfSplitter() exposes three methods. All take the source
as a raw PDF byte string, never a path.
split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResultproduces one output document perPageRangein$ranges, in order. The two bound parameters cap the input size and the range count.splitEvery(string $pdfData, int $pagesPerSegment): SplitResultchops the document into fixed-size segments of$pagesPerSegmentpages each; the last segment holds the remainder.extractPages(string $pdfData, PageRange $range): SplitDocumentextracts a single range and returns that one document directly.
split() and splitEvery() return a NextPDF\Document\SplitResult, a
readonly object that carries $documents (a list of segments), $totalPages
(pages in the source), and $sourceSize. It offers count(), document(int $index)
to fetch a segment by zero-based index, and totalOutputSize().
Each segment, and the return value of extractPages(), is a
NextPDF\Document\SplitDocument: a readonly object exposing $pdfData (the
segment bytes), $range, $pageCount, $sizeBytes, and the isValid() helper.
isValid() is a narrow %PDF-header quick check — it returns true when the
segment bytes start with %PDF — not a document-structure or conformance
validation; it confirms the splitter produced a PDF, not that the file is fully
conformant.
You build a PageRange directly with new PageRange($start, $end), or parse a
human-readable specification with PageRange::parse('1-3,5,7-10'), which returns
a list<PageRange> ready to hand to split(). PageRange::all($totalPages)
returns a single range covering the whole document.
Code sample — Quick start
Section titled “Code sample — Quick start”This sample reads one file and splits it into two documents: pages 1 through 3, and pages 4 through 6. It leaves out error handling to show the call shape; the production sample below adds the full guards.
<?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());Code sample — Production
Section titled “Code sample — Production”This self-contained program builds one small multi-page document in memory, so
it runs without an external file. It demonstrates all three operations — split
by ranges, split every N pages, and extract a single range. It validates and
writes the by-range segments and the extracted tail, and reports the by-size
result as a count, so you see each call shape without three near-identical write
loops. It catches the exceptions the split surface raises and rethrows each with
context instead of swallowing it. Replace the in-memory source with your own
file_get_contents() read or object-storage fetch.
<?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);}Expected standard output (byte sizes depend on the build):
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.Edge cases & gotchas
Section titled “Edge cases & gotchas”- The source is bytes, not a path. Every method takes a raw PDF string. Read
the file with
file_get_contents()first, or pull the bytes from object storage. Passing a path makes the source fail to parse. - Page numbers are 1-based and inclusive.
new PageRange(1, 3)covers pages 1, 2, and 3 — three pages. A start below 1 or an end before the start raisesPageLayoutExceptionfrom thePageRangeconstructor itself. - A range past the end is an error, not a clamp. If a range’s end exceeds the
source page count,
split()raisesPageLayoutException; it never silently trims the range to the last page. Inspect the page count first if your ranges are caller-supplied. splitEvery()keeps the remainder. The last segment holds whatever pages are left over, so a 7-page document split every 2 pages yields four segments: three of 2 pages and one of 1.$pagesPerSegmentmust be at least 1, or you get anInvalidArgumentException.- An empty range list is rejected.
split()with$ranges === []raisesInvalidArgumentException. Build at least one range before you call it. - Bounds raise rather than truncate. Exceeding
maxBytesormaxRangesraisesInvalidArgumentException. The splitter never partially processes an oversized input, so tune both bounds for your workload. - Encrypted, signed, and form-bearing sources fail closed. An encrypted
source (it cannot be copied without the key), a digitally-signed source
(re-paginating would invalidate the signature byte range), or a source carrying
an interactive form (a field’s widgets may sit on dropped pages and orphan)
raise
UnsupportedSourceDocumentException. The splitter refuses rather than emit a corrupt or compromised document. Splitting a form document is a known limitation of this release. UnsupportedSourceDocumentExceptionlives under theMergenamespace. Its fully-qualified name isNextPDF\Document\Merge\UnsupportedSourceDocumentException. ThatMergepath on a split page is not a copy/paste error: it is the single, shared source-document rejection exception that both the merge and split surfaces raise when a source cannot be safely copied. Import it from that namespace.- Output is structurally fresh, not byte-stable. Each segment is a new
document with its own catalog, page tree, and trailer. Two runs over the same
input are structurally equal, but not guaranteed byte-identical — hence the
structuralreproducibility profile.
Performance
Section titled “Performance”Splitting is linear in the number of pages copied across all ranges. Parsing the
source and copying each range’s object closure, not the splitter’s own
bookkeeping, dominate the work. The source is held in memory as a string, and
each segment’s bytes are held until you write them, so peak memory tracks the
source size plus the largest range you produce. The maxBytes guard keeps the
source side of that peak bounded. For high-volume pipelines, set maxBytes and
maxRanges to the smallest values your workload needs, so a malformed or
oversized input fails fast instead of exhausting memory.
Security notes
Section titled “Security notes”The split runs in process; no document bytes leave the host, and no network call is made. Treat every source PDF as untrusted input:
- Keep the bounds tight.
maxBytesandmaxRangesare your first line of defense against denial-of-service input. For any surface that accepts uploads, set them to your real ceiling, not the generous defaults. - Triage before you split. A source that is encrypted or signed fails closed, but you can detect those conditions earlier. Run untrusted inputs through the Core inspector first. See Parse and inspect a PDF for a bounded scan that flags encryption, signatures, and risk markers before heavier processing.
- Never interpolate user input into a path. This recipe writes to a fixed directory or the cookbook side-channel. Derive output paths and segment names from server-controlled values, never from a request field, to avoid path traversal.
- No secrets in the output. Do not write segment files to a location, or with a name, that exposes internal identifiers to a client that should not see them.
Conformance
Section titled “Conformance”This recipe makes no normative standards claim of its own. It decomposes one
document through the Core split surface and sanity-checks each segment with the
SplitDocument::isValid() %PDF-header check — a presence check that the
splitter emitted a PDF, not a conformance or document-structure validation. The
page-tree and cross-reference
structures that PdfSplitter rebuilds for each segment are the PDF 2.0
structures described in the /modules/core/document/ reference
(ISO 32000-2:2020, cross-reference table §7.5.4, page tree §7.7.3). For a
structural read of any input or output document, including version, page count,
encryption, and signature flags, use the Core inspector documented in
Parse and inspect a PDF.
See also
Section titled “See also”- Document module reference — the full split, merge, and document-part surface.
- Merge external PDFs — the inverse recipe: compose many documents into one.
- Parse and inspect a PDF — triage untrusted inputs before you split them.
- Exception-aware error handling
— the NextPDF exception hierarchy behind
PageLayoutExceptionandUnsupportedSourceDocumentException.