Salta ai contenuti
getnextpdf.com

Suddividere un PDF ed estrarre intervalli di pagine

Si dispone di un solo PDF e ne occorrono diversi. Questa ricetta scolpisce un singolo documento in più file tramite l’interfaccia di suddivisione di Core, NextPDF\Document\PdfSplitter. Si passa l’origine come stringa di byte PDF non elaborati e si descrive quali pagine si desiderano. Lo splitter analizza l’origine attraverso il grafo degli oggetti, copia gli oggetti raggiungibili di ciascun intervallo richiesto in un nuovo documento rinumerato con il proprio albero delle pagine e la propria tabella di riferimenti incrociati, e restituisce PDF strutturalmente completi che si caricano in un lettore conforme.

Questa è l’operazione inversa della ricetta di unione: l’unione compone molti documenti in uno solo, la suddivisione scompone un documento in molti. La stessa interfaccia copre le tre attività di cui si ha più spesso bisogno:

  • Suddividere per intervalli — produrre un documento di output per ogni intervallo di pagine che si specifica.
  • Suddividere ogni N pagine — spezzare un file lungo in segmenti di dimensione fissa.
  • Estrarre un intervallo — estrarre un singolo intervallo contiguo di pagine in un unico documento.

La suddivisione viene eseguita in-process, senza browser headless e senza chiamate di rete. Occorre avere Core installato (composer require nextpdf/core:^3) e un PDF leggibile.

Terminal window
composer require nextpdf/core:^3

Un PDF individua le proprie pagine tramite un albero delle pagine la cui radice è un nodo /Pages, e raggiunge ogni oggetto indiretto attraverso i propri dati di riferimento incrociato (una tabella o uno stream). Non è possibile estrarre le pagine ritagliando i byte: una singola pagina fa riferimento a font, immagini e dizionari di risorse condivisi che risiedono altrove nel file, e gli offset di riferimento incrociato non sarebbero più validi.

PdfSplitter svolge il lavoro reale. Per ogni intervallo percorre il grafo degli oggetti a partire dagli oggetti pagina richiesti, raccoglie la chiusura degli oggetti raggiungibili, rinumera tali oggetti in un nuovo spazio di indirizzamento, ricostruisce un documento con un singolo albero delle pagine ed emette una vera tabella di riferimenti incrociati conforme alla struttura del PDF 2.0 (ISO 32000-2:2020, tabella di riferimenti incrociati §7.5.4, albero delle pagine §7.7.3). Ciascun output è un documento autonomo, non un frammento.

I numeri di pagina partono da 1 e sono inclusivi. Un intervallo è un value object NextPDF\Document\PageRange: new PageRange(2, 5) indica le pagine da 2 a 5. Il costruttore convalida i propri invarianti — rifiuta un inizio inferiore a 1 o una fine precedente all’inizio sollevando NextPDF\Exception\PageLayoutException — così che un intervallo impossibile fallisca al momento della costruzione, non in profondità all’interno dello splitter. PageRange::parse() e PageRange::all() sollevano la stessa PageLayoutException in caso di specifica malformata o di un totale di pagine non positivo.

new NextPDF\Document\PdfSplitter() espone tre metodi. Tutti accettano l’origine come stringa di byte PDF non elaborati, mai un percorso.

  • split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResult produce un documento di output per ciascun PageRange in $ranges, in ordine. I due parametri di limite delimitano la dimensione dell’input e il numero di intervalli.
  • splitEvery(string $pdfData, int $pagesPerSegment): SplitResult spezza il documento in segmenti di dimensione fissa di $pagesPerSegment pagine ciascuno; l’ultimo segmento contiene il resto.
  • extractPages(string $pdfData, PageRange $range): SplitDocument estrae un singolo intervallo e restituisce direttamente quel documento.

split() e splitEvery() restituiscono un NextPDF\Document\SplitResult, un oggetto readonly che porta con sé $documents (un elenco di segmenti), $totalPages (le pagine nell’origine) e $sourceSize. Offre count(), document(int $index) per recuperare un segmento tramite indice a partire da zero, e totalOutputSize().

Ciascun segmento, e il valore restituito da extractPages(), è un NextPDF\Document\SplitDocument: un oggetto readonly che espone $pdfData (i byte del segmento), $range, $pageCount, $sizeBytes e l’helper isValid(). isValid() è un controllo di integrità ristretto sull’intestazione %PDF — restituisce true quando i byte del segmento iniziano con %PDF — non una convalida della struttura del documento né della conformità; conferma che lo splitter ha prodotto un PDF, non che il file sia pienamente conforme.

Si costruisce un PageRange direttamente con new PageRange($start, $end), oppure si analizza una specifica leggibile dall’uomo con PageRange::parse('1-3,5,7-10'), che restituisce una list<PageRange> pronta da passare a split(). PageRange::all($totalPages) restituisce un singolo intervallo che copre l’intero documento.

Questo esempio legge un file e lo suddivide in due documenti: le pagine da 1 a 3 e le pagine da 4 a 6. Tralascia la gestione degli errori per mostrare la forma della chiamata; l’esempio di produzione qui sotto aggiunge tutte le protezioni.

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

Questo programma autonomo costruisce in memoria un piccolo documento multipagina, così da poter essere eseguito senza un file esterno. Dimostra tutte e tre le operazioni — suddivisione per intervalli, suddivisione ogni N pagine ed estrazione di un singolo intervallo. Convalida e scrive i segmenti per intervallo e la coda estratta, e riporta il risultato per dimensione come conteggio, così da mostrare la forma di ciascuna chiamata senza tre cicli di scrittura quasi identici. Cattura le eccezioni sollevate dall’interfaccia di suddivisione e rilancia ciascuna con contesto anziché ignorarla. Sostituire l’origine in memoria con la propria lettura file_get_contents() o con il recupero da object storage.

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

Output standard atteso (le dimensioni in byte dipendono dalla 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.
  • L’origine è costituita da byte, non da un percorso. Ogni metodo accetta una stringa PDF non elaborata. Leggere prima il file con file_get_contents() oppure recuperare i byte da object storage. Passare un percorso fa fallire l’analisi dell’origine.
  • I numeri di pagina partono da 1 e sono inclusivi. new PageRange(1, 3) copre le pagine 1, 2 e 3 — tre pagine. Un inizio inferiore a 1 o una fine precedente all’inizio solleva PageLayoutException dallo stesso costruttore di PageRange.
  • Un intervallo oltre la fine è un errore, non un troncamento. Se la fine di un intervallo supera il numero di pagine dell’origine, split() solleva PageLayoutException; non riduce mai silenziosamente l’intervallo all’ultima pagina. Ispezionare prima il numero di pagine se gli intervalli sono forniti dal chiamante.
  • splitEvery() conserva il resto. L’ultimo segmento contiene tutte le pagine rimanenti, perciò un documento di 7 pagine suddiviso ogni 2 pagine produce quattro segmenti: tre da 2 pagine e uno da 1. $pagesPerSegment deve essere almeno 1, altrimenti si ottiene un InvalidArgumentException.
  • Un elenco di intervalli vuoto viene rifiutato. split() con $ranges === [] solleva InvalidArgumentException. Costruire almeno un intervallo prima di chiamarlo.
  • I limiti sollevano un’eccezione anziché troncare. Superare maxBytes o maxRanges solleva InvalidArgumentException. Lo splitter non elabora mai parzialmente un input sovradimensionato, perciò regolare entrambi i limiti in base al proprio carico di lavoro.
  • Le origini cifrate, firmate e con moduli falliscono in modo chiuso. Un’origine cifrata (non può essere copiata senza la chiave), un’origine firmata digitalmente (la ripaginazione invaliderebbe l’intervallo di byte della firma) o un’origine che porta un modulo interattivo (i widget di un campo possono trovarsi su pagine scartate e restare orfani) sollevano UnsupportedSourceDocumentException. Lo splitter rifiuta anziché emettere un documento danneggiato o compromesso. La suddivisione di un documento con moduli è una limitazione nota di questa release.
  • UnsupportedSourceDocumentException risiede sotto il namespace Merge. Il suo nome completo è NextPDF\Document\Merge\UnsupportedSourceDocumentException. Quel percorso Merge in una pagina sulla suddivisione non è un errore di copia/incolla: è l’unica, condivisa eccezione di rifiuto del documento di origine che sia l’interfaccia di unione sia quella di suddivisione sollevano quando un’origine non può essere copiata in modo sicuro. Importarla da quel namespace.
  • L’output è strutturalmente nuovo, non stabile a livello di byte. Ciascun segmento è un nuovo documento con il proprio catalogo, albero delle pagine e trailer. Due esecuzioni sullo stesso input sono strutturalmente equivalenti, ma non sono garantite identiche a livello di byte — da cui il profilo di riproducibilità structural.

La suddivisione è lineare nel numero di pagine copiate attraverso tutti gli intervalli. A dominare il lavoro sono l’analisi dell’origine e la copia della chiusura degli oggetti di ciascun intervallo, non la contabilità interna dello splitter. L’origine viene mantenuta in memoria come stringa e i byte di ciascun segmento vengono mantenuti finché non li si scrive, perciò il picco di memoria segue la dimensione dell’origine più l’intervallo più grande che si produce. La protezione maxBytes mantiene delimitato il lato origine di quel picco. Per le pipeline ad alto volume, impostare maxBytes e maxRanges ai valori più piccoli necessari per il proprio carico di lavoro, così che un input malformato o sovradimensionato fallisca rapidamente anziché esaurire la memoria.

La suddivisione viene eseguita in-process; nessun byte del documento lascia l’host e non viene effettuata alcuna chiamata di rete. Trattare ogni PDF di origine come input non attendibile:

  • Mantenere i limiti stretti. maxBytes e maxRanges sono la prima linea di difesa contro input di denial-of-service. Per qualsiasi interfaccia che accetta caricamenti, impostarli al proprio tetto reale, non ai valori predefiniti generosi.
  • Filtrare prima di suddividere. Un’origine cifrata o firmata fallisce in modo chiuso, ma è possibile rilevare tali condizioni in anticipo. Passare gli input non attendibili prima attraverso l’inspector di Core. Si veda Analizzare e ispezionare un PDF per una scansione delimitata che segnala cifratura, firme e indicatori di rischio prima di un’elaborazione più pesante.
  • Non interpolare mai input dell’utente in un percorso. Questa ricetta scrive in una directory fissa o nel canale laterale del cookbook. Derivare i percorsi di output e i nomi dei segmenti da valori controllati dal server, mai da un campo di richiesta, per evitare il path traversal.
  • Nessun segreto nell’output. Non scrivere i file dei segmenti in una posizione, o con un nome, che esponga identificatori interni a un client che non dovrebbe vederli.

Questa ricetta non avanza alcuna affermazione normativa di conformità a standard. Scompone un documento attraverso l’interfaccia di suddivisione di Core e verifica l’integrità di ciascun segmento con il controllo %PDF di SplitDocument::isValid() — un controllo di presenza del fatto che lo splitter abbia emesso un PDF, non una convalida di conformità o di struttura del documento. Le strutture dell’albero delle pagine e del riferimento incrociato che PdfSplitter ricostruisce per ciascun segmento sono le strutture del PDF 2.0 descritte nel riferimento /modules/core/document/ (ISO 32000-2:2020, tabella di riferimenti incrociati §7.5.4, albero delle pagine §7.7.3). Per una lettura strutturale di qualsiasi documento di input o output, inclusi versione, numero di pagine, cifratura e flag delle firme, usare l’inspector di Core documentato in Analizzare e ispezionare un PDF.