嵌入檔案並建立 PDF 檔案集
這則 recipe(範例)會將一個或多個檔案附加到 PDF;若有多個附件,還會將它們整理成 PDF 檔案集。當文件需要把佐證資料一併帶在同一個檔案中時,就適合使用:隨附底層工時表的發票、夾帶電腦輔助設計(CAD)匯出檔的產品規格表,或是把來源試算表放在算繪報表旁邊的封存紀錄。
NextPDF 在文件物件上提供兩個進入點。embedFile() 會從磁碟讀取檔案;embedFileFromString() 會嵌入你在執行期產生的記憶體位元組。兩者都會註冊該附件。在 save() 時,引擎會把每個附件寫成一段嵌入檔案串流,包進一個檔案規格字典,再把每個規格連進文件層級的 EmbeddedFiles 名稱樹。ISO 32000-2 將該名稱樹定義為嵌入檔案串流透過名稱字典附加到文件的位置。
這是 Core 的能力,沒有任何商業授權限制。附件 API 自 1.0.0 起就維持穩定,並可在 8.1-8.4 的 backport 相容矩陣上執行。
composer require nextpdf/core:^3不需要任何選用的擴充功能。
概念總覽
標題為「概念總覽」的區段一個附件會經過三種 PDF 結構。理解這些結構有助於你判讀輸出,並排查不符規範的檔案。
- 嵌入檔案串流。 附加檔案的原始位元組會以 Flate 壓縮,並寫成串流物件,其
/Type為/EmbeddedFile。NextPDF 會在串流的參數字典中記錄原始大小、一組 MD5 檢查碼,以及修改日期。它也會將偵測到的多用途網際網路郵件延伸(MIME)類型編碼為串流的/Subtype。 - 檔案規格字典。 中繼資料的包裝層。它帶有顯示用的檔名(
/F與 Unicode 的/UF)、人類可讀的描述(/Desc)、對嵌入串流的參照(/EF),以及該檔案與主文件之間的關係(/AFRelationship)。 EmbeddedFiles名稱樹。 文件層級的 Index(索引),會把每個附件的名稱對映到它的檔案規格。ISO 32000-2 要求透過這棵樹到達的每個檔案規格,都必須帶有一個EF項目,其值參照一段嵌入檔案串流。NextPDF 會在save()時為你建立並平衡這棵樹。
這個關係值對是否符規範很重要。PDF Association 應用須知 0002 指出,相關聯檔案需要一個 AFRelationship 項目,並從固定的 PDF 2.0 集合中挑選:Source、Data、Alternative、Supplement、EncryptedPayload、FormData、Schema,或 Unspecified。NextPDF 會將該集合建模為 AFRelationship 列舉(enum),並拒絕任何其他值。請選擇能描述該檔案存在目的的詞:發票背後的工時表是 Source;圖表背後的機器可讀資料集是 Data。
一個 PDF 檔案集(即 collection,定義於 ISO 32000-2)是再上一層的結構。當一份文件帶有多個附件時,型錄的 Collection 字典會告訴閱讀器如何呈現它們:可排序的明細表、並排的圖磚版面,或一個隱藏的封套。ISO 32000-2 將 Collection 字典描述為 PDF 處理器用來把檔案附件呈現為有組織檔案集的控制項。NextPDF 會將它建模為 CollectionDictionary 值物件,並用 CollectionSort 決定明細檢視的欄位順序。
API 介面
標題為「API 介面」的區段文件層級的方法(來自 \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_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()會防範空位元組與串流包裝器,並 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 會把樹分割成平衡的 Kids 與 Limits 範圍,使大型附件集的索引成本仍維持對數等級。
安全性須知
標題為「安全性須知」的區段- 每一條未受信任的路徑都必須比對允許清單做驗證。 嵌入會讀取 PHP 程序所能觸及的任何檔案。如果缺少基底目錄檢查,一個精心構造的檔名就會把附件變成本機檔案引入(LFI)。正式環境範例展示了允許清單防護;只要檔名不是編譯期常數,就請套用它。
- 在使用端要把附加的位元組視為未受信任。 嵌入檔案對 NextPDF 而言是不透明的。引擎不會剖析或執行它。風險存在於該檔案稍後被開啟之處。請設定關係與描述,讓下游使用端在抽取附件前就知道每個附件是什麼。
- 附件或描述中不要放機密。 除非整份文件已加密,否則檔名、描述與位元組都會以明文儲存。若要保護附件,請以權限政策加密文件(參見相關 recipe)。不要嵌入你不會放進算繪頁面的憑證、金鑰或個人資料。
- 這則 recipe 不會發生任何網路存取。 每個位元組都是從驗證過的本機路徑讀取,或在記憶體中提供。
符規範性
標題為「符規範性」的區段| 陳述 | 規範 | 條款 | 參考 ID |
|---|---|---|---|
嵌入檔案串流透過名稱字典中的 EmbeddedFiles 項目附加到文件。 | ISO 32000-2 | 7.11.4 | |
這棵 EmbeddedFiles 名稱樹會把名稱對映到檔案規格,其 EF 項目參照一段嵌入檔案串流。 | ISO 32000-2 | 7.7.4 | |
相關聯檔案需要一個來自固定 PDF 2.0 集合的 AFRelationship 值。 | PDF Association AN002 應用須知 | 3 | |
型錄的 Collection 字典控制附件的檔案集呈現方式。 | ISO 32000-2 | 7.11.6 |
可重現性側寫 — 結構性。 尾段的 /ID、各次儲存的日期原子,以及嵌入串流的 /ModDate 會在每次執行間變動,因此進行結構性比對時,會先剝除這些項目,再比對物件圖。這則 recipe 描述 NextPDF 如何產生這個結構。它並不主張全面的 PDF/A-4f 符規範性,那取決於整份文件。若需要一個要求每個附件都宣告關係與描述的封存側寫,請參見 PDF/A-4 recipe。