기술 문서

Delphi의 PDFium VCL로 PDF/A 보관 적합성 만들기

모든 파일을 PDF/A-1b라고 표시하는 변환기를 배포했고, 고객의 기록 시스템은 그것을 1년 동안 그대로 받아들였다. 그런데 감사가 전체 묶음을 veraPDF에 돌리자 그중 3분의 1이 비적합으로 돌아왔다고 하자. 아무것도 충돌하지 않았고, 예외도 발생하지 않았으며, 책상 위의 어느 뷰어에서나 파일은 잘 열린다. 단지 당신이 찍어 놓은 표준이 실제로는 아니었던 것이다. 이것이 보관용 PDF에서 가장 흔한 실패 형태이며, 그래서 "우리는 플래그를 설정했다"는 말은 결코 "검증을 통과한다"는 주장과 같지 않다

PDFium과 PDF/A의 관계에서 먼저 이해해야 할 것은, 엔진 자체는 그 문제를 전혀 처리하지 않는다는 사실이다. PDFium은 PDF를 렌더링하고 파싱하고 저장하지만, 공개 표면에는 ConvertToPDFA도 없고 OutputIntent writer도 없으며 XMP API도 없다. XMP packet, OutputIntent와 그 안의 ICC profile, catalog marker, validation을 포함한 보관 적합성의 모든 부분은 PDFiumPas 내부, 대략 2,000줄 규모의 pure-Pascal unit인 FPdfPdfa.pas 안에 있으며, 저장된 바이트를 다시 파싱한 뒤 incremental update로 덮어쓰는 방식으로 동작한다. 일이 실제로 어디서 일어나는지 아는 것은 버그가 어디에 숨어 있는지를 아는 것과 같고, 그 버그는 PDFium 안에 숨지 않는다

PDF/A가 실제로 요구하는 것과 자주 걸리는 지점

PDF/A는 하나의 형식이 아니다. ISO 19005는 세 개의 part(PDF/A-1, -2, -3)와, 각 part 안에서 서로 다른 약속을 하는 conformance level을 정의한다. Level B는 시각적 외관 재현성만 보장한다. Level A는 거기에 tagged structure tree와 Unicode mapping까지 요구한다. Level U는 part 2와 3에만 존재하며, 전체 구조 트리 없이도 신뢰 가능한 Unicode 텍스트를 보장한다. ISO 19005-1에는 Level U가 없고, 라이브러리는 이 제약을 코드 차원에서 직접 인코딩한다

실제 현장에서 발목을 잡는 규칙은 몇 가지로 모인다. 암호화는 명시적으로 금지된다(ISO 19005-1 §6.1.3 및 후속 규정). 즉 PDF/A 파일에는 /Encrypt dictionary가 존재할 수 없다. 문서는 반드시 유효한 ICC profile을 목적지로 갖는 OutputIntent를 통해 출력 렌더링 조건을 선언해야 한다(§6.2.3.2). conformance claim 자체도 PDF/A identification schema 아래의 XMP metadata로 존재해야 한다. Level A는 여기에 더해 §6.8의 logical structure, 즉 기계가 읽을 수 있는 tag tree를 요구한다. 이 중 하나라도 빠지면 파일이 화면에는 완벽히 보여도 verifier는 거부한다

아카이브를 만드는 한 번의 호출

PDFiumPas는 전체 파이프라인을 TPdf.SaveAsPdfA 뒤에 숨겨 둔다. 가장 단순한 오버로드는 target conformance 하나만 받으며, 기본값은 PDF/A-1b다. "이 파일을 오래 다시 렌더링할 수 있게 만들어라"라는 일반적인 요구에는 이 기본값이 가장 적절하다

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // Default conformance is pac1b (PDF/A-1b)
    if Pdf.SaveAsPdfA('invoice_archive.pdf') then
      // file now carries XMP, sRGB OutputIntent, and catalog markers
    else
      raise Exception.Create('PDF/A save failed');
  finally
    Pdf.Free;
  end;
end;

내부적으로 이 동작은 두 단계다. SaveAsPdfA 는 먼저 FPDF_SaveAsCopy 로 문서를 직렬화하게 하고, 그 바이트 스트림을 InjectPdfAMarkers 에 넘겨 XMP metadata, 내장 ICC profile을 가진 sRGB OutputIntent, 다시 쓴 catalog를 incremental update로 추가한다. 입력은 position 0에서 읽고 출력도 position 0부터 기록한다. 원본 object tree는 그대로 두고, marker만 기존 %%EOF 뒤에 올라탄다. 파일이 아니라 바이트가 필요하면 같은 옵션으로 TStream 을 받는 SaveAsPdfAToStream 을 사용할 수 있다

options record로 conformance 고르기

특정 part와 level을 직접 지정하려면 TPdfASaveOptions record를 넘기면 된다. 그 안의 Conformance 필드는 TPdfAConformance 값을 받는다. 이 enum은 가능한 조합만 담고, 존재하지 않는 조합은 아예 제공하지 않는다. part 1에는 pac1b, pac1a 가 있고, part 2에는 pac2b, pac2u, pac2a, part 3에는 pac3b, pac3u, pac3a 가 있다. 여기에 validation 쪽을 위한 pacUnknownpacNone 가 더해진다. 표준에 없는 level인 pac1u 는 존재하지 않는다

var
  Pdf: TPdf;
  Opts: TPdfASaveOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('report.pdf');
    Opts := TPdfASaveOptions.Default;
    Opts.Conformance := pac2u;           // PDF/A-2u: reliable Unicode text
    Opts.Title := 'Quarterly Report 2026';
    Opts.Author := 'Finance';
    // Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
    if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
      raise Exception.Create('PDF/A-2u save failed');
  finally
    Pdf.Free;
  end;
end;

이 record의 대부분은 비워 둬도 된다. Title, Author, Subject, Keywords, Creator, Producer 를 비워 두면 SaveAsPdfAFPDF_GetMetaText 를 통해 문서의 Info dictionary에서 값을 자동으로 채운다. CreationDateModDate 를 비워 두면 현재 UTC 시각을 두 XMP 날짜에 사용한다. DocumentIdInstanceId 를 비워 두면 FPDF_GetFileIdentifier 로 값을 채우고, 그것도 없으면 원본 바이트에서 결정적으로 파생한 ID를 만든다. 의도적으로 재정의할 가능성이 높은 필드는 IccProfileData 하나다. 비워 두면 내장 sRGB IEC61966-2.1 profile을 쓰지만, CMYK나 grayscale 워크플로라면 직접 profile을 공급해야 한다

Level A 요청이 downgrade되는 이유와, 그것이 정직한 선택인 이유

단순한 플래그를 곧바로 보장으로 생각하면 여기서 걸린다. tag tree가 전혀 없는 문서에 pac1a 를 요청할 수는 있지만, PDF/A-1a는 §6.8의 logical structure를 요구하고, 라이브러리는 태그가 없는 PDF에서 구조 트리를 발명해 낼 수 없다. 그래서 SaveAsPdfA 는 실제 tagged structure(/StructTreeRoot/MarkInfo 안의 /Marked true)가 있는지 확인하고, 없으면 거짓 Level A claim을 쓰는 대신 claim 자체를 낮춘다. pac1apac1b 로, pac2apac2b 로 내려가며, 이 규칙은 세 part 전체에 적용된다. 내부 helper는 PdfAIsLevelAPdfADowngradeToLevelB

이 선택의 이유는 분명하다. 실제로 만족하는 level을 정직하게 선언하는 파일이, 충족하지 못하는 level을 거짓으로 주장하는 파일보다 훨씬 유용하다. Level U는 다르게 처리된다. 진짜 Unicode coverage를 탐지하려면 단순한 /ToUnicode 존재 여부 검사 이상의 것이 필요하고, 순진한 검사는 WinAnsi처럼 합법적인 경우까지 과도하게 downgrade시킬 수 있다. 그래서 save 경로는 caller가 요청한 U claim을 그대로 기록하고, 그 불일치는 validation 단계에서 잡히게 둔다. 확실한 Level A 아카이브가 필요하다면 변환 전에 문서 자체를 태그해야 한다. 변환기는 없는 구조를 만들어 주지 않는다

실제 validator만 잡아내는 ICC 함정

이것은 가장 비싼 교훈을 남긴 실패였다. 라이브러리 내부 checker는 통과시켰지만, ISO 19005의 기준 validator인 veraPDF는 실패시켰기 때문이다. PDF/A는 OutputIntent의 destination profile이 유효한 ICCBased stream이어야 한다고 요구하며, §6.2.3.2는 verifier가 그 stream 자체를 색 공간으로 검증해야 한다고 말한다. ICCBased stream에는 색 성분 수를 나타내는 /N 이 반드시 있어야 한다. 초기 injector 버전은 ICC stream dictionary를 /Length 만 가진 형태로 쓰고 /N 은 넣지 않았다. 그 결과 veraPDF는 "The N entry (value null)... is missing" 라는 메시지로 이를 거부했다

이 실패가 교묘했던 이유는 PDF/A-1b 와 1a 에서만 드러났기 때문이다. part 2와 part 3의 conformance 모델은 destination profile에 대해 그 특정 검사를 수행하지 않았으므로, 똑같은 injected 구조가 pac2b, pac3b, pac2u 에서는 통과하고, pdfaid:part 값만 다른 pac1b 에서는 실패했다. 단위 테스트는 이 차이를 볼 수 없었다. 라이브러리 내부의 ValidatePdfACompliance/DestOutputProfile 키 존재 여부만 확인했고, stream dictionary 내부까지 들여다보지 않았기 때문이다. 내부 테스트는 초록이었지만, 실제 보관 검증은 실패했다

해결책은 IccComponentCount 다. 이 함수는 ICC header의 offset 16에 있는 data colour space signature를 읽어 성분 수로 매핑한다. GRAY 는 1, RGB , Lab , XYZ 는 3, CMYK 는 4다. 알 수 없는 profile은 3으로 기본 처리한다. 이 수를 stream dictionary의 /N 으로 넣는다. 값이 3으로 하드코딩되지 않고 계산되는 이유는 caller가 IccProfileData 로 CMYK나 grayscale profile을 넣는 경우에도 정확한 수를 기록해야 하기 때문이다. 더 큰 교훈은 방법론에 있다. 라이브러리 내부 checker와 권위 있는 validator는 각자 blind spot이 있고, PDF/A 출력은 self-check만 믿지 말고 반드시 veraPDF 같은 기준 구현으로 끝까지 시험해야 한다는 점이다. 깨끗한 archive를 가능하게 하는 incremental-update 규율은 compressed object와 xref stream 검증 글과도 닿아 있다. injector가 소비하는 현대 PDF들이 종종 cross-reference stream 위에 세워져 있기 때문이다

암호화, xref stream, 그리고 그 밖의 모서리

ISO 19005가 암호화를 금지하므로 save 경로는 저장 전에 보안을 제거한다. SaveAsPdfA 는 직렬화 시 FPDF_REMOVE_SECURITY 를 적용하므로, 비밀번호를 알고 로드한 암호화 문서는 아카이브로 나갈 때 평문으로 저장된다. 암호화되지 않은 문서에서는 이것이 no-op이며 아무 변화도 없다. 여기서 얻는 결론도 분명하다. HotPDF가 반대 방향에서 강제하는 것처럼, 하나의 파일은 암호화돼 있으면서 동시에 PDF/A일 수 없다. 둘 다 필요하면 배포용 암호화 사본과 보관용 깨끗한 사본, 이렇게 두 개의 산출물이 필요하다

보이지 않다가 한 번 걸리면 크게 아픈 edge도 있다. trailer 키워드 없이 순수 cross-reference stream만 사용하는 PDF 1.5+ 문서다. injector는 source의 /Info 를 찾고 incremental update를 붙이기 위해 trailer를 읽어야 하는데, xref-stream 형태를 이해하지 못하면 marker를 조용히 떨어뜨린 채 파일을 통과시킬 수 있다. ISO 32000-1 §7.5.6은 /Prev 가 xref-stream offset을 가리키는 classic trailer incremental update가 xref-stream 문서 뒤에 오는 것을 명시적으로 허용한다. injector가 실제로 내보내는 구조가 바로 그것이다. PDFium의 FPDF_SaveAsCopy 는 항상 classic trailer를 쓰므로 일반 경로에서 injector가 pure xref-stream source를 직접 만나는 일은 없지만, 다른 곳에서 들어온 문서를 위한 읽기 경로는 그 형태도 처리한다

claim을 믿기 전에 검증하기

라이브러리는 바이트 수준 checker인 TPdf.ValidatePdfA 도 함께 제공한다. 이 메서드는 TPdfAValidationResult 를 반환한다. 그 안의 Conformance 필드는 탐지된 level을, IssuesTPdfAValidationIssue 값들의 집합을 나타낸다. 편의 메서드 IsCompliant 는 실제 level이 감지됐고 issue 집합이 비어 있을 때만 true가 된다. 배치 작업에서는 이 메서드를 빠른 1차 게이트로 쓰면 된다

var
  Pdf: TPdf;
  Res: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.LoadFromFile('invoice_archive.pdf');
    Res := Pdf.ValidatePdfA;
    if Res.IsCompliant then
      Writeln('Conformant: detected level ', Ord(Res.Conformance))
    else
      Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
  finally
    Pdf.Free;
  end;
end;

이것이 어디까지 보장하는지는 정직하게 말해야 한다. 바이트 수준 checker는 구조적 문제, 예를 들어 OutputIntent 누락, 금지된 action, /Encrypt 존재, part 1에서 금지되는 transparency를 높은 신뢰도로 잡아낸다. font embedding 검사는 per-glyph coverage를 추적하는 대신 의도적으로 high-confidence signal만 보고하는 count heuristic을 쓴다. 반면 content-stream operator 분석은 수행하지 않는다. 그것은 전체 content parser를 필요로 하며, 설계상 범위 밖이다. 릴리스 게이트에서는 in-library checker와 veraPDF를 함께 써야 한다. 전자는 DLL 없이 어디서나 즉시 돌 수 있고, 후자는 권위 있는 최종 판정자다. 실제 보관 워크플로에서 이 짝을 배치 실행에 연결하는 방법은 batch preflight report CLI 글이 다룬다

여기서 보여 준 SaveAsPdfA, InjectPdfAMarkers, ValidatePdfA API는 모두 Delphi, C++Builder, Lazarus/FPC용 PDFium Component 에 포함되어 있다. 제품 페이지에는 전체 conformance enum과 예제에 쓰인 options record까지 포함한 API 레퍼런스가 연결돼 있다