Ir al contenido

Combinar archivos PDF externos o anexar páginas de documentos existentes

Hay varios archivos PDF en disco y se necesita un solo PDF. Esta receta combina documentos existentes de principio a fin con la superficie de fusión del Core, NextPDF\Document\PdfMerger. Se pasan cadenas de bytes de PDF en bruto. El merger renumera cada objeto para evitar colisiones, construye un único árbol de páginas y una única tabla de referencias cruzadas, y devuelve un NextPDF\Document\MergeResult que se puede escribir en disco o transmitir a un cliente.

La misma superficie cubre las tres tareas a las que los desarrolladores recurren con más frecuencia:

  • Combinar una lista ordenada de PDF en un solo documento.
  • Anexar un segundo PDF después de un PDF base.
  • Anteponer páginas colocando el documento nuevo primero en el orden de entrada.

La fusión se ejecuta en el mismo proceso, sin navegador headless y sin llamadas de red. Se necesita una instalación del Core (composer require nextpdf/core:^3) y dos o más archivos PDF legibles.

Ventana de terminal
composer require nextpdf/core:^3

Un PDF organiza sus páginas en un árbol de páginas cuya raíz es un nodo /Pages, y ubica cada objeto indirecto mediante una tabla de referencias cruzadas. Al combinar dos documentos de origen, sus números de objeto se solapan. Normalmente, ambos archivos contienen un objeto 1 0 obj, un /Catalog y un nodo /Pages. Concatenar sus bytes produciría un archivo corrupto, porque las referencias dejan de apuntar a donde indican los números.

PdfMerger resuelve este problema. Extrae los objetos de página de cada entrada, renumera cada objeto en un único espacio de direccionamiento, reescribe la referencia /Parent de cada página para que apunte a un único nodo /Pages fusionado y emite un solo catálogo, un solo árbol de páginas y un solo tráiler. La salida es un documento estructuralmente nuevo, no una concatenación pegada de los originales.

La regla de orden es sencilla: las páginas aparecen según el orden en que sus archivos de origen aparecen en la lista de entrada. Para anexar, coloca el documento base primero. Para anteponer, coloca el documento nuevo primero. No hay un método separado de anteposición, porque el orden de entrada es el único control necesario.

new NextPDF\Document\PdfMerger() expone dos métodos.

  • merge(list<string> $pdfFiles, int $maxFiles = 100, int $maxTotalBytes = 200_000_000): MergeResult combina una lista ordenada de cadenas de bytes de PDF en bruto. Los dos parámetros de límite acotan la cantidad de archivos y el tamaño total de entrada. Ambos tienen valores predeterminados seguros para producción y se ajustan según cada carga de trabajo.
  • append(string $basePdf, string $appendPdf): MergeResult es un método de conveniencia que fusiona exactamente dos documentos en orden. Es equivalente a merge([$basePdf, $appendPdf]).

Ambos devuelven un NextPDF\Document\MergeResult, un objeto readonly que contiene $pdfData (los bytes fusionados), $totalPages, $sourceCount, $mergedSize y el método auxiliar isValid() que confirma que la salida comienza con el encabezado %PDF.

Las entradas son cadenas de bytes en bruto, no rutas de archivo. El archivo se lee antes con file_get_contents() (o se extraen los bytes del almacenamiento de objetos). Esto mantiene al merger libre de suposiciones sobre el sistema de archivos y permite fusionar documentos que nunca tocan el disco.

Si se necesita importar una sola página de un PDF externo como un Form XObject reutilizable —por ejemplo, para estampar una página de membrete detrás del contenido generado— se usa el contrato de importación entre paquetes NextPDF\Contracts\ImportedFormObjectInterface, implementado por importadores como nextpdf/artisan. La composición de documentos completos y páginas completas es la superficie de PdfMerger que se documenta aquí.

Este ejemplo lee dos archivos y escribe su fusión. Omite el manejo de errores para mostrar la forma de la llamada; el ejemplo de producción que aparece más abajo añade todas las protecciones.

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

Este es un programa autónomo. Construye dos documentos pequeños en memoria para poder ejecutarse sin ningún archivo externo, los fusiona, valida el resultado y escribe la salida. Captura las dos excepciones que lanza la superficie de fusión y las vuelve a lanzar con contexto, en lugar de silenciarlas. En un caso real, se reemplazan las entradas en memoria por lecturas de file_get_contents() (o extracciones del almacenamiento de objetos) y se conecta la salida a la respuesta o a la capa de almacenamiento.

<?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));
}
}

STDOUT esperado (el total de páginas es la suma de las páginas de los orígenes, y el tamaño en bytes depende de la generación):

Merged 3 source(s) into 4 page(s), <n> bytes.
  • Las entradas son bytes, no rutas. merge() toma cadenas de PDF en bruto. Lee primero el archivo con file_get_contents(). Pasar una ruta como cadena hace que la entrada no supere la verificación del encabezado %PDF y lanza PageLayoutException.
  • El orden de entrada es el orden de salida. Las páginas quedan en el orden en que sus archivos de origen aparecen en la lista. No hay método de anteposición: coloca el documento nuevo primero para anteponerlo y último para anexarlo.
  • La lista vacía es un error. Un $pdfFiles vacío lanza PageLayoutException, no un resultado vacío. Valida el conjunto antes de llamar.
  • Cada entrada se valida de antemano. Cada elemento debe ser no vacío y comenzar con %PDF. La primera entrada que falla lanza PageLayoutException con la restricción violada, y no se fusiona nada.
  • Los límites lanzan excepciones en lugar de truncar. Superar maxFiles lanza una excepción a través del guardián de recursos interno, y superar maxTotalBytes lanza WriterException. El merger nunca descarta archivos ni recorta bytes de forma silenciosa, así que ambos límites deben ajustarse a la carga de trabajo.
  • La salida es estructuralmente nueva, no estable a nivel de bytes. El documento fusionado lleva un nuevo catálogo, árbol de páginas y tráiler. Dos ejecuciones sobre las mismas entradas son estructuralmente iguales, pero no se garantiza que sean idénticas byte a byte, y por eso esta receta declara un perfil de reproducibilidad structural.
  • Anotaciones a nivel de página y recursos compartidos. La fusión compone los objetos de página en un solo árbol. Las estructuras a nivel de documento que residen fuera de los objetos de página en un archivo de origen no se trasladan. Cuando se necesita una sola página importada como gráfico reutilizable con sus recursos, se usa la vía ImportedFormObjectInterface a través de un importador como nextpdf/artisan.

La fusión es lineal respecto del total de páginas y está dominada por el análisis y la renumeración de objetos, no por la propia gestión interna del merger. La memoria máxima crece con el total de bytes de entrada, porque cada origen se mantiene en memoria como una cadena mientras se ensambla la salida. El guardián maxTotalBytes mantiene ese pico acotado. Para canalizaciones de alto volumen, conviene fijar maxFiles y maxTotalBytes en los valores más pequeños que requiera la carga de trabajo, para que un lote malformado o sobredimensionado falle rápido en lugar de agotar la memoria. Una fusión pequeña típica se mantiene dentro de un presupuesto de 1500 ms de tiempo de reloj y un pico de 64 MB.

La fusión se ejecuta en el mismo proceso; ningún byte del documento sale del host y no se realiza ninguna llamada de red. Cada PDF externo debe tratarse como entrada no confiable:

  • Mantener los límites ajustados. maxFiles y maxTotalBytes son la primera línea de defensa contra entradas de denegación de servicio. Deben fijarse en el techo real, no en los valores predeterminados generosos, para cualquier superficie que acepte cargas.
  • Validar antes de confiar. Una fusión exitosa significa que los bytes se combinaron, no que las entradas sean seguras. Las entradas no confiables deben pasar primero por el inspector del Core. Consultar Analizar e inspeccionar un PDF para un escaneo de triaje acotado que señala cifrado, firmas y marcadores de riesgo antes de un procesamiento más pesado.
  • No interpolar nunca entrada de usuario en una ruta. Esta receta escribe en una ruta fija o en el canal lateral del cookbook. Las rutas de salida deben derivarse de valores controlados por el servidor, nunca de un campo de la solicitud, para evitar path traversal.
  • No incluir secretos en el documento. No se deben incrustar credenciales, tokens ni identificadores internos en un documento fusionado que se devuelve a un cliente.

Esta receta no hace ninguna afirmación normativa de estándares por sí misma. Compone documentos existentes a través de la superficie de fusión del Core y valida el resultado con la verificación de encabezado MergeResult::isValid(). El modelo de árbol de páginas que PdfMerger reconstruye es la estructura de árbol de páginas de PDF 2.0 descrita en la referencia /modules/core/document/. Para una lectura estructural de cualquier documento de entrada o salida —versión, cantidad de páginas, banderas de cifrado y firma— usa el inspector del Core documentado en Analizar e inspeccionar un PDF.