Ir al contenido
getnextpdf.com

Dividir un PDF y extraer rangos de páginas

Se tiene un PDF y se necesitan varios. Esta receta divide un único documento en varios archivos con la superficie de división del Core, NextPDF\Document\PdfSplitter. Se pasa el origen como una cadena de bytes de PDF en bruto y se describen las páginas que se desean. El divisor analiza el origen a través del grafo de objetos, copia los objetos alcanzables de cada rango solicitado en un documento nuevo y renumerado con su propio árbol de páginas y su tabla de referencias cruzadas, y devuelve PDF estructuralmente completos que se cargan en un lector conforme.

Esta es la operación inversa de la receta de combinación: la combinación compone muchos documentos en uno, la división descompone un documento en muchos. La misma superficie cubre las tres tareas que más a menudo se necesitan:

  • Dividir por rangos: produce un documento de salida por cada rango de páginas que nombres.
  • Dividir cada N páginas: trocea un archivo largo en segmentos de tamaño fijo.
  • Extraer un rango: extrae un único rango contiguo de páginas en un documento.

La división se ejecuta en el mismo proceso, sin un navegador headless ni una llamada de red. Se necesita el Core instalado (composer require nextpdf/core:^3) y un PDF legible.

Ventana de terminal
composer require nextpdf/core:^3

Un PDF localiza sus páginas mediante un árbol de páginas cuya raíz es un nodo /Pages, y alcanza cada objeto indirecto a través de sus datos de referencias cruzadas (una tabla o un flujo). No se pueden extraer páginas cortando bytes: una sola página hace referencia a fuentes, imágenes y diccionarios de recursos compartidos que residen en otra parte del archivo, y los desplazamientos de las referencias cruzadas dejarían de ser válidos.

PdfSplitter hace el trabajo real. Para cada rango recorre el grafo de objetos a partir de los objetos de página solicitados, recopila la clausura de objetos alcanzables, renumera esos objetos en un espacio de direcciones nuevo, reconstruye un documento con un único árbol de páginas y emite una tabla de referencias cruzadas real conforme a la estructura de PDF 2.0 (ISO 32000-2:2020, tabla de referencias cruzadas §7.5.4, árbol de páginas §7.7.3). Cada salida es un documento autónomo, no un fragmento.

Los números de página son base 1 e inclusivos. Un rango es un objeto de valor NextPDF\Document\PageRange: new PageRange(2, 5) significa de la página 2 a la 5. El constructor valida sus propios invariantes (rechaza un inicio inferior a 1 o un fin anterior al inicio lanzando NextPDF\Exception\PageLayoutException), de modo que un rango imposible falla en la construcción, no en lo profundo del divisor. PageRange::parse() y PageRange::all() lanzan la misma PageLayoutException ante una especificación mal formada o un total de páginas no positivo.

new NextPDF\Document\PdfSplitter() expone tres métodos. Todos toman el origen como una cadena de bytes de PDF en bruto, nunca una ruta.

  • split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResult produce un documento de salida por cada PageRange en $ranges, en orden. Los dos parámetros de límite acotan el tamaño de entrada y el número de rangos.
  • splitEvery(string $pdfData, int $pagesPerSegment): SplitResult trocea el documento en segmentos de tamaño fijo de $pagesPerSegment páginas cada uno; el último segmento contiene el resto.
  • extractPages(string $pdfData, PageRange $range): SplitDocument extrae un único rango y devuelve directamente ese documento.

split() y splitEvery() devuelven un NextPDF\Document\SplitResult, un objeto readonly que lleva $documents (una lista de segmentos), $totalPages (páginas del origen) y $sourceSize. Ofrece count(), document(int $index) para obtener un segmento por índice de base cero, y totalOutputSize().

Cada segmento, y el valor de retorno de extractPages(), es un NextPDF\Document\SplitDocument: un objeto readonly que expone $pdfData (los bytes del segmento), $range, $pageCount, $sizeBytes y el ayudante isValid(). isValid() es una comprobación de coherencia limitada de la cabecera %PDF (devuelve true cuando los bytes del segmento empiezan por %PDF), no una validación de estructura del documento ni de conformidad; confirma que el divisor produjo un PDF, no que el archivo sea plenamente conforme.

Se construye un PageRange directamente con new PageRange($start, $end), o se analiza una especificación legible con PageRange::parse('1-3,5,7-10'), que devuelve un list<PageRange> listo para pasar a split(). PageRange::all($totalPages) devuelve un único rango que cubre todo el documento.

Este ejemplo lee un archivo y lo divide en dos documentos: las páginas 1 a 3 y las páginas 4 a 6. Omite el manejo de errores para mostrar la forma de la llamada; el ejemplo de producción de abajo añade todas las salvaguardas.

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

Este programa autónomo construye en memoria un pequeño documento de varias páginas, de modo que se ejecuta sin un archivo externo. Demuestra las tres operaciones: dividir por rangos, dividir cada N páginas y extraer un único rango. Valida y escribe los segmentos por rango y la cola extraída, e informa del resultado por tamaño como un recuento, para que se vea la forma de cada llamada sin tres bucles de escritura casi idénticos. Captura las excepciones que lanza la superficie de división y relanza cada una con contexto en lugar de tragárselas. Sustituye el origen en memoria por tu propia lectura con file_get_contents() o una obtención desde almacenamiento de objetos.

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

Salida estándar esperada (los tamaños en bytes dependen de la compilación):

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.
  • El origen son bytes, no una ruta. Cada método toma una cadena de PDF en bruto. Lee primero el archivo con file_get_contents() o extrae los bytes del almacenamiento de objetos. Pasar una ruta hace que el origen no se pueda analizar.
  • Los números de página son base 1 e inclusivos. new PageRange(1, 3) cubre las páginas 1, 2 y 3: tres páginas. Un inicio inferior a 1 o un fin anterior al inicio lanza PageLayoutException desde el propio constructor de PageRange.
  • Un rango más allá del final es un error, no un recorte. Si el fin de un rango supera el número de páginas del origen, split() lanza PageLayoutException; nunca recorta silenciosamente el rango hasta la última página. Inspecciona primero el número de páginas si los rangos los proporciona quien llama.
  • splitEvery() conserva el resto. El último segmento contiene las páginas que quedan, de modo que un documento de 7 páginas dividido cada 2 páginas produce cuatro segmentos: tres de 2 páginas y uno de 1. $pagesPerSegment debe ser al menos 1, o se obtiene una InvalidArgumentException.
  • Se rechaza una lista de rangos vacía. split() con $ranges === [] lanza InvalidArgumentException. Construye al menos un rango antes de llamarlo.
  • Los límites lanzan en lugar de truncar. Superar maxBytes o maxRanges lanza InvalidArgumentException. El divisor nunca procesa parcialmente una entrada sobredimensionada, así que ajusta ambos límites a tu carga de trabajo.
  • Los orígenes cifrados, firmados y con formularios fallan de forma cerrada. Un origen cifrado (no se puede copiar sin la clave), un origen firmado digitalmente (repaginar invalidaría el rango de bytes de la firma) o un origen que lleva un formulario interactivo (los widgets de un campo pueden quedar en páginas descartadas y huérfanas) lanzan UnsupportedSourceDocumentException. El divisor se niega en lugar de emitir un documento corrupto o comprometido. Dividir un documento con formulario es una limitación conocida de esta versión.
  • UnsupportedSourceDocumentException reside bajo el espacio de nombres Merge. Su nombre completamente cualificado es NextPDF\Document\Merge\UnsupportedSourceDocumentException. Esa ruta Merge en una página de división no es un error de copiar/pegar: es la única excepción de rechazo de documento de origen, compartida, que lanzan tanto la superficie de combinación como la de división cuando un origen no se puede copiar con seguridad. Impórtala desde ese espacio de nombres.
  • La salida es estructuralmente nueva, no estable a nivel de bytes. Cada segmento es un documento nuevo con su propio catálogo, árbol de páginas y tráiler. Dos ejecuciones sobre la misma entrada son estructuralmente iguales, pero no se garantiza que sean idénticas byte a byte; de ahí el perfil de reproducibilidad structural.

La división es lineal respecto al número de páginas copiadas en todos los rangos. Analizar el origen y copiar la clausura de objetos de cada rango, no la contabilidad propia del divisor, dominan el trabajo. El origen se mantiene en memoria como una cadena, y los bytes de cada segmento se mantienen hasta que los escribes, de modo que la memoria pico sigue al tamaño del origen más el rango más grande que produzcas. La salvaguarda maxBytes mantiene acotado el lado del origen de ese pico. Para canalizaciones de gran volumen, fija maxBytes y maxRanges en los valores más pequeños que necesite tu carga de trabajo, de modo que una entrada mal formada o sobredimensionada falle rápido en lugar de agotar la memoria.

La división se ejecuta en el mismo proceso; ningún byte del documento abandona el host y no se realiza ninguna llamada de red. Trata cada PDF de origen como entrada no confiable:

  • Mantén los límites ajustados. maxBytes y maxRanges son tu primera línea de defensa frente a entradas de denegación de servicio. Para cualquier superficie que acepte cargas, fíjalos a tu límite real, no a los valores predeterminados generosos.
  • Clasifica antes de dividir. Un origen cifrado o firmado falla de forma cerrada, pero esas condiciones se pueden detectar antes. Pasa las entradas no confiables primero por el inspector del Core. Consulta Analizar e inspeccionar un PDF para un escaneo acotado que señala cifrado, firmas y marcadores de riesgo antes de un procesamiento más pesado.
  • Nunca interpoles entrada de usuario en una ruta. Esta receta escribe en un directorio fijo o en el canal lateral del recetario. Deriva las rutas de salida y los nombres de los segmentos de valores controlados por el servidor, nunca de un campo de solicitud, para evitar la travesía de rutas.
  • Sin secretos en la salida. No escribas archivos de segmento en una ubicación, ni con un nombre, que exponga identificadores internos a un cliente que no debería verlos.

Esta receta no hace ninguna afirmación normativa de estándares por sí misma. Descompone un documento a través de la superficie de división del Core y verifica la coherencia de cada segmento con la comprobación de cabecera %PDF de SplitDocument::isValid(): una comprobación de presencia de que el divisor emitió un PDF, no una validación de conformidad ni de estructura del documento. Las estructuras de árbol de páginas y de referencias cruzadas que PdfSplitter reconstruye para cada segmento son las estructuras de PDF 2.0 descritas en la referencia /modules/core/document/ (ISO 32000-2:2020, tabla de referencias cruzadas §7.5.4, árbol de páginas §7.7.3). Para una lectura estructural de cualquier documento de entrada o salida, incluidas la versión, el número de páginas, el cifrado y los indicadores de firma, usa el inspector del Core documentado en Analizar e inspeccionar un PDF.