跳到內容

嵌入檔案並建立 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)是再上一層的結構。當一份文件帶有多個附件時,型錄的 Collection 字典會告訴閱讀器如何呈現它們:可排序的明細表、並排的圖磚版面,或一個隱藏的封套。ISO 32000-2 將 Collection 字典描述為 PDF 處理器用來把檔案附件呈現為有組織檔案集的控制項。NextPDF 會將它建模為 CollectionDictionary 值物件,並用 CollectionSort 決定明細檢視的欄位順序。

文件層級的方法(來自 \NextPDF\Core\Document 上的 HasFileAttachments 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 — 用來建立型錄的 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 都是值物件。它們會在建構時驗證輸入,並序列化為驅動閱讀器內檔案集檢視的型錄 /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
型錄的 Collection 字典控制附件的檔案集呈現方式。ISO 32000-27.11.6

可重現性側寫 — 結構性。 尾段的 /ID、各次儲存的日期原子,以及嵌入串流的 /ModDate 會在每次執行間變動,因此進行結構性比對時,會先剝除這些項目,再比對物件圖。這則 recipe 描述 NextPDF 如何產生這個結構。它並不主張全面的 PDF/A-4f 符規範性,那取決於整份文件。若需要一個要求每個附件都宣告關係與描述的封存側寫,請參見 PDF/A-4 recipe。