Перейти к содержимому

Объединение внешних PDF и добавление страниц из существующих документов

У вас есть несколько файлов PDF на диске, а нужен один PDF. Этот рецепт показывает, как полностью объединить существующие документы с помощью интерфейса объединения из Core — NextPDF\Document\PdfMerger. Вы передаёте строки с необработанными байтами PDF. Модуль объединения перенумеровывает каждый объект, чтобы избежать конфликтов, строит единое дерево страниц и единую таблицу перекрёстных ссылок и возвращает NextPDF\Document\MergeResult, который можно записать на диск или передать клиенту потоком.

Тот же интерфейс охватывает три задачи, которые нужны чаще всего:

  • Объединить упорядоченный список PDF в один документ.
  • Добавить второй PDF после базового PDF.
  • Добавить в начало страницы, поместив новый документ первым в порядке входных данных.

Объединение выполняется внутри процесса, без headless-браузера и сетевых вызовов. Вам потребуется установленный Core (composer require nextpdf/core:^3) и два или более доступных для чтения файла PDF.

Окно терминала
composer require nextpdf/core:^3

PDF организует страницы в дерево страниц с корневым узлом /Pages и находит каждый косвенный объект через таблицу перекрёстных ссылок. При объединении двух исходных документов номера их объектов пересекаются. Оба файла почти всегда содержат объект 1 0 obj, узел /Catalog и узел /Pages. Если просто склеить байты, получится повреждённый файл: ссылки больше не указывают на объекты, которые должны идентифицировать.

PdfMerger устраняет это пересечение. Он извлекает объекты страниц из каждого входного документа, перенумеровывает каждый объект в едином адресном пространстве, переписывает ссылку /Parent каждой страницы так, чтобы она указывала на общий объединённый узел /Pages, и выдаёт один каталог, одно дерево страниц и один трейлер. Результат — структурно новый документ, а не склеенная конкатенация.

Правило упорядочивания простое: страницы располагаются в том же порядке, что и исходные файлы во входном списке. Чтобы добавить в конец, поместите базовый документ первым. Чтобы добавить в начало, поместите новый документ первым. Отдельного метода для добавления в начало нет: порядок входных данных — единственный управляющий параметр, который здесь нужен.

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\MergeResultreadonly-объект с $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));
}
}

Ожидаемый стандартный вывод (общее число страниц равно сумме числа страниц исходных файлов, а размер в байтах зависит от сборки):

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 мс по времени выполнения и 64 МБ по пиковой памяти.

Объединение выполняется внутри процесса; ни один байт документа не покидает хост, сетевые вызовы не выполняются. Считайте каждый внешний PDF недоверенными входными данными:

  • Держите ограничения жёсткими. maxFiles и maxTotalBytes — ваш первый рубеж защиты от входных данных, направленных на отказ в обслуживании. Для любого интерфейса, принимающего загрузки, задавайте их по вашему реальному потолку, а не по щедрым значениям по умолчанию.
  • Проверяйте, прежде чем доверять. Успешное объединение означает, что байты были скомбинированы, но не что входные данные безопасны. Сначала пропустите недоверенные входные данные через инспектор Core. См. Разбор и проверка PDF, где описано ограниченное сканирование для предварительной оценки: оно отмечает шифрование, подписи и маркеры рисков до более тяжёлой обработки.
  • Никогда не подставляйте пользовательский ввод в путь. Этот рецепт записывает по фиксированному пути или в служебный канал cookbook. Формируйте выходные пути из контролируемых сервером значений, но никогда из поля запроса, чтобы избежать обхода каталогов.
  • Никаких секретов в документе. Не встраивайте учётные данные, токены или внутренние идентификаторы в объединённый документ, который вы возвращаете клиенту.

Этот рецепт сам по себе не заявляет о соответствии нормативным стандартам. Он компонует существующие документы через интерфейс объединения из Core и проверяет результат проверкой заголовка MergeResult::isValid(). Модель дерева страниц, которую перестраивает PdfMerger, — это структура дерева страниц PDF 2.0, описанная в справочнике /modules/core/document/. Для структурного чтения любого входного или выходного документа, включая версию, число страниц, флаги шифрования и подписи, используйте инспектор Core, описанный в Разбор и проверка PDF.