Aller au contenu
getnextpdf.com

Découper un PDF et extraire des plages de pages

Tu as un seul PDF, et tu en veux plusieurs. Cette recette découpe un document unique en plusieurs fichiers avec la surface de découpe du Core, NextPDF\Document\PdfSplitter. Tu fournis la source sous forme de chaîne d’octets PDF bruts et tu décris les pages que tu veux. Le splitter analyse la source à travers le graphe d’objets, copie les objets atteignables de chaque plage demandée dans un document neuf et renuméroté doté de son propre arbre de pages et de sa propre table de références croisées, puis renvoie des PDF structurellement complets qui se chargent dans un lecteur conforme.

C’est l’inverse de la recette de fusion : la fusion compose plusieurs documents en un seul, la découpe décompose un document en plusieurs. La même surface couvre les trois tâches dont tu as le plus souvent besoin :

  • Découper par plages — produire un document de sortie par plage de pages que tu nommes.
  • Découper toutes les N pages — segmenter un long fichier en tranches de taille fixe.
  • Extraire une plage — extraire une seule plage de pages contiguë dans un seul document.

La découpe s’exécute dans le processus, sans navigateur headless et sans appel réseau. Tu as besoin du Core installé (composer require nextpdf/core:^3) et d’un PDF lisible.

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

Un PDF localise ses pages grâce à un arbre de pages dont la racine est un nœud /Pages, et il atteint chaque objet indirect grâce à ses données de références croisées (une table ou un flux). Tu ne peux pas extraire des pages en découpant des octets : une seule page référence des polices, des images et des dictionnaires de ressources partagés qui vivent ailleurs dans le fichier, et les décalages des références croisées ne seraient plus valides.

PdfSplitter fait le vrai travail. Pour chaque plage, il parcourt le graphe d’objets à partir des objets de page demandés, collecte la fermeture des objets atteignables, renumérote ces objets dans un nouvel espace d’adressage, reconstruit un document à arbre de pages unique, et émet une vraie table de références croisées conforme à la structure PDF 2.0 (ISO 32000-2:2020, table de références croisées §7.5.4, arbre de pages §7.7.3). Chaque sortie est un document autonome, pas un fragment.

Les numéros de pages commencent à 1 et sont inclusifs. Une plage est un objet valeur NextPDF\Document\PageRange : new PageRange(2, 5) désigne les pages 2 à 5. Le constructeur valide ses propres invariants — il rejette un début inférieur à 1 ou une fin antérieure au début en levant une NextPDF\Exception\PageLayoutException — de sorte qu’une plage impossible échoue à la construction, et non au fond du splitter. PageRange::parse() et PageRange::all() lèvent la même PageLayoutException sur une spécification mal formée ou un total de pages non positif.

new NextPDF\Document\PdfSplitter() expose trois méthodes. Toutes prennent la source sous forme de chaîne d’octets PDF bruts, jamais un chemin.

  • split(string $pdfData, array $ranges, int $maxBytes = 100_000_000, int $maxRanges = 1000): SplitResult produit un document de sortie par PageRange dans $ranges, dans l’ordre. Les deux paramètres de bornage plafonnent la taille d’entrée et le nombre de plages.
  • splitEvery(string $pdfData, int $pagesPerSegment): SplitResult segmente le document en tranches de taille fixe de $pagesPerSegment pages chacune ; la dernière tranche contient le reste.
  • extractPages(string $pdfData, PageRange $range): SplitDocument extrait une seule plage et renvoie directement ce document.

split() et splitEvery() renvoient un NextPDF\Document\SplitResult, un objet readonly qui porte $documents (une liste de tranches), $totalPages (les pages de la source) et $sourceSize. Il offre count(), document(int $index) pour récupérer une tranche par index commençant à zéro, et totalOutputSize().

Chaque tranche, ainsi que la valeur de retour d’extractPages(), est un NextPDF\Document\SplitDocument : un objet readonly exposant $pdfData (les octets de la tranche), $range, $pageCount, $sizeBytes, et l’utilitaire isValid(). isValid() est un contrôle de cohérence restreint sur l’en-tête %PDF — il renvoie true quand les octets de la tranche commencent par %PDF — et non une validation de structure de document ou de conformité ; il confirme que le splitter a produit un PDF, pas que le fichier est entièrement conforme.

Tu construis une PageRange directement avec new PageRange($start, $end), ou tu analyses une spécification lisible par un humain avec PageRange::parse('1-3,5,7-10'), qui renvoie une list<PageRange> prête à passer à split(). PageRange::all($totalPages) renvoie une seule plage couvrant tout le document.

Cet exemple lit un fichier et le découpe en deux documents : les pages 1 à 3 et les pages 4 à 6. 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\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());

Ce programme autonome construit un petit document multipage en mémoire, de sorte qu’il s’exécute sans fichier externe. Il démontre les trois opérations — découper par plages, découper toutes les N pages, et extraire une seule plage. Il valide et écrit les tranches par plage et la fin extraite, et rapporte le résultat par taille sous forme de comptage, pour que tu voies la forme de chaque appel sans trois boucles d’écriture quasi identiques. Il intercepte les exceptions que la surface de découpe lève et relance chacune avec son contexte au lieu de l’avaler. Remplace la source en mémoire par ta propre lecture file_get_contents() ou ta récupération depuis un stockage objet.

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

Sortie standard attendue (la taille en octets dépend du 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.
  • La source, ce sont des octets, pas un chemin. Chaque méthode prend une chaîne PDF brute. Lis d’abord le fichier avec file_get_contents(), ou récupère les octets depuis un stockage objet. Passer un chemin fait échouer l’analyse de la source.
  • Les numéros de pages commencent à 1 et sont inclusifs. new PageRange(1, 3) couvre les pages 1, 2 et 3 — trois pages. Un début inférieur à 1 ou une fin antérieure au début lève une PageLayoutException depuis le constructeur de PageRange lui-même.
  • Une plage qui dépasse la fin est une erreur, pas un écrêtage. Si la fin d’une plage dépasse le nombre de pages de la source, split() lève une PageLayoutException ; il ne tronque jamais silencieusement la plage à la dernière page. Inspecte d’abord le nombre de pages si tes plages sont fournies par l’appelant.
  • splitEvery() conserve le reste. La dernière tranche contient les pages restantes, donc un document de 7 pages découpé toutes les 2 pages donne quatre tranches : trois de 2 pages et une de 1. $pagesPerSegment doit valoir au moins 1, sinon tu obtiens une InvalidArgumentException.
  • Une liste de plages vide est rejetée. split() avec $ranges === [] lève une InvalidArgumentException. Construis au moins une plage avant de l’appeler.
  • Les bornes lèvent au lieu de tronquer. Dépasser maxBytes ou maxRanges lève une InvalidArgumentException. Le splitter ne traite jamais partiellement une entrée surdimensionnée, donc ajuste les deux bornes pour ta charge de travail.
  • Les sources chiffrées, signées et porteuses de formulaire échouent à fermé. Une source chiffrée (elle ne peut pas être copiée sans la clé), une source signée numériquement (la repagination invaliderait la plage d’octets de la signature) ou une source portant un formulaire interactif (les widgets d’un champ peuvent se trouver sur des pages supprimées et devenir orphelins) lèvent une UnsupportedSourceDocumentException. Le splitter refuse plutôt que d’émettre un document corrompu ou compromis. Découper un document à formulaire est une limitation connue de cette version.
  • UnsupportedSourceDocumentException vit sous l’espace de noms Merge. Son nom pleinement qualifié est NextPDF\Document\Merge\UnsupportedSourceDocumentException. Ce chemin Merge sur une page de découpe n’est pas une erreur de copier/coller : c’est l’unique exception partagée de rejet de document source que les surfaces de fusion et de découpe lèvent toutes deux quand une source ne peut pas être copiée en toute sûreté. Importe-la depuis cet espace de noms.
  • La sortie est structurellement neuve, pas stable au niveau des octets. Chaque tranche est un nouveau document doté de son propre catalogue, de son propre arbre de pages et de sa propre queue. Deux exécutions sur la même entrée sont structurellement égales, mais sans garantie d’identité au niveau des octets — d’où le profil de reproductibilité structural.

La découpe est linéaire par rapport au nombre de pages copiées à travers toutes les plages. L’analyse de la source et la copie de la fermeture d’objets de chaque plage, et non la comptabilité propre au splitter, dominent le travail. La source est gardée en mémoire sous forme de chaîne, et les octets de chaque tranche sont conservés jusqu’à ce que tu les écrives, donc le pic mémoire suit la taille de la source plus la plus grande plage que tu produis. Le garde-fou maxBytes borne le côté source de ce pic. Pour les pipelines à fort volume, fixe maxBytes et maxRanges aux plus petites valeurs dont ta charge de travail a besoin, afin qu’une entrée mal formée ou surdimensionnée échoue tôt plutôt que d’épuiser la mémoire.

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

  • Garde les bornes serrées. maxBytes et maxRanges sont ta première ligne de défense contre une entrée de déni de service. Pour toute surface qui accepte des téléversements, fixe-les à ton vrai plafond, pas aux valeurs par défaut généreuses.
  • Trie avant de découper. Une source chiffrée ou signée échoue à fermé, mais tu peux détecter ces conditions plus tôt. Fais passer les entrées non fiables par l’inspecteur du Core d’abord. Voir Analyser et inspecter un PDF pour un balayage borné qui signale le chiffrement, les signatures et les marqueurs de risque avant un traitement plus lourd.
  • Ne jamais interpoler une entrée utilisateur dans un chemin. Cette recette écrit dans un répertoire fixe ou le canal latéral du livre de recettes. Dérive les chemins de sortie et les noms de tranches 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 la sortie. N’écris pas les fichiers de tranches à un emplacement, ou avec un nom, qui expose des identifiants internes à un client qui ne devrait pas les voir.

Cette recette ne fait aucune revendication normative de standard qui lui soit propre. Elle décompose un document via la surface de découpe du Core et effectue un contrôle de cohérence sur chaque tranche avec le contrôle d’en-tête %PDF de SplitDocument::isValid() — un contrôle de présence qui confirme que le splitter a émis un PDF, et non une validation de conformité ou de structure de document. Les structures d’arbre de pages et de références croisées que PdfSplitter reconstruit pour chaque tranche sont les structures PDF 2.0 décrites dans la référence /modules/core/document/ (ISO 32000-2:2020, table de références croisées §7.5.4, arbre de pages §7.7.3). Pour une lecture structurelle de tout document d’entrée ou de sortie, y compris la version, le nombre de pages, le chiffrement et les indicateurs de signature, utilise l’inspecteur du Core documenté dans Analyser et inspecter un PDF.