Разделение PDF и извлечение диапазонов страниц
У вас есть один PDF, а нужно несколько. Этот рецепт разрезает один документ на
несколько файлов с помощью поверхности разделения Core,
NextPDF\Document\PdfSplitter. Вы передаёте источник как сырую байтовую строку
PDF и описываете, какие страницы вам нужны. Разделитель разбирает источник через
граф объектов, копирует достижимые объекты каждого запрошенного диапазона в
свежий, перенумерованный документ с собственным деревом страниц и таблицей
перекрёстных ссылок и возвращает структурно полные PDF, которые загружаются в
соответствующем стандарту просмотрщике.
Это обратная операция к рецепту объединения: объединение компонует множество документов в один, разделение раскладывает один документ на множество. Одна и та же поверхность покрывает три задачи, которые нужны вам чаще всего:
- Разделение по диапазонам — создаёт один выходной документ на каждый названный вами диапазон страниц.
- Разделение каждые N страниц — нарезает длинный файл на сегменты фиксированного размера.
- Извлечение диапазона — вытягивает один непрерывный диапазон страниц в один документ.
Разделение выполняется внутри процесса, без headless-браузера и без сетевого
вызова. Вам нужны установленное 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.
Конструктор проверяет собственные инварианты — он отклоняет начало ниже 1 или
конец раньше начала, выбрасывая NextPDF\Exception\PageLayoutException, — поэтому
невозможный диапазон даёт сбой при построении, а не где-то глубоко внутри
разделителя. 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создаёт один выходной документ на каждыйPageRangeв$ranges, по порядку. Два параметра-предела ограничивают размер ввода и число диапазонов.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: он возвращает true,
когда байты сегмента начинаются с %PDF, — а не валидация структуры документа или
соответствия; он подтверждает, что разделитель создал PDF, а не что файл полностью
соответствует стандарту.
Вы строите PageRange напрямую через new PageRange($start, $end) или разбираете
понятную человеку спецификацию с помощью 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 или конец раньше начала выбрасываетPageLayoutExceptionиз самого конструктораPageRange. - Диапазон за пределами конца — это ошибка, а не подгонка. Если конец
диапазона превышает число страниц источника,
split()выбрасываетPageLayoutException; он никогда молча не обрезает диапазон до последней страницы. Сначала проверьте число страниц, если диапазоны задаёт вызывающий код. splitEvery()сохраняет остаток. Последний сегмент содержит все оставшиеся страницы, поэтому документ из 7 страниц, разделённый каждые 2 страницы, даёт четыре сегмента: три по 2 страницы и один на 1.$pagesPerSegmentдолжен быть не меньше 1, иначе вы получитеInvalidArgumentException.- Пустой список диапазонов отклоняется.
split()с$ranges === []выбрасываетInvalidArgumentException. Постройте хотя бы один диапазон, прежде чем вызывать его. - Пределы выбрасывают исключение, а не усекают. Превышение
maxBytesилиmaxRangesвыбрасываетInvalidArgumentException. Разделитель никогда не обрабатывает завышенный ввод частично, поэтому настройте оба предела под свою нагрузку. - Зашифрованные, подписанные и несущие форму источники дают сбой
закрытым образом. Зашифрованный источник (его нельзя скопировать без ключа),
цифрово подписанный источник (повторная пагинация сделала бы недействительным
диапазон байтов подписи) или источник, несущий интерактивную форму (виджеты поля
могут оказаться на отброшенных страницах и осиротеть), выбрасывают
UnsupportedSourceDocumentException. Разделитель отказывается, вместо того чтобы выдать повреждённый или скомпрометированный документ. Разделение документа с формой — известное ограничение этого выпуска. UnsupportedSourceDocumentExceptionнаходится в пространстве имёнMerge. Его полностью квалифицированное имя —NextPDF\Document\Merge\UnsupportedSourceDocumentException. Этот путьMergeна странице о разделении — не ошибка копирования/вставки: это единственное общее исключение отклонения исходного документа, которое выбрасывают и поверхность объединения, и поверхность разделения, когда источник нельзя безопасно скопировать. Импортируйте его из этого пространства имён.- Вывод структурно свежий, а не побайтово стабильный. Каждый сегмент — это
новый документ с собственным каталогом, деревом страниц и трейлером. Два запуска
над одним и тем же вводом структурно равны, но не гарантированно побайтово
идентичны — отсюда профиль воспроизводимости
structural.
Производительность
Заголовок раздела «Производительность»Разделение линейно по числу страниц, скопированных по всем диапазонам. Основную
работу составляют разбор источника и копирование замыкания объектов каждого
диапазона, а не собственный учёт разделителя. Источник держится в памяти как
строка, а байты каждого сегмента удерживаются, пока вы их не запишете, поэтому
пиковая память отслеживает размер источника плюс самый большой создаваемый вами
диапазон. Защита maxBytes удерживает ограниченной сторону источника этого пика.
Для конвейеров большого объёма задавайте maxBytes и maxRanges в наименьшие
значения, которые нужны вашей нагрузке, чтобы некорректный или завышенный ввод
давал сбой быстро, а не исчерпывал память.
Примечания по безопасности
Заголовок раздела «Примечания по безопасности»Разделение выполняется внутри процесса; никакие байты документа не покидают хост и никакой сетевой вызов не делается. Относитесь к каждому исходному PDF как к недоверенному вводу:
- Держите пределы жёсткими.
maxBytesиmaxRanges— ваша первая линия защиты от ввода типа «отказ в обслуживании». Для любой поверхности, принимающей загрузки, задавайте их по своему реальному потолку, а не по щедрым значениям по умолчанию. - Сортируйте, прежде чем разделять. Источник, который зашифрован или подписан, даёт сбой закрытым образом, но эти условия можно обнаружить раньше. Прогоняйте недоверенный ввод через инспектор Core сначала. См. Разбор и исследование PDF для ограниченного сканирования, которое помечает шифрование, подписи и маркеры риска до более тяжёлой обработки.
- Никогда не интерполируйте пользовательский ввод в путь. Этот рецепт пишет в фиксированный каталог или в побочный канал cookbook. Выводите выходные пути и имена сегментов из значений, контролируемых сервером, никогда из поля запроса, чтобы избежать обхода путей.
- Никаких секретов в выводе. Не записывайте файлы сегментов в место или с именем, которые раскрывают внутренние идентификаторы клиенту, который не должен их видеть.
Соответствие стандартам
Заголовок раздела «Соответствие стандартам»Этот рецепт не делает собственного нормативного заявления о стандартах. Он
раскладывает один документ через поверхность разделения Core и проверяет
корректность каждого сегмента проверкой заголовка %PDF
SplitDocument::isValid() — проверкой наличия того, что разделитель выдал PDF, а
не валидацией соответствия или структуры документа. Структуры дерева страниц и
перекрёстных ссылок, которые PdfSplitter перестраивает для каждого сегмента, —
это структуры PDF 2.0, описанные в справочнике /modules/core/document/
(ISO 32000-2:2020, таблица перекрёстных ссылок §7.5.4, дерево страниц §7.7.3). Для
структурного чтения любого входного или выходного документа, включая версию, число
страниц, флаги шифрования и подписи, используйте инспектор Core,
документированный в
Разбор и исследование PDF.
См. также
Заголовок раздела «См. также»- Справочник модуля Document — полная поверхность разделения, объединения и частей документа.
- Объединение внешних PDF — обратный рецепт: компоновка множества документов в один.
- Разбор и исследование PDF — сортировка недоверенных вводов до их разделения.
- Обработка ошибок с учётом исключений
— иерархия исключений NextPDF, стоящая за
PageLayoutExceptionиUnsupportedSourceDocumentException.