Skip to content
getnextpdf.com

Split a PDF and extract page ranges

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.

Terminal window
composer require nextpdf/core:^3

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.

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): SplitResult produces one output document per PageRange in $ranges, in order. The two bound parameters cap the input size and the range count.
  • splitEvery(string $pdfData, int $pagesPerSegment): SplitResult chops the document into fixed-size segments of $pagesPerSegment pages each; the last segment holds the remainder.
  • extractPages(string $pdfData, PageRange $range): SplitDocument extracts 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.

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());

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.
  • 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 raises PageLayoutException from the PageRange constructor itself.
  • A range past the end is an error, not a clamp. If a range’s end exceeds the source page count, split() raises PageLayoutException; 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. $pagesPerSegment must be at least 1, or you get an InvalidArgumentException.
  • An empty range list is rejected. split() with $ranges === [] raises InvalidArgumentException. Build at least one range before you call it.
  • Bounds raise rather than truncate. Exceeding maxBytes or maxRanges raises InvalidArgumentException. 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.
  • UnsupportedSourceDocumentException lives under the Merge namespace. Its fully-qualified name is NextPDF\Document\Merge\UnsupportedSourceDocumentException. That Merge path 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 structural reproducibility profile.

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.

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. maxBytes and maxRanges are 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.

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.