Ir al contenido

Incrustar archivos y crear portafolios PDF

Esta receta adjunta uno o más archivos a un PDF y, cuando hay varios adjuntos, los organiza como un portafolio PDF. Se usa cuando un documento necesita incluir en el mismo archivo la evidencia que lo respalda: una factura que incorpora el registro de horas subyacente, una ficha técnica de producto que agrupa una exportación de diseño asistido por computadora (CAD) o un registro de archivo que conserva la hoja de cálculo de origen junto al informe generado.

NextPDF ofrece dos puntos de entrada en el objeto del documento. embedFile() lee un archivo del disco; embedFileFromString() incrusta bytes en memoria generados en tiempo de ejecución. Ambos registran el adjunto. Al llamar a save(), el motor escribe cada uno como un flujo de archivo incrustado, lo envuelve en un diccionario de especificación de archivo y enlaza cada especificación con el árbol de nombres EmbeddedFiles a nivel de documento. ISO 32000-2 define ese árbol de nombres como el lugar donde los flujos de archivo incrustado se adjuntan al documento en su conjunto a través del diccionario de nombres.

Esta es una capacidad de Core sin restricción comercial. La interfaz de programación de aplicaciones (API) de adjuntos es estable desde la versión 1.0.0 y funciona en toda la matriz de versiones con backport 8.1-8.4.

Ventana de terminal
composer require nextpdf/core:^3

No se requiere ninguna extensión opcional.

Un adjunto recorre tres estructuras del PDF. Conocerlas ayuda a leer la salida y a depurar un archivo no conforme.

  1. Flujo de archivo incrustado. Los bytes sin procesar del archivo adjunto, comprimidos con Flate y escritos como un objeto de flujo cuyo /Type es /EmbeddedFile. NextPDF registra el tamaño original, una suma de comprobación MD5 y la fecha de modificación en el diccionario de parámetros del flujo. Codifica el tipo MIME detectado (extensiones multipropósito de correo de internet) como el /Subtype del flujo.
  2. Diccionario de especificación de archivo. Es el envoltorio de metadatos. Incluye el nombre de archivo visible (/F y el /UF en Unicode), una descripción legible por humanos (/Desc), una referencia al flujo incrustado (/EF) y la relación que el archivo mantiene con el documento contenedor (/AFRelationship).
  3. Árbol de nombres EmbeddedFiles. Es un índice único a nivel de documento que asigna el nombre de cada adjunto a su especificación de archivo. ISO 32000-2 exige que toda especificación de archivo alcanzada a través de este árbol lleve una entrada EF cuyo valor haga referencia a un flujo de archivo incrustado. NextPDF construye y equilibra este árbol automáticamente en save().

El valor de la relación importa para la conformidad. La nota de aplicación 0002 de la PDF Association establece que un archivo asociado requiere una entrada AFRelationship elegida del conjunto fijo de PDF 2.0: Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema o Unspecified. NextPDF modela ese conjunto como el enum AFRelationship y rechaza cualquier otro valor. Debe elegirse el término que describa por qué está presente el archivo: un registro de horas que respalda una factura es Source; un conjunto de datos legible por máquina detrás de un gráfico es Data.

Un portafolio PDF (llamado colección en ISO 32000-2) es la siguiente capa. Cuando un documento lleva varios adjuntos, el diccionario Collection del catálogo indica al lector cómo presentarlos: una tabla de detalles ordenable, un diseño de mosaicos o un sobre oculto. ISO 32000-2 describe el diccionario Collection como el control que usa un procesador de PDF para presentar los archivos adjuntos como un portafolio organizado. NextPDF modela esto como el objeto de valor CollectionDictionary, con CollectionSort para el orden de columnas de una vista de detalles.

Métodos a nivel de documento (del concern HasFileAttachments en \NextPDF\Core\Document):

  • embedFile(string $path, string $description = ''): static — lee un archivo desde $path y lo adjunta. El tipo MIME se detecta a partir de la extensión; la relación usa de forma predeterminada el valor Unspecified. Lee hasta 100 MB; para cargas más grandes, se debe usar embedFileFromString(). Devuelve el documento para encadenar.
  • embedFileFromString(string $data, string $filename, string $description = '', string $afRelationship = '/Unspecified'): static — adjunta bytes en memoria con el nombre visible $filename. Permite pasar un literal AFRelationship (con o sin la barra inicial) para establecer la relación. Devuelve el documento para encadenar.

Los tipos de apoyo (espacios de nombres \NextPDF\Navigation y \NextPDF\Document):

  • \NextPDF\Navigation\AFRelationship — el enum de los ocho valores de relación válidos. AFRelationship::coerce() normaliza una cadena o un caso de enum y lanza una excepción ante un valor desconocido. toPdfName() emite el literal /Name.
  • \NextPDF\Document\CollectionDictionary — construye el diccionario Collection del catálogo. Las constantes VIEW_DETAILS, VIEW_TILE, VIEW_HIDDEN, VIEW_CUSTOM y VIEW_NONE seleccionan el modo de presentación; el constructor también acepta un nombre de documento inicial y un orden opcional.
  • \NextPDF\Document\CollectionSort — el objeto de valor para el orden de columnas de un portafolio en vista de detalles.

Este ejemplo mínimo adjunta a una página de factura un conjunto de datos generado en formato de valores separados por comas (CSV) y lo declara como los datos de origen Source a partir de los cuales se construyó la factura.

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

El lector muestra line-items.csv en su panel de adjuntos, y la relación lo marca como el origen del que deriva la factura.

Este ejemplo completo adjunta un archivo del disco y un conjunto de datos en memoria, valida la ruta del disco frente a un directorio base permitido antes de leerla y construye un portafolio ordenable sobre los adjuntos. Captura las excepciones de NextPDF más específicas que puede lanzar el proceso de adjunto y luego devuelve un código de salida definido en lugar de ocultar el fallo.

<?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 y CollectionSort son objetos de valor. Validan sus entradas durante la construcción y se serializan al literal /Collection del catálogo que controla la vista de portafolio en el lector.

  • La ruta de entrada es responsabilidad de la aplicación. embedFile() protege contra bytes nulos y envoltorios de flujo, y resuelve la ruta real, pero no impone por sí solo un directorio base permitido. Si la ruta proviene de una solicitud, debe validarse primero, como hace el ejemplo de producción con resolveWithinBase().
  • El límite de 100 MB se aplica solo a embedFile(). Un archivo de más de 104,857,600 bytes lanza PageLayoutException. Para cargas más grandes, se deben transmitir los bytes directamente y pasarlos a embedFileFromString().
  • Los nombres de tipo MIME demasiado largos se rechazan. El tipo MIME detectado se convierte en el /Subtype del flujo incrustado, un token de nombre PDF acotado a 127 bytes por ISO 32000-2. Un tipo inusualmente largo (algunos formatos de Office se acercan a los 90 bytes) se mantiene muy por debajo del límite, pero un tipo proporcionado manualmente que lo supere lanza PageLayoutException. Conviene dejar que el motor detecte el tipo a partir de la extensión, salvo que haya una razón concreta para anularlo.
  • Una relación desconocida lanza una excepción. AFRelationship::coerce() rechaza cualquier valor fuera del conjunto fijo en lugar de degradarlo a Unspecified. Pasar un caso de enum (AFRelationship::Source->value) evita que un error tipográfico llegue al tiempo de ejecución.
  • Los nombres de archivo deben ser distintos en el árbol de nombres. Dos adjuntos con el mismo nombre visible colisionan en el índice EmbeddedFiles. Debe asignarse a cada adjunto un nombre de archivo único.
  • _ModDate se registra en tiempo universal coordinado (UTC). embedFile() lee la hora de modificación del archivo y la escribe con gmdate() para que el mismo fixture produzca una fecha idéntica byte a byte entre máquinas, sin importar la configuración de la zona horaria.

Cada adjunto se comprime una sola vez con gzcompress() en el nivel 9 y se escribe como un único flujo en save(). La compresión domina el costo y escala con el tamaño de la carga adjunta, no con el contenido de la página. Un conjunto reducido de archivos de apoyo pequeños (conjuntos de datos, hojas de cálculo o un registro de horas en PDF) se mantiene dentro del presupuesto de 2000 ms / 64 MB. Para muchos adjuntos grandes, los bytes incrustados marcan el mínimo de memoria: un adjunto de 50 MB retenido como una cadena ocupa al menos esa cantidad antes de la compresión. Conviene preferir embedFileFromString() con generación por fragmentos en lugar de cargar varios archivos grandes a la vez.

El árbol de nombres se construye una sola vez en save(). Hasta 64 entradas permanecen en un árbol plano de raíz única. Por encima de ese umbral, NextPDF particiona el árbol en rangos equilibrados Kids y Limits, de modo que el costo del índice se mantiene logarítmico para conjuntos grandes de adjuntos.

  • Validar cada ruta no confiable contra una lista de permitidos. La incrustación lee cualquier archivo que el proceso de PHP pueda alcanzar. Sin una comprobación de directorio base, un nombre de archivo manipulado convierte la incrustación en una inclusión de archivo local (LFI). El ejemplo de producción muestra la protección con lista de permitidos; debe aplicarse siempre que el nombre de archivo no sea una constante de tiempo de compilación.
  • Tratar los bytes adjuntos como no confiables en el lado que los consume. Un archivo incrustado es opaco para NextPDF. El motor no lo analiza ni lo ejecuta. El riesgo está donde el archivo se abre más tarde. Establecer la relación y la descripción permite que un consumidor aguas abajo sepa qué es cada adjunto antes de extraerlo.
  • Sin secretos en los adjuntos ni en las descripciones. El nombre de archivo, la descripción y los bytes se almacenan en claro a menos que se cifre todo el documento. Para proteger un adjunto, debe cifrarse el documento con una política de permisos (consulta la receta relacionada). No incrustar credenciales, claves ni datos personales que no se pondrían en la página generada.
  • Esta receta no realiza ningún acceso a la red. Cada byte se lee desde la ruta local validada o se proporciona en memoria.
DeclaraciónEspecificaciónCláusulareference_id
Los flujos de archivo incrustado se adjuntan al documento a través de la entrada EmbeddedFiles del diccionario de nombres.ISO 32000-27.11.4
El árbol de nombres EmbeddedFiles asigna nombres a especificaciones de archivo cuya entrada EF referencia un flujo de archivo incrustado.ISO 32000-27.7.4
Un archivo asociado requiere un valor AFRelationship del conjunto fijo de PDF 2.0.PDF Association AN0023
El diccionario Collection del catálogo controla la presentación de los adjuntos como portafolio.ISO 32000-27.11.6

Perfil de reproducibilidad — estructural. El /ID del tráiler, los átomos de fecha de cada guardado y el /ModDate del flujo incrustado varían entre ejecuciones, por lo que una comparación estructural los elimina antes de comparar el grafo de objetos. Esta receta describe cómo NextPDF produce la estructura. No afirma una conformidad general con PDF/A-4f, que depende del documento completo. Para un perfil de archivo que exija que cada adjunto declare una relación y una descripción, consulta la receta de PDF/A-4.