跳转到内容

嵌入文件并创建 PDF 文件集

这篇 recipe(范例)会将一个或多个文件附加到 PDF;当附件不止一个时,再将它们组织成 PDF 文件集。适用于需要把佐证材料随主文件一并交付的场景:随附基础工时表的发票、附带计算机辅助设计(CAD)导出文件的产品规格表,或把源电子表格与渲染报表一起归档的记录。

NextPDF 在文件对象上提供两个入口。embedFile() 从磁盘读取文件;embedFileFromString() 会嵌入你在运行时生成的内存字节。两者都会注册该附件。调用 save() 时,引擎会将每个附件写成一段嵌入文件流,包装到一个文件规格字典中,再把每个规格连接到文件级的 EmbeddedFiles 名称树。ISO 32000-2 将该名称树定义为通过名称字典把嵌入文件流整体附加到文件的位置。

这属于 Core 能力,不受任何商业授权限制。附件 API 自 1.0.0 起保持稳定,并可在 8.1-8.4 的 backport 兼容矩阵上运行。

Terminal window
composer require nextpdf/core:^3

不需要任何可选扩展。

一个附件会涉及三种 PDF 结构。了解这些结构,有助于你解读输出,并排查不符合规范的文件。

  1. 嵌入文件流。 附加文件的原始字节会以 Flate 压缩后写入一个流对象,其 /Type/EmbeddedFile。NextPDF 会在流的参数字典中记录原始大小、一组 MD5 校验和,以及修改日期。它会将检测到的多用途互联网邮件扩展(MIME)类型编码为流的 /Subtype
  2. 文件规格字典。 元数据的包装层。它包含显示用文件名(/F 与 Unicode 的 /UF)、一段可读描述(/Desc)、指向嵌入流的引用(/EF),以及该文件与主文件之间的关系(/AFRelationship)。
  3. EmbeddedFiles 名称树。 一个文件级索引(Index),将每个附件的名称映射到它的文件规格。ISO 32000-2 要求通过这棵树到达的每个文件规格都必须包含一个 EF 项目,其值引用一段嵌入文件流。NextPDF 会在 save() 时为你创建并平衡这棵树。

这个关系值是否符合规范很重要。PDF Association 应用须知 0002 指出,关联文件需要一个 AFRelationship 项目,并且必须从固定的 PDF 2.0 值集合中选择:SourceDataAlternativeSupplementEncryptedPayloadFormDataSchema,或 Unspecified。NextPDF 将该集合建模为 AFRelationship 枚举(enum),并拒绝任何其他值。请选择能够说明该文件为何存在的词:发票背后的工时表是 Source;图表背后的机器可读数据集是 Data

一个 PDF 文件集(即 collection,定义于 ISO 32000-2)是更上一层的结构。当一份文件带有多个附件时,文档 Catalog 的 Collection 字典会告诉阅读器如何呈现它们:可排序的明细表、并排的平铺布局,或隐藏的封套。ISO 32000-2 将 Collection 字典描述为 PDF 处理器用于将文件附件呈现为有组织文件集的控制项。NextPDF 将它建模为 CollectionDictionary 值对象,并用 CollectionSort 决定明细视图的字段顺序。

文件级方法(来自 HasFileAttachments 这个位于 \NextPDF\Core\Document 上的 concern):

  • embedFile(string $path, string $description = ''): static — 从 $path 读取并附加文件。MIME 类型会根据扩展名检测;关系默认为 Unspecified。最多读取 100 MB;更大的载荷请改用 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 — 创建文档 Catalog 的 Collection 字典。VIEW_DETAILSVIEW_TILEVIEW_HIDDENVIEW_CUSTOMVIEW_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);
}

CollectionDictionaryCollectionSort 都是值对象。它们会在构造时验证输入,并序列化为驱动阅读器中文件集视图的 Catalog /Collection 字面值。

  • 路径输入由你负责。 embedFile() 会防范空字节与流包装器,并 resolve(解析)出真实路径,但它不会强制执行基底目录允许列表。当路径来自请求时,请先验证它,就像生产环境示例中的 resolveWithinBase() 那样。
  • 100 MB 上限只适用于 embedFile() 超过 104,857,600 字节的文件会引发 PageLayoutException。更大的载荷请自行以流式方式提供字节,并将它们传给 embedFileFromString()
  • 过长的 MIME 类型名称会被拒绝。 检测到的 MIME 类型会成为嵌入流的 /Subtype,这是一个受 ISO 32000-2 限制、最长 127 字节的 PDF 名称令牌。不常见的长类型(某些 Office 格式接近 90 字节)仍远低于上限,但手动提供且超出上限的类型会引发 PageLayoutException。除非你有特定理由要覆盖,否则让引擎根据扩展名检测类型。
  • 未知关系会抛出异常。 AFRelationship::coerce() 会拒绝固定集合以外的任何值,而不是降级为 Unspecified。请传入一个枚举项(AFRelationship::Source->value),避免拼写错误的值流到运行时。
  • 名称树中的文件名必须互不相同。 两个显示名称相同的附件会在 EmbeddedFiles 索引中冲突。请为每个附件提供唯一的文件名。
  • _ModDate 以世界协调时间(UTC)记录。 embedFile() 会读取文件的修改时间,并用 gmdate() 写入,使同一份 fixture 无论时区设置如何,都能在不同机器上生成完全相同的日期。

每个附件都会用 gzcompress() 以等级 9 压缩一次,并在 save() 时写成单一流。压缩主导整体成本,并随附加载荷的大小扩展,而不是随页面内容扩展。少数几个小型支撑文件(数据集、电子表格、一份工时表 PDF)会落在 2000 ms / 64 MB 的预算之内。对于许多大型附件,嵌入字节就是内存下限:一个以字符串保存的 50 MB 附件,在压缩前至少占用同等空间。与其一次载入多个大型文件,不如优先采用配合分块生成的 embedFileFromString()

名称树只会在 save() 时创建一次。最多 64 个项目会保持为单一根节点的扁平树。超过之后,NextPDF 会将树拆分成平衡的 KidsLimits 范围,使大型附件集的索引成本仍保持在对数级。

  • 每个未受信任的路径都必须与允许列表比对验证。 嵌入操作会读取 PHP 程序可访问的任何文件。缺少基底目录检查时,一个精心构造的文件名就会让附件变成本地文件包含(LFI)。生产环境示例展示了允许列表防护;只要文件名不是编译期常量,就应应用它。
  • 在客户端要把附加的字节视为未受信任。 嵌入文件对 NextPDF 而言是不透明的。引擎不会解析或执行它。风险存在于该文件之后被打开的地方。请设置关系与描述,让下游使用方在提取附件前就知道每个附件是什么。
  • 附件或描述中不要放机密。 除非整份文件已加密,否则文件名、描述与字节都会以明文存储。若要保护附件,请使用权限策略加密文件(参见相关 recipe)。不要嵌入你不会放进渲染页面的凭证、密钥或个人数据。
  • 这篇 recipe 不会发生任何网络访问。 每个字节都来自经过验证的本地路径,或在内存中提供。
陈述规范条款参考 ID
嵌入文件流通过名称字典中的 EmbeddedFiles 项目附加到文件。ISO 32000-27.11.4
这个 EmbeddedFiles 名称树会将名称映射到文件规格,其 EF 项目引用一段嵌入文件流。ISO 32000-27.7.4
关联文件需要一个来自固定 PDF 2.0 集合的 AFRelationship 值。PDF Association AN002 应用须知3
文档 Catalog 的 Collection 字典控制附件的文件集呈现方式。ISO 32000-27.11.6

可复现性配置 — 结构性。 尾部的 /ID、每次保存的日期原子,以及嵌入流的 /ModDate 会在每次运行之间变化,因此结构性比对会在比对对象图之前先剥离这些内容。这篇 recipe 描述 NextPDF 如何生成这个结构。它并不主张全面符合 PDF/A-4f,是否符合取决于整份文件。若需要一个要求每个附件都声明关系与描述的归档配置,请参见 PDF/A-4 recipe。