콘텐츠로 이동

파일 첨부 및 PDF 포트폴리오 생성

이 레시피는 하나 이상의 파일을 PDF에 첨부하고, 첨부 파일이 여러 개이면 PDF 포트폴리오로 구성하는 방법을 다룹니다. 문서가 근거 자료를 같은 파일 안에 함께 담아야 할 때 사용합니다. 예를 들어 근거가 되는 근무 시간표를 함께 제공하는 청구서, CAD(Computer-Aided Design) 내보내기 파일을 묶은 제품 데이터시트, 또는 렌더링된 보고서와 원본 스프레드시트를 함께 보관하는 아카이브 기록 등이 있습니다.

NextPDF는 문서 객체에 두 개의 진입점을 제공합니다. embedFile()는 디스크에서 파일을 읽고, embedFileFromString()는 런타임에 생성한 메모리 내 바이트를 임베드합니다. 두 메서드 모두 첨부 파일을 등록합니다. save() 시점에 엔진은 각 항목을 임베드 파일 스트림으로 작성하고, 이를 파일 사양 딕셔너리로 감싼 뒤, 모든 사양을 문서 수준의 EmbeddedFiles 이름 트리에 연결합니다. ISO 32000-2는 이 이름 트리를 임베드 파일 스트림이 이름 딕셔너리를 통해 문서 전체에 연결되는 지점으로 정의합니다.

이 기능은 상업적 제한 없이 사용할 수 있는 Core 기능입니다. 첨부 파일 API(Application Programming Interface)는 1.0.0 이후 안정적이며 8.1-8.4 백포트 매트릭스 전체에서 동작합니다.

Terminal window
composer require nextpdf/core:^3

선택 확장은 필요 없습니다.

첨부 파일은 세 가지 PDF 구조를 거칩니다. 이를 이해하면 출력을 해석하고 준수하지 않는 파일을 디버깅하는 데 도움이 됩니다.

  1. 임베드 파일 스트림. 첨부 파일의 원시 바이트입니다. Flate로 압축되며 /Type/EmbeddedFile인 스트림 객체로 작성됩니다. NextPDF는 원본 크기, MD5 체크섬, 수정 날짜를 스트림의 매개변수 딕셔너리에 기록합니다. 감지한 MIME(Multipurpose Internet Mail Extensions) 유형은 스트림 /Subtype으로 인코딩됩니다.
  2. 파일 사양 딕셔너리. 메타데이터 래퍼입니다. 여기에는 표시용 파일명(/F 및 유니코드 /UF), 사람이 읽을 수 있는 설명(/Desc), 임베드 스트림에 대한 참조(/EF), 그리고 파일이 호스트 문서에 대해 갖는 관계(/AFRelationship)가 담깁니다.
  3. EmbeddedFiles 이름 트리. 각 첨부 파일의 이름을 해당 파일 사양에 매핑하는 문서 수준의 단일 인덱스입니다. ISO 32000-2는 이 트리를 통해 도달하는 모든 파일 사양이, 임베드 파일 스트림을 참조하는 값을 갖는 EF 항목을 포함하도록 요구합니다. NextPDF는 save() 시점에 이 트리를 자동으로 구축하고 균형을 맞춥니다.

관계 값은 준수 측면에서 중요합니다. PDF Association Application Note 0002에 따르면 연관 파일에는 고정된 PDF 2.0 집합에서 선택한 AFRelationship 항목이 필요합니다. 즉 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를 사용합니다.

문서 수준 메서드(HasFileAttachments concern에서 제공되며, 이 concern은 \NextPDF\Core\Document에 있음):

  • 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(comma-separated values) 데이터셋을 청구서 페이지에 첨부하고, 이 데이터셋을 청구서가 작성된 기반인 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()은 널 바이트와 스트림 래퍼를 방어하고 실제 경로를 해석하지만, 기준 디렉터리 허용 목록까지 강제하지는 않습니다. 요청에서 받은 경로라면 프로덕션 샘플이 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()로 기록하므로, 동일한 픽스처는 시간대 설정과 관계없이 머신 전반에서 바이트 단위로 동일한 날짜를 생성합니다.

각 첨부 파일은 gzcompress()로 레벨 9에서 한 번 압축되며 save() 시점에 단일 스트림으로 작성됩니다. 압축이 비용의 대부분을 차지하며, 페이지 콘텐츠가 아니라 첨부된 페이로드 크기에 따라 늘어납니다. 소수의 작은 보조 파일(데이터셋, 스프레드시트, 근무 시간표 PDF)은 2000 ms / 64 MB 예산 안에 들어옵니다. 다수의 대용량 첨부 파일의 경우, 임베드 바이트가 최소 메모리 사용량이 됩니다. 문자열로 보유한 50 MB 첨부 파일은 압축 전에 최소한 그만큼의 메모리를 차지합니다. 여러 대용량 파일을 한 번에 로드하는 것보다 청크 단위 생성과 함께 embedFileFromString()을 사용하는 것이 좋습니다.

이름 트리는 save() 시점에 한 번 구축됩니다. 최대 64개 항목까지는 평면 단일 루트 트리로 유지됩니다. 이를 넘으면 NextPDF는 트리를 균형 잡힌 KidsLimits 범위로 분할하므로, 대규모 첨부 파일 집합에서도 인덱스 비용이 로그 수준으로 유지됩니다.

  • 신뢰할 수 없는 모든 경로를 허용 목록에 대해 검증하십시오. 임베딩 과정에서는 PHP 프로세스가 접근할 수 있는 모든 파일을 읽습니다. 기준 디렉터리 검사가 없으면 조작된 파일명이 첨부를 로컬 파일 포함(LFI, Local File Inclusion)으로 바꿔버립니다. 프로덕션 샘플은 허용 목록 가드를 보여줍니다. 파일명이 컴파일 타임 상수가 아닐 때마다 이를 적용하십시오.
  • 첨부된 바이트를 소비 측에서 신뢰할 수 없는 것으로 취급하십시오. 임베드 파일은 NextPDF에는 불투명합니다. 엔진은 이를 파싱하거나 실행하지 않습니다. 위험은 파일이 나중에 열리는 지점에 있습니다. 다운스트림 소비자가 추출하기 전에 각 첨부 파일이 무엇인지 알 수 있도록 관계와 설명을 설정하십시오.
  • 첨부 파일이나 설명에 비밀 정보를 넣지 마십시오. 문서 전체가 암호화되지 않는 한 파일명, 설명, 바이트는 평문으로 저장됩니다. 첨부 파일을 보호하려면 권한 정책으로 문서를 암호화하십시오(관련 레시피 참조). 렌더링된 페이지에 두지 않을 자격 증명, 키 또는 개인 데이터는 임베드하지 마십시오.
  • 이 레시피에서는 네트워크 접근이 발생하지 않습니다. 모든 바이트는 검증된 로컬 경로에서 읽히거나 메모리에서 제공됩니다.
명제사양reference_id (참조 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는 실행마다 달라지므로, 구조적 비교에서는 객체 그래프를 비교하기 전에 이들을 제거합니다. 이 레시피는 NextPDF가 이 구조를 어떻게 생성하는지 설명합니다. 전체 문서에 좌우되는 포괄적인 PDF/A-4f 준수를 단언하지는 않습니다. 모든 첨부 파일이 관계와 설명을 선언하도록 요구하는 아카이브 프로파일에 대해서는 PDF/A-4 레시피를 참조하십시오.