Aller au contenu

Fusionner des PDF externes ou ajouter des pages depuis des documents existants

Tu as plusieurs fichiers PDF sur le disque et tu veux obtenir un seul PDF. Cette recette combine des documents existants bout à bout avec la surface de fusion du Core, NextPDF\Document\PdfMerger. Tu lui fournis des chaînes d’octets PDF bruts. Le merger renumérote chaque objet pour éviter les collisions, construit un arbre de pages unique et une table de références croisées unique, puis renvoie un NextPDF\Document\MergeResult que tu peux écrire sur le disque ou renvoyer à un client.

La même surface couvre les trois tâches que les développeurs demandent le plus souvent :

  • Fusionner une liste ordonnée de PDF en un seul document.
  • Ajouter à la fin un second PDF après un PDF de base.
  • Ajouter au début des pages en plaçant le nouveau document en premier dans l’ordre d’entrée.

La fusion s’exécute dans le processus, sans navigateur headless et sans appel réseau. Tu as besoin d’une installation du Core (composer require nextpdf/core:^3) et de deux fichiers PDF lisibles ou plus.

Fenêtre de terminal
composer require nextpdf/core:^3

Un PDF organise ses pages dans un arbre de pages dont la racine est un nœud /Pages, et localise chaque objet indirect grâce à une table de références croisées. Quand tu combines deux documents sources, leurs numéros d’objet se chevauchent. Les deux fichiers contiennent presque toujours un objet 1 0 obj, un /Catalog et un nœud /Pages. Concaténer les octets produirait un fichier corrompu, car les références ne pointeraient plus là où les numéros l’indiquent.

PdfMerger résout ce problème. Il extrait les objets de page de chaque entrée, renumérote chaque objet dans un espace d’adressage unique, réécrit la référence /Parent de chaque page pour qu’elle pointe vers l’unique nœud /Pages fusionné, et émet un seul catalogue, un seul arbre de pages et une seule queue. La sortie est un document structurellement neuf, pas un simple assemblage par concaténation.

La règle d’ordonnancement est simple : les pages apparaissent dans l’ordre où leurs fichiers sources apparaissent dans la liste d’entrée. Pour ajouter à la fin, place le document de base en premier. Pour ajouter au début, place le nouveau document en premier. Il n’existe pas de méthode séparée pour ajouter au début, car l’ordre d’entrée suffit pour ce contrôle.

new NextPDF\Document\PdfMerger() expose deux méthodes.

  • merge(list<string> $pdfFiles, int $maxFiles = 100, int $maxTotalBytes = 200_000_000): MergeResult combine une liste ordonnée de chaînes d’octets PDF bruts. Les deux paramètres de bornage plafonnent le nombre de fichiers et la taille totale d’entrée. Ils ont tous deux des valeurs par défaut sûres pour la production, que tu peux resserrer selon ta charge de travail.
  • append(string $basePdf, string $appendPdf): MergeResult est un wrapper pratique qui fusionne exactement deux documents dans cet ordre. Il équivaut à merge([$basePdf, $appendPdf]).

Les deux renvoient un NextPDF\Document\MergeResult, un objet readonly qui contient $pdfData (les octets fusionnés), $totalPages, $sourceCount, $mergedSize, et la méthode utilitaire isValid() qui confirme que la sortie commence par l’en-tête %PDF.

Les entrées sont des chaînes d’octets bruts, pas des chemins de fichiers. Tu lis toi-même le fichier avec file_get_contents() (ou tu récupères les octets depuis un stockage objet). Cela évite au merger toute hypothèse sur le système de fichiers et te permet de fusionner des documents qui ne touchent jamais le disque.

Si tu as besoin d’importer une seule page d’un PDF externe comme Form XObject réutilisable — par exemple pour estamper une page d’en-tête derrière du contenu généré — utilise le contrat d’importation entre paquets NextPDF\Contracts\ImportedFormObjectInterface, implémenté par des importateurs tels que nextpdf/artisan. La composition de documents entiers et de pages entières relève de la surface PdfMerger documentée ici.

Cet exemple lit deux fichiers et écrit le PDF fusionné. Il omet la gestion des erreurs pour montrer la forme de l’appel ; l’exemple de production ci-dessous ajoute tous les garde-fous.

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

Cet exemple est un programme autonome. Il construit deux petits documents en mémoire pour s’exécuter sans fichier externe, les fusionne, valide le résultat et écrit la sortie. Il intercepte les deux exceptions que la surface de fusion lève et les relance avec du contexte plutôt que de les avaler. Remplace les entrées en mémoire par tes propres lectures file_get_contents() (ou des récupérations depuis un stockage objet), et raccorde la sortie à ta réponse ou à ta couche de stockage.

<?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 attendu (le total de pages est la somme du nombre de pages de chaque source, et la taille en octets dépend de la build) :

Merged 3 source(s) into 4 page(s), <n> bytes.
  • Les entrées sont des octets, pas des chemins. merge() prend des chaînes PDF brutes. Lis d’abord le fichier avec file_get_contents(). Passer une chaîne représentant un chemin fait échouer l’entrée à la vérification de l’en-tête %PDF et lève PageLayoutException.
  • L’ordre d’entrée est l’ordre de sortie. Les pages se placent dans l’ordre où leurs fichiers sources apparaissent dans la liste. Il n’y a pas de méthode d’ajout au début : place le nouveau document en premier pour l’ajouter au début, en dernier pour l’ajouter à la fin.
  • Une liste vide est une erreur. Un $pdfFiles vide lève PageLayoutException, pas un résultat vide. Valide l’ensemble avant l’appel.
  • Chaque entrée est validée en amont. Chaque élément ne doit pas être vide et doit commencer par %PDF. La première entrée qui échoue lève PageLayoutException avec la contrainte violée, et rien n’est fusionné.
  • Les bornes lèvent une exception plutôt que de tronquer. Dépasser maxFiles déclenche le garde de ressource interne, et dépasser maxTotalBytes lève WriterException. Le merger ne supprime jamais silencieusement de fichiers et ne tronque jamais d’octets ; ajuste donc les deux bornes pour ta charge de travail.
  • La sortie est structurellement neuve, pas stable à l’octet près. Le document fusionné porte un nouveau catalogue, un nouvel arbre de pages et une nouvelle queue. Deux exécutions sur les mêmes entrées sont structurellement égales mais pas forcément strictement identiques à l’octet près, ce qui explique pourquoi cette recette déclare un profil de reproductibilité structural.
  • Annotations de page et ressources partagées. La fusion compose les objets de page en un seul arbre. Les structures de niveau document qui vivent en dehors des objets de page dans un fichier source ne sont pas reprises. Quand tu as besoin d’une seule page importée comme graphique réutilisable avec ses ressources, utilise le chemin ImportedFormObjectInterface via un importateur tel que nextpdf/artisan.

La fusion est linéaire par rapport au nombre total de pages et est dominée par l’analyse et la renumérotation des objets, pas par la comptabilité interne du merger. Le pic de mémoire suit le total des octets d’entrée, car chaque source est conservée en mémoire sous forme de chaîne pendant que la sortie est assemblée. Le garde maxTotalBytes maintient ce pic borné. Pour les pipelines à fort volume, règle maxFiles et maxTotalBytes aux plus petites valeurs dont ta charge de travail a besoin, afin qu’un lot mal formé ou surdimensionné échoue vite au lieu d’épuiser la mémoire. Une petite fusion typique tient dans un budget de 1500 ms de temps écoulé et 64 Mo de pic mémoire.

La fusion s’exécute dans le processus ; aucun octet de document ne quitte l’hôte et aucun appel réseau n’est effectué. Traite chaque PDF externe comme une entrée non fiable :

  • Garde les bornes serrées. maxFiles et maxTotalBytes sont ta première ligne de défense contre une entrée conçue pour provoquer un déni de service. Règle-les sur ton plafond réel, pas sur les valeurs par défaut généreuses, pour toute surface qui accepte des fichiers envoyés par les utilisateurs.
  • Valide avant de faire confiance. Une fusion réussie signifie que les octets ont été combinés, pas que les entrées sont sûres. Fais d’abord passer les entrées non fiables par l’inspecteur du Core. Consulte Analyser et inspecter un PDF pour une analyse de triage bornée qui signale le chiffrement, les signatures et les marqueurs de risque avant un traitement plus lourd.
  • N’interpole jamais une entrée utilisateur dans un chemin. Cette recette écrit vers un chemin fixe ou le canal latéral du cookbook. Dérive les chemins de sortie de valeurs contrôlées par le serveur, jamais d’un champ de requête, pour éviter la traversée de chemin.
  • Aucun secret dans le document. N’intègre pas d’identifiants, de jetons ou d’identifiants internes dans un document fusionné que tu renvoies à un client.

Cette recette ne formule aucune revendication normative propre à un standard. Elle compose des documents existants via la surface de fusion du Core et valide le résultat avec la vérification d’en-tête MergeResult::isValid(). Le modèle d’arbre de pages que PdfMerger reconstruit est la structure d’arbre de pages PDF 2.0 décrite dans la référence /modules/core/document/. Pour une lecture structurelle de tout document d’entrée ou de sortie — version, nombre de pages, indicateurs de chiffrement et de signature — utilise l’inspecteur du Core documenté dans Analyser et inspecter un PDF.