Salta ai contenuti

Unire PDF esterni o aggiungere pagine da documenti esistenti

Su disco sono presenti più file PDF e occorre ottenere un unico PDF. Questa ricetta combina documenti esistenti dall’inizio alla fine tramite l’interfaccia di unione di Core, NextPDF\Document\PdfMerger. Si passano stringhe di byte PDF non elaborate. Il merger rinumera ogni oggetto per evitare collisioni, costruisce un unico albero delle pagine e un’unica tabella di riferimenti incrociati, quindi restituisce un NextPDF\Document\MergeResult che è possibile scrivere su disco o trasmettere in streaming a un client.

La stessa interfaccia copre le tre attività a cui gli sviluppatori ricorrono più spesso:

  • Unire un elenco ordinato di PDF in un unico documento.
  • Aggiungere in coda un secondo PDF dopo un PDF di base.
  • Aggiungere in testa pagine collocando il nuovo documento all’inizio dell’ordine di input.

L’unione viene eseguita in-process, senza browser headless e senza chiamate di rete. Occorre un’installazione di Core (composer require nextpdf/core:^3) e due o più file PDF leggibili.

Terminal window
composer require nextpdf/core:^3

Un PDF organizza le proprie pagine in un albero delle pagine la cui radice è un nodo /Pages e individua ogni oggetto indiretto tramite una tabella di riferimenti incrociati. Quando si combinano due documenti di origine, i rispettivi numeri di oggetto si sovrappongono. Entrambi i file contengono quasi sempre un oggetto 1 0 obj, un /Catalog e un nodo /Pages. Concatenare i byte produrrebbe un file danneggiato, perché i riferimenti non puntano più alle posizioni indicate dai numeri.

PdfMerger risolve questo problema. Estrae gli oggetti pagina da ciascun input, rinumera ogni oggetto in un unico spazio di indirizzamento, riscrive il riferimento /Parent di ogni pagina in modo che punti a un unico nodo /Pages unito ed emette un solo catalogo, un solo albero delle pagine e un solo trailer. L’output è un documento strutturalmente nuovo, non una semplice concatenazione giustapposta.

La regola di ordinamento è semplice: le pagine compaiono nell’ordine in cui i rispettivi file di origine compaiono nell’elenco di input. Per aggiungere in coda, collocare per primo il documento di base. Per aggiungere in testa, collocare per primo il nuovo documento. Non esiste un metodo separato per l’aggiunta in testa, perché l’ordine di input è l’unico controllo necessario.

new NextPDF\Document\PdfMerger() espone due metodi.

  • merge(list<string> $pdfFiles, int $maxFiles = 100, int $maxTotalBytes = 200_000_000): MergeResult combina un elenco ordinato di stringhe di byte PDF non elaborate. I due parametri di delimitazione limitano il numero di file e la dimensione totale dell’input. Entrambi assumono per impostazione predefinita valori sicuri per la produzione ed è possibile renderli più restrittivi in base al carico di lavoro.
  • append(string $basePdf, string $appendPdf): MergeResult è un wrapper di comodo che unisce in ordine esattamente due documenti. Equivale a merge([$basePdf, $appendPdf]).

Entrambi restituiscono un NextPDF\Document\MergeResult, un oggetto readonly che contiene $pdfData (i byte uniti), $totalPages, $sourceCount, $mergedSize e l’helper isValid() che conferma che l’output inizia con l’intestazione %PDF.

Gli input sono stringhe di byte non elaborate, non percorsi di file. Il file viene letto direttamente con file_get_contents() (oppure recuperando i byte dall’archiviazione a oggetti). In questo modo il merger resta senza assunzioni sul file system e consente di unire documenti che non vengono mai scritti su disco.

Se occorre importare una singola pagina da un PDF esterno come Form XObject riutilizzabile — ad esempio per applicare una pagina con carta intestata dietro il contenuto generato — usare il contratto di importer tra pacchetti NextPDF\Contracts\ImportedFormObjectInterface, implementato da importer come nextpdf/artisan. Per comporre documenti interi e pagine intere, l’interfaccia documentata qui è PdfMerger.

Questo esempio legge due file e ne scrive l’unione. Tralascia la gestione degli errori per mostrare la struttura della chiamata; l’esempio di produzione più avanti aggiunge tutte le protezioni.

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

Questo è uno script autonomo. Crea in memoria due piccoli documenti, così può essere eseguito senza file esterni; poi li unisce, convalida il risultato e scrive l’output. Intercetta le due eccezioni generate dall’interfaccia di unione e rilancia ciascuna con il relativo contesto anziché ignorarla. Sostituire gli input in memoria con le proprie letture file_get_contents() (o recuperi dall’archiviazione a oggetti) e collegare l’output al proprio livello di risposta o di archiviazione.

<?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 previsto (il totale delle pagine è la somma delle pagine dei documenti di origine e la dimensione in byte dipende dalla build):

Merged 3 source(s) into 4 page(s), <n> bytes.
  • Gli input sono byte, non percorsi. merge() accetta stringhe PDF non elaborate. Leggere prima il file con file_get_contents(). Il passaggio di una stringa di percorso fa sì che l’input non superi il controllo dell’intestazione %PDF e genera PageLayoutException.
  • L’ordine è l’ordine di output. Le pagine si dispongono nell’ordine in cui i rispettivi file di origine compaiono nell’elenco. Non esiste un metodo per l’aggiunta in testa: collocare il nuovo documento all’inizio per aggiungerlo in testa, alla fine per aggiungerlo in coda.
  • Un elenco vuoto è un errore. Un $pdfFiles vuoto genera PageLayoutException, non un risultato vuoto. Convalidare l’insieme prima della chiamata.
  • Ogni input viene convalidato in anticipo. Ogni voce deve essere non vuota e iniziare con %PDF. Il primo input che non supera il controllo genera PageLayoutException con il vincolo violato e nulla viene unito.
  • I limiti generano un’eccezione anziché troncare. Il superamento di maxFiles genera un’eccezione tramite il guard interno sulle risorse, mentre il superamento di maxTotalBytes genera WriterException. Il merger non scarta mai file in modo silenzioso né tronca byte, quindi regolare entrambi i limiti in base al carico di lavoro.
  • L’output è strutturalmente nuovo, non stabile a livello di byte. Il documento unito contiene un nuovo catalogo, un nuovo albero delle pagine e un nuovo trailer. Due esecuzioni sugli stessi input sono strutturalmente uguali ma non è garantito che siano identiche a livello di byte, motivo per cui questa ricetta dichiara un profilo di riproducibilità structural.
  • Annotazioni a livello di pagina e risorse condivise. L’unione compone gli oggetti pagina in un unico albero. Le strutture a livello di documento che risiedono al di fuori degli oggetti pagina in un file di origine non vengono trasferite. Quando occorre importare una singola pagina come elemento grafico riutilizzabile con le relative risorse, usare il percorso ImportedFormObjectInterface tramite un importer come nextpdf/artisan.

L’unione è lineare rispetto al numero totale di pagine ed è dominata dal parsing e dalla rinumerazione degli oggetti, non dall’overhead interno del merger. Il picco di memoria segue i byte totali di input, perché ogni origine viene mantenuta in memoria come stringa durante l’assemblaggio dell’output. Il guard maxTotalBytes mantiene tale picco entro il limite. Per pipeline ad alto volume, impostare maxFiles e maxTotalBytes ai valori minimi necessari per il carico di lavoro, in modo che un batch malformato o sovradimensionato fallisca rapidamente anziché esaurire la memoria. Un’unione tipica di piccole dimensioni rientra in un budget di 1500 ms di tempo reale e 64 MB di picco.

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

  • Mantenere limiti restrittivi. maxFiles e maxTotalBytes sono la prima linea di difesa contro un input di tipo denial-of-service. Impostarli sul limite massimo effettivo, non sui valori predefiniti generosi, per qualsiasi interfaccia che accetti caricamenti.
  • Convalidare prima di fidarsi. Un’unione riuscita significa che i byte sono stati combinati, non che gli input siano sicuri. Sottoporre prima gli input non attendibili all’inspector di Core. Vedere Analizzare e ispezionare un PDF per una scansione di triage delimitata che segnala cifratura, firme e marcatori di rischio prima di elaborazioni più impegnative.
  • Non interpolare mai l’input dell’utente in un percorso. Questa ricetta scrive in un percorso fisso o nel canale laterale del cookbook. Derivare i percorsi di output da valori controllati dal server, mai da un campo della richiesta, in modo da evitare il path traversal.
  • Nessun segreto nel documento. Non incorporare credenziali, token o identificatori interni in un documento unito restituito a un client.

Questa ricetta non avanza alcuna dichiarazione normativa di conformità agli standard. Compone documenti esistenti tramite l’interfaccia di unione di Core e convalida il risultato con il controllo dell’intestazione MergeResult::isValid(). Il modello dell’albero delle pagine ricostruito da PdfMerger è la struttura dell’albero delle pagine di PDF 2.0 descritta nel riferimento /modules/core/document/. Per una lettura strutturale di qualsiasi documento di input o output — versione, numero di pagine, flag di cifratura e di firma — usare l’inspector di Core documentato in Analizzare e ispezionare un PDF.