Delphi에서 PDF/A-3 문서에 소스 파일을 첨부하려면, PDFium Component는 PDF 2.0 associated-file 체인을 기록합니다. MIME /Subtype을 실은 임베디드 파일 스트림, /AFRelationship을 지닌 파일 명세, 그리고 카탈로그나 페이지에 걸린 /AF 배열입니다. InjectAssociateFiles와 TPdf.SaveAsWithAssociateFiles는 그 체인을 한 번의 증분 업데이트로 만들고, v3.121.2부터 MIME 타입은 올바르게 이스케이프된 PDF 이름 하나로 직렬화됩니다. 이 글의 나머지는 검증기가 무엇을 검사하는지, text/plain을 부순 한 글자 버그, 그리고 오래된 릴리스가 요청받은 것과 다른 일을 조용히 했던 자리들을 다룹니다
PDF/A-3 associated file에는 실제로 무엇이 필요할까요?
PDF/A-3 첨부 파일은 세 개체가 서로 합의할 때만 검증을 통과합니다. 임베디드 파일 스트림이 /Type /EmbeddedFile과 MIME /Subtype을 선언하고, 파일 명세 사전(ISO 32000-2 §7.11.3)이 /F, /UF, /EF, /AFRelationship을 지니며, 문서 안의 무언가가 그 파일 명세를 /AF 배열(ISO 32000-2 §14.13)로 참조합니다. TPdf.CreateAttachment가 하는 것 같은 /Names /EmbeddedFiles 트리를 통한 평범한 임베딩은 연관 필드를 전혀 set하지 않습니다. PDFium Component의 자체 PDF/A-3b 검증 피쳐가 이 의존성을 구체적으로 보여 줍니다. /AFRelationship 키만 바꾸면 파일은 ISO 19005-3 절 6.8의 정확히 한 규칙에서 떨어지고, MIME /Subtype만 빼면 다른 6.8 규칙이 떨어지며, 같은 첨부를 PDF/A-1b 후보에 넣으면 메타데이터가 얼마나 말쑥하든 그대로 거부됩니다. PDF/A-1은 임베디드 파일을 금지하니까요
relationship 값은 사람들이 감으로 추측하는 부분입니다. FPdfAssocFiles의 TPdfAFRelationship은 인젝터가 내보낼 수 있는 각 이름 토큰에 열거 멤버를 하나씩 매핑하는데, 처음 다섯만 ISO 19005-3이 인식하는 부분집합에 속합니다:
afSource→/Source: 워드프로세서 파일이나 스프레드시트처럼 PDF가 만들어진 원본afData→/Data: 보이는 콘텐츠가 파생되었거나 표현하는 기계 판독 가능 데이터afAlternative→/Alternative,afSupplement→/Supplement,afUnspecified→/UnspecifiedafEncryptedPayload,afFormData,afTemplate: PDF/A-3 부분집합 밖에 있는 PDF 2.0 추가분이므로 아카이브 출력에는 넣지 마세요
/Subtype /text/plain이 검증을 부순 이유는?
MIME 버그는 준수 간극이 아니라 토크나이즈 오류였습니다. v3.121.2 이전 인젝터는 호출자의 문자열을 슬래시 뒤에 곧바로 이어 붙여 /Subtype /text/plain을 만들었습니다. PDF 문법에서 두 번째 슬래시는 새 name 개체(ISO 32000-1 §7.3.5)를 시작하므로, 스트림 사전은 갑자기 키 /Subtype, 이름 /text, 그리고 키-값 쌍의 균형을 깨뜨리는 매달린 이름 /plain을 담게 됩니다. 독립 PDF/A 검증기는 EmbeddedFile 사전을 파싱하다가, 즉 PDF/A 규칙에 도달하기도 전에 파일을 거부했고, 그래서 실패가 빠진 첨부 속성처럼이 아니라 파일 손상처럼 보였습니다
수정은 MIME 값을 EscapePdfName으로 보내 /text#2Fplain을 내놓게 합니다. 디코딩하면 text/plain인 이름 하나입니다. 이스케이핑은 일부러 슬래시보다 넓습니다. 32 이하의 모든 바이트(공백, 탭, CR, LF), 127 이상의 모든 바이트, 구분자 ()<>[]{}/%, 그리고 # 이스케이프 문자 자체가 #XX가 됩니다. 슬래시만 이스케이프했다면 다른 구멍이 남았을 것입니다. >>나 공백을 담은 MIME 문자열은 사전을 일찍 닫거나 추가 키를 주입할 수 있으므로, 회귀 테스트는 모든 구분자에 탭, LF, CR을 더한 적대적 값을 먹이고 정확한 인코딩 출력을 검사합니다
// 인젝터가 MIMEType = 'text/plain'에 대해 쓰는 것
// v3.121.2 이전: /Type /EmbeddedFile /Subtype /text/plain (이름 두 개)
// v3.121.2: /Type /EmbeddedFile /Subtype /text#2Fplain (이름 하나)
//
// 호출자는 언제나 평범한 MIME 값을 넘깁니다. 미리 직접 이스케이프하면
// '#'가 이중 인코딩되어 'text#2Fplain'이 'text#232Fplain'이 됩니다
Options.Files[0].MIMEType := 'text/plain';
InjectAssociateFiles로 PDF/A-3 파일 만들기
PDF/A-3 출력을 위해서는 TPdf.SaveAsPdfAToStream으로 준수하는 기본 문서를 만든 다음 그 스트림에 InjectAssociateFiles를 호출하세요. 이 두 단계 파이프라인이 검증 피쳐가 PDF/A-3b를 통과하기 전에 돌리는 것과 정확히 같습니다. TPdf.SaveAsWithAssociateFiles는 편의 래퍼지만, PDF/A 작성기가 아니라 saRemoveSecurity를 붙인 평범한 SaveAs 경로로 저장하므로 PDF/A가 요구하는 XMP 식별과 출력 인텐트를 추가하지 않습니다. 레코드 타입은 FPdfAssocFiles와 FPdfPdfa에 살므로 두 유닛 모두 uses 절에 들어가야 합니다. v3.121.3부터 FileName과 Description은 평범한 ASCII일 필요가 없습니다. /UF와 /Desc는 PDF 텍스트 문자열로 기록됩니다. 인쇄 가능 ASCII는 문자 그대로, 나머지는 바이트 순서 표식과 함께 UTF-16BE로요. 반면 레거시 /F 이름은 항상 이식 가능한 인쇄 가능 ASCII로, 다른 모든 문자는 _로 바뀝니다. /F를 자기 코드 페이지로 디코딩하는 리더는 깨진 글자 대신 밑줄을 보게 됩니다. 더 오래된 빌드는 Delphi에서는 세 값 모두를 시스템 ANSI 코드 페이지로 변환했고 Free Pascal에서는 raw UTF-8 바이트를 썼으므로, 오래된 빌드가 같은 출력을 내야 한다면 이름은 ASCII만 유지하세요
uses
System.SysUtils, System.Classes, System.IOUtils,
PDFium, FPdfPdfa, FPdfAssocFiles;
procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
PdfAOptions: TPdfASaveOptions;
Options: TAssocFilesOptions;
Base: TMemoryStream;
Output: TFileStream;
begin
PdfAOptions := TPdfASaveOptions.Default;
PdfAOptions.Conformance := pac3b;
Options := TAssocFilesOptions.Default; // TargetPage = 0: 카탈로그 수준 /AF
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'invoice-data.xml';
Options.Files[0].Description := 'Structured invoice data';
Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
Options.Files[0].Relationship := afData;
Options.Files[0].MIMEType := 'application/xml'; // /application#2Fxml로 기록됩니다
Base := TMemoryStream.Create;
try
if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
raise Exception.Create('PDF/A-3 base save failed');
Output := TFileStream.Create(OutPath, fmCreate);
try
InjectAssociateFiles(Base, Output, Options); // Base를 되감습니다. 실패 시 EPdfAssocFilesError
finally
Output.Free;
end;
finally
Base.Free;
end;
end;
카탈로그인가 페이지인가: /AF 배열은 어디에 놓일까요?
TAssocFilesOptions.TargetPage가 /AF 배열의 소유자를 결정합니다. 0이면 카탈로그에 문서 수준 연관으로 붙고, 1..N이면 그 페이지 사전에 붙습니다(1 기반). 인젝터는 고정 레이아웃의 단일 증분 업데이트로 모든 것을 추가합니다(임베디드 스트림, 그다음 파일 명세들, 그다음 /AF 배열, 그다음 다시 쓴 카탈로그나 페이지 개체). 기존 개체는 오프셋을 유지하고 아무것도 재압축되지 않습니다. 대상 사전에 있던 기존 /AF 항목은 병합이 아니라 교체되므로 반복 저장은 멱등하지만, 다른 파일 목록으로 두 번째 호출하면 그쪽이 이깁니다. 예전에는 직접 가드해야 했던 동작이 둘 있는데, 둘 다 바뀌었습니다. v3.122.0 이전에는 범위 밖 TargetPage가 실패하지 않고 카탈로그로 폴백했으니 오타가 아무 신호 없이 페이지 수준 연관을 문서 수준으로 바꿨습니다. v3.122.0부터 SaveAsWithAssociateFiles와 SaveAsWithAssociateFilesToStream은 TargetPage가 0..PageCount 밖이면 EPdfError를 일으키고, InjectAssociateFiles는 음의 TargetPage나 존재하지 않는 페이지를 가리키는 값에 대해 새 EPdfAssocFilesError를 일으키며 대상 스트림은 그대로 둡니다. v3.121.4 이전에는 페이지 탐색이 파일 순서대로 저장된 바이트에서 /Type /Page 사전을 찾았으므로, 페이지 개체가 표시 순서와 다르게 저장되면(예컨대 페이지가 재정렬되거나 삽입된 뒤) 파일이 다른 페이지에 붙을 수 있었습니다. v3.121.4부터 TargetPage는 문서 페이지 순서에서 그 위치의 페이지를 가리킵니다
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
const CsvBytes: TBytes; const OutPath: string);
var
Options: TAssocFilesOptions;
begin
// v3.122.0부터 범위 밖 TargetPage는 EPdfError를 일으킵니다 (오래된 빌드는
// 조용히 카탈로그 수준 /AF로 폴백). 미리 검사해 페이지를 확정합니다
if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);
Options := TAssocFilesOptions.Default;
Options.TargetPage := PageNumber;
SetLength(Options.Files, 1);
Options.Files[0].FileName := 'chart-data.csv';
Options.Files[0].Content := CsvBytes;
Options.Files[0].Relationship := afSource;
Options.Files[0].MIMEType := 'text/csv';
if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
raise Exception.Create('Associated-file save failed');
end;
AFRelationship을 확실하게 되읽으려면 어떻게 할까요?
TPdf.AttachmentRelationship[Index]는 네이티브 FPDFAttachment_GetAFRelationship 익스포트를 통해 첨부의 /AFRelationship 이름을 반환하지만, 빈 문자열은 두 가지 의미가 가능하므로 먼저 AttachmentRelationshipFeaturesAvailable을 호출하세요. 바인딩은 관대하게 로드됩니다. PDFium DLL에 그 익스포트가 없으면 모든 relationship이 빈 문자열로 읽히는데, 이는 단순히 /AFRelationship이 없는 파일 명세와 구별되지 않습니다. 이 속성은 /Names /EmbeddedFiles 트리의 항목을 세는 AttachmentCount와 인덱스를 공유합니다. 인젝터는 /AF 체인만 쓰고 이름 트리 항목은 추가하지 않으므로, InjectAssociateFiles로 첨부된 파일은 그 인덱스 밖에 있습니다. 주입된 체인을 확인하려면 저장된 바이트를 검사하거나 PDF/A 검증기를 돌리세요. 그 이름 트리의 내부는 PDFium Component로 Delphi에서 PDF 첨부 파일 다루기에서 다룹니다
procedure ReportRelationships(const FileName: string);
var
Pdf: TPdf;
I: Integer;
Rel: string;
begin
if not AttachmentRelationshipFeaturesAvailable then
begin
Writeln('This PDFium build cannot report /AFRelationship');
Exit; // 빈 답은 모호하므로 묻지 않습니다
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for I := 0 to Pdf.AttachmentCount - 1 do
begin
Rel := Pdf.AttachmentRelationship[I];
if Rel = '' then
Rel := '(no /AFRelationship)';
Writeln(Pdf.AttachmentName[I], ': ', Rel);
end;
finally
Pdf.Free;
end;
end;
SaveAsWithAssociateFiles가 보장하지 않는 것은?
TPdf.SaveAsWithAssociateFiles가 보장하는 것은 파일 형식 봉투와 요청한 파일이 주입됐다는 것입니다. 준수가 아닙니다. 주입 부분은 새것입니다. v3.122.0 이전에는 저장된 바이트에 읽을 수 있는 trailer가 없거나 카탈로그 사전을 찾지 못하면 InjectAssociateFiles가 입력을 그대로 복사해 통과시키고 메서드는 여전히 True를 반환했습니다. v3.122.0부터 InjectAssociateFiles는 무엇을 쓰기 전에 그런 경우 EPdfAssocFilesError를 일으키고, SaveAsWithAssociateFiles는 False를 반환하며, 이제 대상을 열기 전에 save store에서 완전한 출력을 만들므로 거부되거나 실패한 저장이 기존 파일을 잘라 먹지 않습니다. 빈 Files 배열은 설계상 문서를 그대로 복사해 통과시킵니다. 페이로드의 내용도 당신의 책임입니다. 인젝터는 XML 파일이 well-formed인지, MIME 타입이 바이트와 맞는지, 기본 문서가 애초에 PDF/A인지 검사하지 않습니다. 검증기가 보기 전까지는 최종 파일을 미검증으로 취급하세요. PDFium Component와 PDF/A 아카이브 준수가 묘사하는 것과 같은 규율입니다. 들어오는 사전을 직접 파싱한다면 같은 #XX 이름 규칙이 반대 방향으로도 적용됩니다. PDF 사전을 파싱할 때의 name 토큰 함정에서 다루는 주제입니다
associated file, PDF/A 출력, 첨부 메타데이터와 검증은 모두 같은 컴포넌트에 실려 나오므로, 위의 파이프라인은 빌드에 두 번째 PDF 라이브러리 없이 돌아갑니다. API 레퍼런스, 평가판 다운로드와 라이선스 옵션은 PDFium Component 제품 페이지에 있습니다