Внедрение файлов и создание PDF-портфолио
В этом рецепте показано, как прикрепить один или несколько файлов к PDF и, если вложений несколько, оформить их как PDF-портфолио. Используйте его, когда документ должен содержать подтверждающие материалы в том же файле: счёт с табелем учёта рабочего времени, на котором он основан, спецификацию изделия с экспортом из системы автоматизированного проектирования (CAD) или архивную запись, где исходная таблица хранится рядом с отрисованным отчётом.
NextPDF даёт две точки входа на объекте документа. embedFile() читает файл с диска; embedFileFromString() внедряет байты из памяти, которые формируются во время выполнения. Оба метода регистрируют вложение. При вызове save() движок записывает каждое вложение как поток внедрённого файла, оборачивает его в словарь спецификации файла и связывает каждую спецификацию с деревом имён EmbeddedFiles на уровне документа. ISO 32000-2 определяет это дерево имён как место, где потоки внедрённых файлов прикрепляются к документу в целом через словарь имён.
Это функция Core без коммерческих ограничений. API вложений стабилен начиная с версии 1.0.0 и работает во всей матрице обратной совместимости 8.1-8.4.
Установка
Заголовок раздела «Установка»composer require nextpdf/core:^3Дополнительные расширения не требуются.
Концептуальный обзор
Заголовок раздела «Концептуальный обзор»Каждое вложение проходит через три структуры PDF. Понимание этих структур помогает проверять выходные данные и отлаживать файл, не соответствующий требованиям.
- Поток внедрённого файла. Необработанные байты прикреплённого файла, сжатые методом Flate и записанные как потоковый объект с
/Type, равным/EmbeddedFile. NextPDF записывает исходный размер, контрольную сумму MD5 и дату изменения в словарь параметров потока. Обнаруженный тип Multipurpose Internet Mail Extensions (MIME) кодируется как/Subtypeпотока. - Словарь спецификации файла. Обёртка для метаданных. В ней хранятся отображаемое имя файла (
/Fи Unicode-вариант/UF), понятное человеку описание (/Desc), ссылка на внедрённый поток (/EF) и отношение файла к содержащему его документу (/AFRelationship). - Дерево имён
EmbeddedFiles. Единый индекс на уровне документа, который сопоставляет имя каждого вложения его спецификации файла. ISO 32000-2 требует, чтобы каждая спецификация файла, достижимая через это дерево, содержала записьEF, значение которой ссылается на поток внедрённого файла. NextPDF строит и балансирует это дерево за вас при вызовеsave().
Значение отношения важно для соответствия требованиям. PDF Association Application Note 0002 указывает, что для связанного файла требуется запись AFRelationship, выбранная из фиксированного набора PDF 2.0: Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema или Unspecified. NextPDF представляет этот набор как перечисление AFRelationship и отклоняет любое другое значение. Выбирайте значение, которое объясняет, зачем файл присутствует: табель учёта рабочего времени, на котором основан счёт, — это Source; машиночитаемый набор данных, лежащий в основе диаграммы, — это Data.
Следующий уровень — PDF-портфолио (в ISO 32000-2 оно называется коллекцией). Когда в документе несколько вложений, словарь Collection в каталоге сообщает программе чтения, как их представить: сортируемая таблица сведений, плиточный макет или скрытый конверт. ISO 32000-2 описывает словарь Collection как средство управления тем, как обработчик PDF представляет вложенные файлы в виде упорядоченного портфолио. В NextPDF за это отвечает объект-значение CollectionDictionary, а CollectionSort задаёт порядок столбцов в представлении сведений.
Поверхность API
Заголовок раздела «Поверхность API»Методы уровня документа предоставляет примесь HasFileAttachments в \NextPDF\Core\Document:
embedFile(string $path, string $description = ''): static— читает файл из$pathи прикрепляет его. NextPDF определяет тип MIME по расширению; отношение по умолчанию равноUnspecified. Читает до 100 МБ; для полезных данных большего размера используйтеembedFileFromString(). Возвращает документ, чтобы можно было строить цепочку вызовов.embedFileFromString(string $data, string $filename, string $description = '', string $afRelationship = '/Unspecified'): static— прикрепляет байты из памяти под отображаемым именем$filename. Передайте литералAFRelationship(с ведущей косой чертой или без неё), чтобы задать отношение. Возвращает документ, чтобы можно было строить цепочку вызовов.
Вспомогательные типы находятся в пространствах имён \NextPDF\Navigation и \NextPDF\Document:
\NextPDF\Navigation\AFRelationship— перечисление восьми допустимых значений отношения.AFRelationship::coerce()нормализует строку или вариант перечисления и выбрасывает исключение при неизвестном значении.toPdfName()возвращает литерал/Name.\NextPDF\Document\CollectionDictionary— строит словарьCollectionв каталоге. КонстантыVIEW_DETAILS,VIEW_TILE,VIEW_HIDDEN,VIEW_CUSTOMиVIEW_NONEвыбирают режим представления; конструктор также принимает имя начального документа и необязательную сортировку.\NextPDF\Document\CollectionSort— объект-значение для упорядочивания столбцов в портфолио с представлением сведений.
Пример кода — быстрый старт
Заголовок раздела «Пример кода — быстрый старт»Этот минимальный пример прикрепляет сформированный набор данных в формате CSV (значения, разделённые запятыми) к странице со счётом и задаёт для него отношение Source, потому что на этих данных построен счёт.
<?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');В программе чтения line-items.csv появится на панели вложений, а отношение пометит его как источник счёта.
Пример кода — продакшен
Заголовок раздела «Пример кода — продакшен»Этот полный пример прикрепляет файл с диска и набор данных из памяти, перед чтением проверяет путь на диске относительно разрешённого базового каталога и строит сортируемое портфолио для вложений. Он перехватывает наиболее конкретные исключения NextPDF, которые может вызвать путь обработки вложения, а затем возвращает определённый код выхода вместо того, чтобы скрыть сбой.
<?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 и CollectionSort — это объекты-значения. Они проверяют входные данные при создании и сериализуются в литерал /Collection в каталоге, который управляет представлением портфолио в программе чтения.
Граничные случаи и подводные камни
Заголовок раздела «Граничные случаи и подводные камни»- Ввод пути — это ваша ответственность.
embedFile()защищает от нулевых байтов и потоковых обёрток и преобразует путь в реальный, но не проверяет его по списку разрешённых базовых каталогов. Когда путь приходит из запроса, сначала проверьте его, как это делает продакшен-пример с помощьюresolveWithinBase(). - Предел в 100 МБ применяется только к
embedFile(). Файл размером более104,857,600байт вызываетPageLayoutException. Для полезных данных большего размера самостоятельно сформируйте байты и передайте их вembedFileFromString(). - Длинные имена типов MIME отклоняются. Обнаруженный тип MIME становится
/Subtypeвнедрённого потока — токеном имени PDF, ограниченным 127 байтами в ISO 32000-2. Необычно длинный тип (некоторые форматы Office приближаются к 90 байтам) всё равно остаётся значительно ниже предела, но тип, заданный вручную и превышающий этот предел, вызываетPageLayoutException. Позвольте движку определить тип по расширению, если только у вас нет конкретной причины переопределить его. - Неизвестное отношение приводит к исключению.
AFRelationship::coerce()отклоняет любое значение вне фиксированного набора вместо того, чтобы понижать его доUnspecified. Передавайте вариант перечисления (AFRelationship::Source->value), чтобы опечатка не дошла до среды выполнения. - Имена файлов в дереве имён должны быть уникальны. Два вложения с одним и тем же отображаемым именем конфликтуют в индексе
EmbeddedFiles. Дайте каждому вложению уникальное имя файла. _ModDateзаписывается во всемирном координированном времени (UTC).embedFile()читает время изменения файла и записывает его с помощьюgmdate(), так что одна и та же фикстура даёт байт-в-байт идентичную дату на разных машинах, независимо от настройки часового пояса.
Производительность
Заголовок раздела «Производительность»Каждое вложение сжимается один раз с помощью gzcompress() на уровне 9 и записывается как единый поток при вызове save(). Основные затраты приходятся на сжатие и масштабируются с размером прикреплённых полезных данных, а не с содержимым страницы. Несколько небольших подтверждающих файлов (наборы данных, таблицы, табель учёта рабочего времени в PDF) остаются в пределах бюджета 2000 мс / 64 МБ. При большом числе крупных вложений нижнюю границу расхода памяти задают внедрённые байты: вложение размером 50 МБ, хранимое как строка, занимает как минимум столько же до сжатия. Предпочитайте embedFileFromString() с поблочным формированием данных вместо загрузки нескольких крупных файлов сразу.
Дерево имён строится один раз при вызове save(). До 64 записей остаются в плоском дереве с одним корнем. После этого NextPDF разбивает дерево на сбалансированные диапазоны Kids и Limits, так что затраты на индекс остаются логарифмическими для больших наборов вложений.
Замечания по безопасности
Заголовок раздела «Замечания по безопасности»- Проверяйте каждый недоверенный путь по списку разрешённых. Внедрение читает любой файл, до которого может добраться процесс PHP. Без проверки базового каталога специально подготовленное имя файла превращает внедрение во включение локального файла (LFI). Продакшен-пример показывает защиту на основе списка разрешённых; применяйте её всякий раз, когда имя файла не является константой времени компиляции.
- Относитесь к прикреплённым байтам как к недоверенным на стороне потребителя. Внедрённый файл непрозрачен для NextPDF. Движок не разбирает и не выполняет его. Риск возникает там, где файл позже открывают. Задайте отношение и описание, чтобы следующий потребитель знал, чем является каждое вложение, перед его извлечением.
- Никаких секретов во вложениях или описаниях. Имя файла, описание и байты хранятся в открытом виде, если только весь документ не зашифрован. Чтобы защитить вложение, зашифруйте документ с политикой разрешений (см. соответствующий рецепт). Не внедряйте учётные данные, ключи или персональные данные, которые вы не стали бы размещать на отрисованной странице.
- В этом рецепте нет сетевого доступа. Каждый байт читается из проверенного локального пути или предоставляется в памяти.
Соответствие требованиям
Заголовок раздела «Соответствие требованиям»| Утверждение | Спецификация | Пункт | reference_id (идентификатор ссылки) |
|---|---|---|---|
Потоки внедрённых файлов прикрепляются к документу через запись EmbeddedFiles в словаре имён. | ISO 32000-2 | 7.11.4 | |
Дерево имён EmbeddedFiles сопоставляет имена со спецификациями файлов, у которых запись EF ссылается на поток внедрённого файла. | ISO 32000-2 | 7.7.4 | |
Связанный файл требует значения AFRelationship из фиксированного набора PDF 2.0. | PDF Association AN002 (прикладная записка) | 3 | |
Словарь Collection в каталоге управляет представлением вложений в виде портфолио. | ISO 32000-2 | 7.11.6 |
Профиль воспроизводимости — структурный. Трейлерный /ID, атомы даты при каждом сохранении и /ModDate внедрённого потока различаются между запусками, поэтому структурное сравнение удаляет эти значения перед сравнением графа объектов. Этот рецепт описывает, как NextPDF формирует структуру. Он не утверждает полного соответствия PDF/A-4f, которое зависит от всего документа. Для архивного профиля, который требует, чтобы каждое вложение объявляло отношение и описание, см. рецепт PDF/A-4.