기술 문서

Delphi HotPDF로 PDF 2.0, PDF/A-4, PDF/UA-2 출력

PDF/A-3 체크리스트는 대개 XMP 패킷이 pdfaid:part와 pdfaid:conformance 식별 항목 두 개를 가져야 한다고 말합니다. 올바르게 기록된 PDF/A-4 파일은 pdfaid:part 4와 pdfaid:rev 2020을 담고, 기본 프로파일에는 적합성 항목이 아예 없습니다. "고치려고" 문자를 붙이면 파일이 틀려집니다. ISO 19005-4는 기본 파트를 번호와 리비전 연도로 식별하고, 문자를 유지하는 것은 두 확장뿐입니다

PDF/UA-2에는 한 단계 아래, 구조 트리 안에 비슷한 함정이 있습니다. 파트 2가 추가한 기능이 네임스페이스이니 자체 네임스페이스에서 사용자 정의 롤을 선언하면 그것이 작업의 전부처럼 보입니다. 절반이지 전부가 아닙니다. https://example.com/ns/widgets의 WidgetGroup 같은 롤은 규격 검사기가 수용하기 전에 표준 롤로 사상되어야 하고, 렌더링된 페이지 어디에도 사상이 빠졌다는 힌트는 없습니다

PDFACompliance로 PDF/A-4 선택하기

HotPDF에서 PDF/A-4 프로파일은 기존 PDFACompliance 속성의 세 값입니다. 기본 파트의 '4', 엔지니어링 문서의 '4E', 내장 파일을 갖는 파일의 '4F'입니다. '4'가 선택되면 XMP 패킷은 pdfaid 네임스페이스에서 pdfaid:part 4와 pdfaid:rev 2020 이외에는 아무것도 받지 않습니다. '4E'와 '4F'는 E나 F를 붙인 pdfaid:conformance를 추가합니다. 디코더 함수도 같은 분할을 보고합니다. 세 값 모두 PDFAPart는 4를 돌려주고, PDFAConformanceLevel은 E나 F, 기본 프로파일에는 #0을 돌려줍니다

HotPDF의 PDF/A-4 선택 값 매트릭스: 적합성 문자가 없는 기본 4 옆에, 공유되는 PDF 2.0 제약 위에 4E 엔지니어링 프로파일과 4F 내장 파일 프로파일이 놓이는 모습
기본 '4'는 문자 없이 part 4 rev 2020을 기록하며 문자를 유지하는 것은 4E와 4F뿐입니다

셋 모두 PDF 2.0 헤더를 강제합니다. 업그레이드는 EndDoc 안에서 일어나므로 PDF 1.4로 시작한 문서가 별도 호출 없이 2.0으로 나옵니다. 이를 막는 케이스는 하나, StrictVersionLock입니다. 그 플래그는 자동 업그레이드를 금지하므로, EndDoc은 메타데이터에 part 4를 주장하는 1.7 파일을 쓰는 대신 "PDF/A-4 requires PDF 2.0; StrictVersionLock prevents the version upgrade"을 일으킵니다. 오래된 인쇄 워크플로를 위해 버전을 고정해 온 코드베이스는 누군가 프로파일을 뒤집는 첫 순간 정확히 그 메시지를 보게 됩니다

PDF/A-4 규칙 두 개는 트레일러와 Catalog에 기록되는 것을 바꿉니다. ISO 19005-4 §6.1.3은 문서 정보 딕셔너리를 Catalog에 PieceInfo 항목이 있을 때만 허용하고 그 경우에도 ModDate로 제한하므로, HotPDF는 part 4에서 Info 딕셔너리를 쓰지 않고 제목은 XMP의 dc:title로 이동합니다. Title과 Lang은 여전히 필수이며 어느 하나라도 비워 두면 EndDoc이 예외를 일으킵니다. AddPDFAOutputIntent로 출력 인텐트가 추가되지 않았을 때에도 예외가 나오고, PDF/A 프로파일이 선택되어 있는 동안 EnableEncrypt는 실행을 거부합니다. 암호화된 보관 파일은 표준이 허용하지 않는 모순이기 때문입니다

PDF/A-4f와 4e 아래의 내장 파일

내장 파일이 PDF/A-4f를 고르는 주된 이유입니다. 렌더링된 페이지 뒤의 XML 인보이스, 다운스트림 시스템이 파싱할 JSON 페이로드 같은 것들입니다. AddPDFAssociatedFile은 파일 이름과 MIME 타입, 설명, AFRelationship 값, 바이트를 받고 간접 FileSpec 딕셔너리를 돌려주므로 여러분의 선택적 키를 붙일 수 있습니다

var
  Pdf: THotPDF;
  Icc: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  Icc := TFileStream.Create('sRGB2014.icc', fmOpenRead or fmShareDenyWrite);
  try
    Pdf.FileName := 'invoice-archive.pdf';
    Pdf.PDFACompliance := '4F';        // part 4, rev 2020, 적합성 F
    Pdf.Title := 'Invoice 2026-0731';  // XMP dc:title에만 기록됨
    Pdf.Lang := 'en-US';
    Pdf.BeginDoc;
    Pdf.AddPDFAOutputIntent('sRGB IEC61966-2.1', 'sRGB IEC61966-2.1',
      Icc, 3, 'DeviceRGB');
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-0731');
    // LoadInvoiceBytes는 TBytes를 돌려주는 여러분의 함수입니다
    Pdf.AddPDFAssociatedFile('invoice.xml', 'text/xml',
      'Structured invoice data', 'Data', LoadInvoiceBytes);
    Pdf.EndDoc;
  finally
    Icc.Free;
    Pdf.Free;
  end;
end;

각 호출은 네 가지를 합니다. /Subtype에 MIME 타입을 PDF 이름으로 담은 EmbeddedFile 스트림을 기록하고, 크기와 수정 날짜, MD5 체크섬을 갖는 /Params 딕셔너리를 붙입니다. /F와 /UF를 모두 갖추고 설명을 /Desc로, 관계 이름을 그대로 담은 FileSpec을 만듭니다. 그다음 그 FileSpec을 Catalog /AF 배열과 EmbeddedFiles 이름 트리, 두 곳에 등록합니다. 두 등록이 모두 필요합니다. 한 곳에만 등록된 파일은 뷰어의 첨부 목록에서 열릴 수도 있는데, 그래서 눈으로 확인하는 정도의 점검은 아카이브에 대해 거의 아무것도 증명하지 못합니다

관계 문자열은 Source, Data, Alternative, Supplement, Unspecified 중 하나여야 하고, 그 외의 것은 바이트가 기록되기 전에 예외를 일으키며 빈 MIME 타입도 마찬가지입니다. 프로파일도 검사합니다. AddPDFAssociatedFile은 PDF/A-3, PDF/A-4e, PDF/A-4f를 받고, 내장 파일을 허용하지 않는 기본 '4' 프로파일은 예외를 일으킵니다. 반대 방향으로 AddDocumentAttachment는 PDF/A-3, 4e, 4f 아래에서 실행을 거부합니다. 평범한 첨부는 그 프로파일들이 요구하는 /AF 등록이 없기 때문입니다. AddPDFA3AssociatedFile을 상대로 PDF/A-3 용도로 쓰던 코드는 계속 컴파일되고 같게 동작합니다. AddPDFAssociatedFile은 그 메서드의 프로파일 중립 이름입니다

두 확장은 남은 권한을 나눠 갖습니다. '4F' 아래에서는 EndDoc이 Catalog가 EmbeddedFiles 이름 트리를 갖추도록 확인하고 AddFileAttachmentAnnotation이 수용되지만, 기본 프로파일과 4e는 거부합니다. '4E' 아래에서는 Add3DAnnotation과 AddRichMediaAnnotation, 그리고 SetOCGState와 GoTo3DView 액션이 허용됩니다. 그 외의 모든 PDF/A 프로파일은 넷 모두를 거부합니다

네임스페이스를 갖춘 사용자 정의 롤이 여전히 PDF/UA-2에 실패하는 이유

사용자 정의 롤은 두 조건이 모두 성립할 때만 PDF/UA-2를 통과합니다. /NS 항목이 구조 트리 루트에 나열된 네임스페이스 딕셔너리를 가리키는 것, 그리고 그 롤이 그 네임스페이스의 RoleMapNS를 거쳐 표준 네임스페이스의 롤로 해석되는 것입니다. HotPDF는 그 일을 세 메서드에 나눠 맡깁니다. RegisterStructureNamespace는 간접 /Type /Namespace 딕셔너리를 만들어 StructTreeRoot /Namespaces에 추가하고 그것을 돌려주며, 같은 URI로 다시 호출하면 새로 추가하는 대신 기존 딕셔너리를 돌려줍니다. AddStructureElementNS는 요소를 만들고 /NS를 설정하면서 길에서 네임스페이스를 등록합니다. AddStructRoleMapNS는 사상을 기록합니다

Delphi에서 RegisterStructureNamespace와 AddStructureElementNS로 묶인 PDF/UA-2 네임스페이스와 평평한 PDF/UA-1 롤 어휘를 나란히 대조하는 다이어그램
하나의 URI는 언제나 하나의 네임스페이스 딕셔너리를 재사용합니다. 사본이 쌓이는 일은 없습니다
const
  PDF2NS   = 'http://iso.org/pdf2/ssn';
  WidgetNS = 'https://example.com/ns/widgets';
var
  DocElem, Group: THPDFDictionaryObject;
begin
  Pdf.PDFUACompliance := True;
  Pdf.PDFUAPart := 2;                // PDF/UA-2는 PDF 2.0을 요구합니다
  Pdf.Title := 'Widget catalog';     // 빈 Title은 EndDoc에서 예외를 일으킵니다
  Pdf.Lang := 'en-US';
  Pdf.BeginDoc;

  DocElem := Pdf.AddStructureElement('Document', nil);  // PDF2NS에 묶임
  Group := Pdf.AddStructureElementNS('WidgetGroup', WidgetNS, DocElem);
  // 이 사상이 없으면 EndDoc은 ua2.rolemap.unresolved를 일으킵니다
  Pdf.AddStructRoleMapNS('WidgetGroup', WidgetNS, 'Div', PDF2NS);

  Pdf.CurrentPage.SetFont('Arial', [], 18);
  Pdf.CurrentPage.BeginMarkedContentMCID('H1', 0);
  Pdf.CurrentPage.TextOut(50, 760, 0, 'Widgets');
  Pdf.CurrentPage.EndMarkedContent;
  Pdf.AddStructureElement('H1', Group, 0, 0);  // 페이지 인덱스 0, MCID 0
  Pdf.EndDoc;
end;

그 목록 중 일부는 요청받지 않아도 이루어집니다. PDFUAPart가 2로 설정되면 AddStructureElement('Document', nil)는 그 요소를 PDF 2.0 표준 네임스페이스인 http://iso.org/pdf2/ssn에 묶습니다. PDF/UA-2 트리의 루트는 반드시 그 네임스페이스의 Document여야 하기 때문입니다. 같은 묶음이 PDF 2.0 어휘의 모든 롤에 일어나므로 H1과 P에는 네임스페이스 인수가 필요 없습니다. Note, Code, BlockQuote처럼 PDF 1.7 집합에만 존재하는 롤은 1.7 네임스페이스에 머뭅니다

사상 목표는 실제로 선택의 문제입니다. AddStructRoleMapNS에 빈 목표 네임스페이스를 넘기면 짧은 형태, 즉 PDF 1.7 집합에 대해 해석되는 맨 롤 이름이 기록됩니다. PDF 2.0 URI를 넘기면 두 항목짜리 [role namespace] 형태가 기록됩니다. PDF/UA-2 문서에서는 PDF 2.0 네임스페이스로 사상하세요. 트리 전체가 하나의 어휘로 해석되고, 1.7 짝이 없는 Aside나 FENote 같은 목표가 사용 가능해집니다. 두 사상 모양은 EndDoc에서 무조건 거부됩니다. 자기 자신으로 되돌아오는 사슬, 그리고 명시적 네임스페이스의 롤이 그 같은 네임스페이스의 다른 롤로 사상되는 경우입니다

호출 순서는 겉보기보다 관대합니다. 사상은 요소가 존재하기 전이나 후에 추가될 수 있습니다. 트리가 끝나기 전까지는 아무것도 검사하지 않기 때문입니다. 즉시 실패하는 것은 PDFUAPart가 1인 상태의 AddStructureElementNS입니다. 파트 1에는 네임스페이스가 없고, 그 호출은 그 자리에서 "structure namespaces require PDFUAPart=2"를 일으킵니다

EndDoc이 파일이 기록되기 전에 검사하는 것들

PDFUACompliance가 켜지고 PDFUAPart가 2이면 EndDoc은 아무것도 직렬화하기 전에 온전한 PDF/UA-2 구조 검증을 돌리고 첫 오류에서 예외를 일으킵니다. 그 검사 중 넷이 트리의 뼈대를 지킵니다. 루트는 정확히 하나의 최상위 Document 요소를 가져야 합니다. 모든 네임스페이스 딕셔너리는 간접이어야 하고 Namespace 타입이며, 딱 한 번만 나타나는 비어 있지 않은 URI를 가져야 합니다. 모든 /NS 참조는 루트 /Namespaces 배열에 나열된 딕셔너리로 해석되어야 합니다. 그리고 모든 롤은 직접이든 RoleMap과 RoleMapNS를 거치든 표준 네임스페이스로 해석되어야 합니다. Document 요소에서 닿을 수 없는 요소도 함께 보고됩니다

HotPDF가 EndDoc에서 돌리는 검사 중 네 개의 구조 트리 게이트 흐름: 단일 Document 루트, 깨끗한 네임스페이스 딕셔너리, 해석되는 NS 참조, 표준 또는 사상된 롤
게이트를 더 일찍 검사하면 유효한 중간 상태들이 거부됩니다

같은 패스가 문서 수준 요건도 커버합니다. 비어 있지 않고 유효한 Lang, true로 설정된 MarkInfo /Marked, 켜진 DisplayDocTitle, 비어 있지 않은 dc:title과 pdfuaid:part 2를 담은 XMP 패킷입니다. PDF/UA가 켜지면 HotPDF가 marked 플래그와 뷰어 환경설정, XMP 식별을 스스로 설정하고 파트 번호 옆에 pdfuaid:rev 2024를 기록합니다. 제목과 언어는 여러분이 공급하고, 둘 중 하나라도 비어 있으면 검증기가 시작하기도 전에 예외가 나옵니다. 제목과 목록, 표, 주석, 폼은 같은 실행에서 검사됩니다

EndDoc까지 기다리는 것은 의도입니다. 생성 중간의 트리는 정당하게 불완전합니다. Document 요소에 아직 자식이 없을 수 있고, 네임스페이스가 어떤 요소가 쓰기 전에 등록되었을 수 있으며, 사상이 자기 요소보다 나중에 도착할 수 있습니다. 매 호출마다 검사하면 그런 상태들이 거부될 것입니다. EndDoc 시점에는 트리가 완성되었고 여전히 메모리 안에 있으며, 예외는 규칙 코드와 그 규칙을 깬 요소의 경로를 밝힙니다

try
  Pdf.EndDoc;
except
  on E: Exception do
  begin
    // E.Message의 형태는 다음과 같습니다
    //   PDF/UA-2 validation failed [ua2.heading.skipped at ...]:
    //   Heading level skips from H2 to H4
    LogBuildFailure(Pdf.FileName, E.Message);  // your own logger
    raise;
  end;
end;

PDF/A-4 출력은 자체 검증기로 같은 대우를 받습니다. PDFACompliance가 part 4 값을 담고 있으면 EndDoc은 만들어진 그래프를 ISO 19005-4와 대조하고 규칙 코드와 조항을 붙여 예외를 일으킵니다. 유효한 관계가 없는 FileSpec에는 pdfa4.filespec.relationship, 어긋난 Info 키에는 pdfa4.trailer.info_entries 같은 식입니다. Enterprise 에디션은 두 검증기를 ValidatePDFUA2Structure와 ValidatePDFA4로 노출하며, 첫 오류에서 멈추는 대신 유지된 모든 진단을 이슈 배열에 채워 넣습니다. 전체 목록을 출력해야 할 CI 단계에는 이쪽이 더 나은 진입점입니다

PDF 2.0 롤과 H6를 넘는 제목

타입 지정된 THPDFStandardStructureType 열거형에 sstDocumentFragment, sstAside, sstTitle, sstFENote, sstSub, sstEm, sstStrong, sstArtifact가 생깁니다. 셋은 평범한 실무 문서의 태깅 방식을 바꿉니다. Aside는 사이드바와 인용 뽑아내기를 남용된 Sect가 아닌 제자리에 놓습니다. FENote는 각주와 미주를 노트로 표시해 화면 낭독기가 문장 한가운데 읽지 않고 요청할 때 제공할 수 있게 합니다. Em과 Strong은 강조를 구조로 운반합니다. PDF 1.7 트리는 그것을 Span으로 감쌀 수만 있었습니다

제목은 PDF 2.0에서 열린 구조입니다. 열거형은 sstH6에서 멈추지만 AddStructureElement의 문자열 오버로드는 H7, H8과 그 이상을 받습니다. PDF 2.0 집합은 1부터 임의의 n까지 Hn을 정의하기 때문입니다. PDF 1.7은 H6에서 멈췄고, 깊은 기술 문서들은 아웃라인을 평평하게 만들거나 레벨을 재활용해야 했습니다. 표준 문서와 법전, 부품 카탈로그에는 그것 하나만으로도 출력을 PDF 2.0으로 옮길 이유가 됩니다

더 깊은 제목에는 더 엄격한 규칙이 따라옵니다. EndDoc 안의 PDF/UA-2 패스는 제목 레벨이 연속되기를 요구합니다. 첫 제목은 H1이어야 하고 H2에서 H4로 뛰면 ua2.heading.skipped와 함께 예외가 나옵니다. 계층이 더 낮은 곳에서 시작할 수 있는 것은 DocumentFragment, 그러니까 더 큰 문서에서 발췌한 콘텐츠를 위한 롤 안에서만입니다. Markdown이나 HTML 아웃라인을 Hn 롤에 곧바로 사상하는 생성기는 저자가 화면 크기 때문에 건너뛴 모든 레벨을 물려받으므로, 태깅 전에 아웃라인을 정규화하세요

프로덕션 출력을 PDF 2.0으로 전환하기 전에

PDF 2.0은 헤더 변경에서 시작하고, 헤더가 바로 오래된 소비자들이 처음 읽는 것입니다. PDF 1.7까지만 받는 유입 서비스나 인쇄 워크플로, 사업용 뷰어는 파일의 나머지가 아무리 깨끗해도 %PDF-2.0 앞에서 멈춥니다. PDF/A-4 프로파일이나 PDF/UA 파트 2를 고르는 것은 여러분이 요청했든 아니든 PDF 2.0을 고르는 것이므로, 전환 전이지 후가 아니라 소비 시스템마다 확인하세요

위험도가 가장 낮은 롤아웃은 여러분이 통제하지 않는 독자에게 나가는 문서에는 PDF/A-3을 유지하고, 유입 쪽을 여러분이 소유한 내부 아카이브에는 PDF/A-4f를 쓰며, 접근성 정책이 명시한 곳에 PDF/UA-2를 채택하는 것입니다. StrictVersionLock은 전환기의 방어선으로도 쓰입니다. 1.7에 머물러야 하는 코드 경로에서 Version을 pdf17로 하고 락을 걸어 두면, 우발적인 PDF/A-4 선택은 열지 못하는 시스템에 도달하는 대신 메시지를 달고 EndDoc에서 실패합니다. PDF/A, PDF/X, PDF/UA 검증 가이드와 PDF/A-3 위의 ZUGFeRD와 Factur-X 하이브리드 인보이스 가이드가 버전 번호보다 앞서는 프로파일 결정을 다루고, 자동 프리플라이트 보고 노트는 그 판정을 빌드 단계로 바꾸는 방법을 보여 줍니다

이 모든 것은 Delphi와 C++Builder용 컴포넌트 안의 네이티브 VCL 코드로 돌아갑니다. PDF/A-4와 PDF/UA-2 출력이 실행 파일과 함께 배송되고 외부 엔진이 필요 없다는 뜻입니다. 지원되는 프로파일과 RAD Studio 버전은 HotPDF Delphi 컴포넌트 페이지에 정리되어 있습니다