Intégrer des fichiers et créer des portfolios PDF
Ce recipe ajoute un ou plusieurs fichiers à un PDF et, lorsqu’il y a plusieurs pièces jointes, les organise dans un portfolio PDF. Utilise-le quand un document doit transporter ses justificatifs dans le même fichier : une facture livrée avec la feuille de temps sous-jacente, une fiche produit qui regroupe un export de conception assistée par ordinateur (CAO, Computer-Aided Design), ou un enregistrement d’archive qui conserve le tableur source à côté du rapport rendu.
NextPDF te fournit deux points d’entrée sur l’objet document. embedFile() lit un fichier depuis le disque ; embedFileFromString() intègre des octets en mémoire que tu as générés à l’exécution. Les deux enregistrent la pièce jointe. Au moment de save(), le moteur écrit chacun sous forme de flux de fichier intégré, l’enveloppe dans un dictionnaire de spécification de fichier, puis relie chaque spécification dans la table de noms EmbeddedFiles au niveau du document. ISO 32000-2 définit cette table de noms comme l’emplacement où les flux de fichiers intégrés se rattachent au document dans son ensemble via le dictionnaire de noms.
C’est une fonctionnalité du Core, sans verrou commercial. L’interface de programmation d’application (API, Application Programming Interface) des pièces jointes est stable depuis la version 1.0.0 et fonctionne sur toute la matrice de backport 8.1-8.4.
Installation
Section intitulée « Installation »composer require nextpdf/core:^3Aucune extension optionnelle n’est requise.
Vue d’ensemble conceptuelle
Section intitulée « Vue d’ensemble conceptuelle »Une pièce jointe traverse trois structures PDF. Les connaître t’aide à lire la sortie et à déboguer un fichier non conforme.
- Flux de fichier intégré. Les octets bruts du fichier joint sont compressés avec Flate et écrits comme un objet de flux dont le
/Typeest/EmbeddedFile. NextPDF enregistre la taille d’origine, une somme de contrôle MD5 et la date de modification dans le dictionnaire de paramètres du flux. Il encode le type MIME (Multipurpose Internet Mail Extensions) détecté comme le/Subtypedu flux. - Dictionnaire de spécification de fichier. L’enveloppe de métadonnées. Elle porte le nom de fichier affiché (
/Fet la version Unicode/UF), une description lisible (/Desc), une référence vers le flux intégré (/EF) et la relation que le fichier entretient avec le document hôte (/AFRelationship). - Table de noms
EmbeddedFiles. Un index unique au niveau du document qui associe le nom de chaque pièce jointe à sa spécification de fichier. ISO 32000-2 exige que chaque spécification de fichier atteinte via cette table porte une entréeEFdont la valeur référence un flux de fichier intégré. NextPDF construit et équilibre cette table pour toi àsave().
La valeur de relation compte pour la conformité. La note d’application 0002 de la PDF Association indique qu’un fichier associé requiert une entrée AFRelationship choisie dans l’ensemble fixe de PDF 2.0 : Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema ou Unspecified. NextPDF modélise cet ensemble comme l’énumération AFRelationship et rejette toute autre valeur. Choisis le terme qui décrit la raison de la présence du fichier : une feuille de temps derrière une facture est Source ; un jeu de données lisible par machine derrière un graphique est Data.
Un portfolio PDF (appelé collection dans ISO 32000-2) est la couche supérieure. Quand un document transporte plusieurs pièces jointes, le dictionnaire Collection du catalogue indique au lecteur comment les présenter : un tableau de détails triable, une disposition en mosaïque ou une enveloppe masquée. ISO 32000-2 décrit le dictionnaire Collection comme l’élément qui contrôle la présentation des pièces jointes en portfolio organisé par un processeur PDF. NextPDF modélise cela comme l’objet valeur CollectionDictionary, avec CollectionSort pour l’ordre des colonnes d’une vue de détails.
Surface de l’API
Section intitulée « Surface de l’API »Les méthodes au niveau du document (issues du concern HasFileAttachments sur \NextPDF\Core\Document) :
embedFile(string $path, string $description = ''): static— lit un fichier depuis$pathet le joint. Le type MIME est détecté à partir de l’extension ; la relation prend par défaut la valeurUnspecified. Lit jusqu’à 100 Mo ; utiliseembedFileFromString()pour des charges utiles plus grandes. Renvoie le document pour le chaînage.embedFileFromString(string $data, string $filename, string $description = '', string $afRelationship = '/Unspecified'): static— joint des octets en mémoire sous le nom affiché$filename. Passe un littéralAFRelationship(avec ou sans la barre oblique initiale) pour définir la relation. Renvoie le document pour le chaînage.
Les types de support (espaces de noms \NextPDF\Navigation et \NextPDF\Document) :
\NextPDF\Navigation\AFRelationship— l’énumération des huit valeurs de relation valides.AFRelationship::coerce()normalise une chaîne ou un cas d’énumération et lève une exception sur une valeur inconnue.toPdfName()émet le littéral/Name.\NextPDF\Document\CollectionDictionary— construit le dictionnaireCollectiondu catalogue. Les constantesVIEW_DETAILS,VIEW_TILE,VIEW_HIDDEN,VIEW_CUSTOMetVIEW_NONEsélectionnent le mode de présentation ; le constructeur accepte aussi un nom de document initial et un tri optionnel.\NextPDF\Document\CollectionSort— l’objet valeur d’ordonnancement des colonnes pour un portfolio en vue détaillée.
Exemple de code — Démarrage rapide
Section intitulée « Exemple de code — Démarrage rapide »Cet exemple minimal joint à une page de facture un jeu de données au format valeurs séparées par des virgules (CSV, comma-separated values), en le déclarant avec la relation Source, comme la source à partir de laquelle la facture a été construite.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;use NextPDF\Navigation\AFRelationship;
$doc = Document::createStandalone();$doc->addPage();$doc->setFont('helvetica', 'B', 18);$doc->cell(0, 12, 'Invoice INV-2026-0042', newLine: true);
// Attach the line-item dataset the invoice was rendered from.$csv = "sku,qty,unit_price\nA-100,3,49.00\nB-220,1,180.00\n";$doc->embedFileFromString( data: $csv, filename: 'line-items.csv', description: 'Source line items for INV-2026-0042', afRelationship: AFRelationship::Source->value,);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/invoice-with-attachment.pdf');Le lecteur affiche line-items.csv dans son panneau de pièces jointes, et la relation l’identifie comme la source dont la facture dérive.
Exemple de code — Production
Section intitulée « Exemple de code — Production »Cet exemple complet joint un fichier depuis le disque et un jeu de données en mémoire, valide le chemin sur disque par rapport à un répertoire de base autorisé avant de le lire, et construit un portfolio triable sur les pièces jointes. Il intercepte les exceptions NextPDF les plus spécifiques susceptibles d’être levées par le chemin de pièce jointe, puis renvoie un code de sortie défini au lieu d’ignorer silencieusement l’échec.
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;use NextPDF\Document\CollectionDictionary;use NextPDF\Document\CollectionSort;use NextPDF\Exception\CompressionException;use NextPDF\Exception\InvalidConfigException;use NextPDF\Exception\PageLayoutException;use NextPDF\Navigation\AFRelationship;
/** * Resolve a caller-supplied filename against an allowed base directory. * * Rejects path traversal and stream wrappers so an embedded attachment can * never read outside the directory the application owns. Returns the * canonical absolute path, or null when the input escapes the base. * * @param non-empty-string $baseDir Absolute path to the allowed directory. * @param non-empty-string $userName Untrusted filename from the request. */function resolveWithinBase(string $baseDir, string $userName): ?string{ $base = \realpath($baseDir); if ($base === false) { return null; }
$candidate = \realpath($base . \DIRECTORY_SEPARATOR . \basename($userName)); if ($candidate === false || !\str_starts_with($candidate, $base . \DIRECTORY_SEPARATOR)) { return null; }
return $candidate;}
$attachmentsDir = __DIR__ . '/attachments';$requestedFile = 'timesheet-2026-05.pdf';
$safePath = resolveWithinBase($attachmentsDir, $requestedFile);if ($safePath === null) { \fwrite(\STDERR, "Rejected attachment path: outside the allowed directory\n"); exit(2);}
try { $doc = Document::createStandalone(); $doc->setTitle('Invoice INV-2026-0042 with supporting documents'); $doc->addPage(); $doc->setFont('helvetica', 'B', 18); $doc->cell(0, 12, 'Invoice INV-2026-0042', newLine: true);
// 1. A validated file from disk: the supporting timesheet. $doc->embedFile( $safePath, 'Timesheet supporting the billed hours', );
// 2. An in-memory dataset generated at runtime. $lineItems = "sku,qty,unit_price\nA-100,3,49.00\nB-220,1,180.00\n"; $doc->embedFileFromString( data: $lineItems, filename: 'line-items.csv', description: 'Machine-readable line items', afRelationship: AFRelationship::Data->value, );
// Present both attachments as a sortable details portfolio. The sort // keys reference columns declared in the portfolio /Schema; here the // built-in filename and modification-date fields order the view. $portfolio = new CollectionDictionary( view: CollectionDictionary::VIEW_DETAILS, initialDocument: 'line-items.csv', sort: new CollectionSort( keys: ['_Filename', '_ModDate'], ascending: [true, false], ), ); // $portfolio->toPdfDictionary() yields the catalog /Collection literal, // shared with the unencrypted-wrapper envelope path.
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/invoice-portfolio.pdf'; $doc->save($out);
echo "Wrote {$out} with 2 attachments and a details portfolio\n";} catch (PageLayoutException $e) { // Unreadable path, oversized file, null byte, or a MIME-type name that // exceeds the 127-byte PDF name limit. \fwrite(\STDERR, "Attachment rejected: {$e->getMessage()}\n"); exit(1);} catch (CompressionException | InvalidConfigException $e) { // The attachment data could not be compressed, or a config value was invalid. \fwrite(\STDERR, "Write failed: {$e->getMessage()}\n"); exit(1);}CollectionDictionary et CollectionSort sont des objets valeur. Ils valident leurs entrées à la construction et se sérialisent vers le littéral /Collection du catalogue, qui pilote la vue du portfolio dans le lecteur.
Cas limites & pièges
Section intitulée « Cas limites & pièges »- La saisie du chemin est ta responsabilité.
embedFile()se prémunit contre les octets nuls et les wrappers de flux, et résout le chemin réel, mais il n’impose pas de liste d’autorisation de répertoire de base. Quand le chemin provient d’une requête, valide-le d’abord, comme le fait l’exemple de production avecresolveWithinBase(). - Le plafond de 100 Mo s’applique uniquement à
embedFile(). Un fichier dépassant104,857,600octets lèvePageLayoutException. Pour des charges utiles plus grandes, traite les octets en flux toi-même et passe-les àembedFileFromString(). - Les noms de type MIME longs sont rejetés. Le type MIME détecté devient le
/Subtypedu flux intégré, un jeton de nom PDF limité à 127 octets par ISO 32000-2. Un type d’une longueur inhabituelle (certains formats Office approchent les 90 octets) reste bien en dessous de la limite, mais un type fourni manuellement qui la dépasse lèvePageLayoutException. Laisse le moteur détecter le type depuis l’extension, sauf si tu as une raison précise de le remplacer. - Une relation inconnue lève une exception.
AFRelationship::coerce()rejette toute valeur hors de l’ensemble fixe plutôt que de la rétrograder enUnspecified. Passe un cas d’énumération (AFRelationship::Source->value) pour éviter qu’une faute de frappe ne se propage jusqu’à l’exécution. - Les noms de fichier doivent être distincts dans la table de noms. Deux pièces jointes portant le même nom affiché entrent en collision dans l’index
EmbeddedFiles. Donne à chaque pièce jointe un nom de fichier unique. _ModDateest enregistré en temps universel coordonné (UTC, Coordinated Universal Time).embedFile()lit l’heure de modification du fichier et l’écrit avecgmdate()afin qu’une même fixture produise une date strictement identique d’une machine à l’autre, quel que soit le réglage du fuseau horaire.
Performance
Section intitulée « Performance »Chaque pièce jointe est compressée une fois avec gzcompress() au niveau 9 et écrite comme un flux unique au moment de save(). La compression domine le coût et évolue avec la taille de la charge utile jointe, pas avec le contenu de la page. Une poignée de petits fichiers justificatifs (jeux de données, tableurs, une feuille de temps PDF) reste dans le budget de 2000 ms / 64 Mo. Pour de nombreuses pièces jointes volumineuses, les octets intégrés constituent le plancher d’utilisation mémoire : une pièce jointe de 50 Mo conservée comme chaîne occupe au moins autant avant compression. Préfère embedFileFromString() avec une génération par blocs plutôt que de charger plusieurs gros fichiers à la fois.
La table de noms est construite une seule fois à save(). Jusqu’à 64 entrées restent dans une table plate à racine unique. Au-delà, NextPDF partitionne la table en plages Kids et Limits équilibrées, de sorte que le coût de l’index reste logarithmique pour de grands ensembles de pièces jointes.
Notes de sécurité
Section intitulée « Notes de sécurité »- Valide chaque chemin non fiable par rapport à une liste d’autorisation. L’intégration lit n’importe quel fichier que le processus PHP peut atteindre. Sans contrôle de répertoire de base, un nom de fichier forgé transforme la pièce jointe en inclusion de fichier local (LFI, Local File Inclusion). L’exemple de production montre le garde-fou par liste d’autorisation ; applique-le dès que le nom de fichier n’est pas une constante connue à la compilation.
- Traite les octets joints comme non fiables du côté qui les consomme. Un fichier intégré est opaque pour NextPDF. Le moteur ne l’analyse pas et ne l’exécute pas. Le risque réside là où le fichier est ouvert par la suite. Définis la relation et la description pour qu’un consommateur en aval sache ce que contient chaque pièce jointe avant de l’extraire.
- Aucun secret dans les pièces jointes ou les descriptions. Le nom de fichier, la description et les octets sont stockés en clair, sauf si le document entier est chiffré. Pour protéger une pièce jointe, chiffre le document avec une politique de permissions (voir le recipe connexe). N’intègre pas d’identifiants, de clés ou de données personnelles que tu ne placerais pas dans la page rendue.
- Aucun accès réseau n’a lieu dans ce recipe. Chaque octet est lu depuis le chemin local validé ou fourni en mémoire.
Conformité
Section intitulée « Conformité »| Déclaration | Spécification | Clause | reference_id |
|---|---|---|---|
Les flux de fichiers intégrés se rattachent au document via l’entrée EmbeddedFiles du dictionnaire de noms. | ISO 32000-2 | 7.11.4 | |
La table de noms EmbeddedFiles associe des noms à des spécifications de fichier dont l’entrée EF référence un flux de fichier intégré. | ISO 32000-2 | 7.7.4 | |
Un fichier associé requiert une valeur AFRelationship issue de l’ensemble fixe de PDF 2.0. | PDF Association AN002 | 3 | |
Le dictionnaire Collection du catalogue contrôle la présentation en portfolio des pièces jointes. | ISO 32000-2 | 7.11.6 |
Profil de reproductibilité — structurel. Le /ID du trailer, les atomes de date par enregistrement, et le /ModDate du flux intégré varient entre les exécutions, donc une comparaison structurelle les retire avant de différencier le graphe d’objets. Ce recipe décrit comment NextPDF produit la structure. Il n’affirme pas une conformité PDF/A-4f générale, qui dépend du document complet. Pour un profil d’archivage qui exige que chaque pièce jointe déclare une relation et une description, voir le recipe PDF/A-4.