기술 문서

Delphi에서 PDFium VCL로 PDF/A 사전검사 검증하기

아카이브 수집 게이트가 데스크 위의 어떤 뷰어에서도 멀쩡히 열리던 "PDF/A-2b" 파일 묶음을 반려했습니다. 공급사는 규격 적합하다고 장담했지만 사실이 아니었습니다. 각 파일의 catalog 안에는 JavaScript action 이 숨어 있었고, 이런 종류는 대충 눈으로 봐서는 놓치기 쉽지만 veraPDF 같은 본격적인 PDF/A validator 는 즉시 잡아냅니다. 문제는 파일마다 예 또는 아니오 한 번만 판정하면 되는 Delphi 배치 서비스에 Java 툴체인을 얹고 싶어 하는 사람이 아무도 없었다는 점입니다. 바로 그 틈을 PDFium Component 의 ValidatePdfACompliance 가 메우며, content stream 을 끝까지 파싱하지 않고도 어떻게 결론을 내리는지 이해해 둘 가치가 있습니다

Delphi용 PDFium Component의 PDF/A preflight 파이프라인: 원본 PDF에서 스트림 본문을 떼내고, 객체 스트림을 팽창하며, 구조 바이트 위의 Pascal 토큰 스캔이 하나의 TPdfAValidationResult를 채워, 그 IsCompliant 게이트는 감지된 레벨과 빈 이슈 집합을 요구
ValidatePdfACompliance는 콘텐츠 스트림을 결코 파싱하지 않습니다. 스트림 본문을 비우고 객체 스트림을 인플레이트한 채 구조 바이트를 스캔하며, 통과에는 탐지된 적합성 수준과 빈 이슈 집합이 필요합니다

왜 PDFium 자체만으로는 답할 수 없는가

먼저 분명히 해야 할 사실이 있습니다. 번들된 pdfium.dll 에는 PDF/A 기능이 전혀 없습니다. ConvertToPDFA 도 없고, OutputIntent writer 도 없고, 공개 API 면에 XMP API 도 없습니다. 이 라이브러리에서 PDF/A 와 관련된 모든 기능은 작성 쪽이든 검사 쪽이든 FPdfPdfa.pas 안의 순수 Pascal 코드로 구현되며, 바이트 수준 파싱과 incremental update 로 동작합니다. 그러니 validator 를 호출할 때 Chromium renderer 에 무언가를 묻는 것이 아닙니다. 파일 구조 바이트 위를 Pascal 토큰 스캐너로 훑는 것입니다

공개 API 는 의도적으로 작습니다. 함수 하나가 stream 을 위치 0에서 읽고 record 하나를 돌려줍니다

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // TPdfAValidationIssue set
    function IsCompliant: Boolean;        // level <> unknown/none일 때만 True
  end;                                    // AND Issues가 비어 있음

IsCompliant 는 게이트에서 실제로 중요한 규칙을 담고 있습니다. 진짜 conformance level 이 감지되고 issue 집합이 비어 있을 때만 파일은 통과입니다. 파싱은 성공했지만 pdfaid marker 를 찾지 못하면 결과는 pacNone 이 되며, 이것은 명시적으로 통과가 아닙니다. 이는 외부에서 batch preflight report CLI 가 말하는 바와도 같습니다. 인식되지 않은 파일에서 findings 목록이 비어 있다고 해서 깨끗하다는 뜻은 아닙니다

토큰 스캔 전에 stream 본문을 비우기

이 구현에서 가장 중요한 디테일이자, 직접 스캐너를 쓰면 가장 쉽게 틀리는 부분이 여기입니다. 검출기는 /JavaScript, /LZWDecode, /BM 같은 구분된 name token 을 찾아 위반을 판정합니다. 그런데 원본 파일 바이트를 그대로 스캔하면 내장된 binary stream 본문, 압축 이미지, ICC profile, font program 안에서 우연히 그런 토큰처럼 보이는 바이트 열이 튀어나옵니다. JPEG 내부의 세 바이트가 우연히 그렇게 배치됐다는 이유로 /AA 나 /3D 를 찾았다고 보고하게 되고, 그러면 오탐 생산기가 됩니다

해결책은 PdfStructureBytes 입니다. 이 루틴은 파일을 순회하면서 모든 stream 과 endstream 사이 바이트를 공백으로 덮어쓰되 dictionary 구조는 그대로 둡니다. 스캔은 그 다음에야 실행됩니다. validator 의 모든 name-token 검사는 이 stripped copy 위에서 동작합니다. 이 글에서 단 하나만 가져가야 한다면 바로 이 점입니다. 동일한 원칙은 PDF/UA validator 에도 반영되어 있으며, 두 표준이 독립적으로 발전하기 때문에 해당 루틴의 사본을 따로 유지합니다

29개 이슈와 각 항목의 의미

TPdfAValidationIssue 는 문서화된 계약입니다. ordinal 은 DUnitX 테스트, 데모, 리포트 레이어가 모두 의존하므로 고정되어 있고, 새 findings 는 항상 맨 끝에만 추가됩니다. v1.63.0 기준으로 멤버는 29개이며, 몇 가지 계열로 나뉩니다

  • Metadata and identity: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates
  • Color and output: pvaiMissingOutputIntent, pvaiMissingIccProfile, 그리고 DeviceRGB 와 DeviceCMYK 가 함께 나타날 때의 pvaiMixedDeviceColorSpaces (6.2.3.3)
  • 모든 파트에서 금지되는 항목: pvaiEncryptionPresent ( /Encrypt dictionary 는 무조건 금지됩니다), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation
  • Fonts: pvaiFontNotEmbedded 와 더 엄격한 pvaiUnembeddedFont, 그리고 pvaiUnicodeMappingMissing 는 /ToUnicode 가 없는 Level U claim 에서 발생합니다
  • Tagging: pvaiLevelAStructureMissing 는 conformance=A claim 에 tagged structure 가 없을 때 발생합니다

ordinal 24부터 29까지 추가된 최신 여섯 멤버는 리뷰어가 실제로 자주 걸려 넘어지는 미묘한 경우를 다룹니다. pvaiTrappedTrue 는 Info dictionary 의 /Trapped /True 를 잡아내는데, 값은 False 또는 Unknown 이어야 하므로 일종의 함정입니다. pvaiForbiddenActionSubtype 는 annotation 이 아니라 action 으로 쓰인 Sound 또는 Movie 를 뜻합니다. pvaiTransparentColorSpace 는 non-Normal blend mode 나 1.0 이 아닌 /CA//ca 를 뜻합니다. 여기에 pvaiAnnotationDictViolation, pvaiUnembeddedFont, pvaiMixedDeviceColorSpaces 가 더해집니다

Delphi용 PDFium Component PDF/A 검증기의 29개 TPdfAValidationIssue 코드 다이어그램: 메타데이터와 신원, 색과 출력, 하드 금지, 폰트, 태깅, 그리고 pvaiTrappedTrue와 pvaiTransparentColorSpace 같은 가장 최신 여섯 발견으로 묶임
29개 이슈 코드는 다섯 계열과 가장 최근 여섯 멤버로 나뉘며, 암호화, JavaScript, LZW 같은 경질 금지는 모든 PDF/A 파트에 적용됩니다

파트 인지형 게이트: A-1은 엄격하고 A-2, A-3는 완화된다

PDF/A 는 단일 규칙집이 아닙니다. PDF/A-1 에서 금지되지만 PDF/A-2 부터는 명시적으로 허용되는 것이 세 가지 있습니다. transparency 는 /Transparency group 이나 활성 /SMask 로 나타날 수 있고 6.4 에 걸립니다. optional content 는 /OCProperties 로 6.1.13 에 걸립니다. embedded file 은 /EmbeddedFiles 또는 /EF 로 6.1.11 에 걸립니다. 순진한 validator 가 이 셋을 모든 파일에서 전부 오류로 처리하면, 사실은 완전히 유효한 PDF/A-2 문서를 대량으로 반려하게 됩니다

그래서 validator 는 PdfAPartOf 를 통해 pdfaid marker 에서 part 번호를 읽고, 이 검사들을 PartNo = 1 조건 뒤에 둡니다. 새 transparency 이슈에 대한 blend-mode 와 annotation-alpha 검사도 마찬가지로 part-1 전용입니다

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

보수적인 기본값 하나도 언급할 만합니다. pdfaid marker 가 아예 없으면 part 는 가장 엄격한 1로 취급됩니다. 식별되지 않은 파일은 쉽게 통과시키기보다 가장 강한 규칙으로 걸러야 한다는 판단입니다. JavaScript, 금지된 action, LZW, XFA, NeedAppearances, 금지된 annotation, 임베드되지 않은 font 는 어느 파트에서도 금지되므로 이런 검사는 게이트 뒤로 숨지 않습니다

아무것도 숨지 못하게 object stream 확장하기

PDF 1.5 는 cross-reference stream 과 object stream(/Type /ObjStm) 을 도입했고, 이것이 순진한 바이트 스캐너의 사각지대를 만듭니다. catalog, OutputIntent, action dictionary 처럼 stream 자체가 아닌 항목도 ObjStm 안에서 Flate 압축될 수 있습니다. 원시 구조만 스캔하면 이런 내용은 하나도 보이지 않고, 실제로는 문제가 많은 파일을 깨끗하다고 보고하게 됩니다

PdfExpandObjectStreams 가 그 빈틈을 메웁니다. 어떤 검사도 실행되기 전에 validator 는 Data := PdfExpandObjectStreams(Data) 를 수행합니다. 루틴은 모든 ObjStm 을 찾아 /N 과 /First 헤더를 읽어 포함된 object 번호와 offset 을 구하고, 본문을 PdfInflate 로 압축 해제한 뒤 Delphi 에서는 System.ZLib, FPC 에서는 zstream 을 사용합니다. 그런 다음 각 contained object 를 일반적인 N 0 obj ... endobj 형태로 바이트 사본의 끝에 붙입니다. 기존 token 검사는 로직을 바꾸지 않고도 이제 그 object 들을 볼 수 있습니다

이 방식이 깨끗하고 덜 취약한 이유는 두 가지 제약 덕분입니다. stream object, Metadata, ICC profile, font program 은 object stream 안에 들어갈 수 없고 non-stream dictionary 만 가능합니다. 그래서 확장은 dictionary 만 다루며, 뒤에 붙는 object 에는 body-stripping pass 를 방해할 stream keyword 가 없습니다. 또한 붙는 내용이 %%EOF 뒤에 오기 때문에 startxref 에서 역방향 검색을 해도 원래 trailer 를 여전히 찾습니다. cross-reference stream trailer 자체는 v1.49.3 에서 이미 plaintext xref-stream dictionary 에서 Root, Size, ID 를 직접 읽는 방식으로 처리되었고, 그 내용은 object 및 cross-reference stream 검증 글에서 다룹니다. object-stream 작업이 추가로 해야 했던 것은 inflate 단계뿐이었고, type-2 xref entry 를 해석하거나 PNG predictor 를 되돌릴 필요는 없었습니다

Delphi의 부분 인식 PDF/A 게이팅: 선언된 pdfaid part가 PartNo = 1 게이트에 공급되어 transparency, optional content, 임베디드 파일 검사를 PDF/A-1에만 활성화하는 반면, JavaScript, LZW, XFA, 미임베드 폰트는 모든 part에서 금지 유지
파트 인식 게이팅은 pdfaid 파트 번호를 읽어 투명성, 선택적 콘텐츠, 임베디드 파일 검사를 PDF/A-1에만 적용하고, 마커가 없는 파일은 가장 엄격한 규칙으로 묶습니다

바이트 수준 검사기의 솔직한 한계

이것은 preflight 도구이지 인증 validator 가 아니며, 경계는 분명합니다. font embedding 은 개수 기반 heuristic 이고, 이를 제대로 맞추는 과정에서 알아둘 만한 수정이 있었습니다. 원래 검사는 PdfCountName('/FontDescriptor') 를 사용했지만, 각 font 는 두 개의 /FontDescriptor token 을 만들어 냅니다. 하나는 font dictionary 에서의 참조이고, 다른 하나는 descriptor object 자체 안의 /Type 이므로 개수는 N개의 embedded program 에 대해 항상 2N 이 되었고 검사는 언제나 참이었습니다. 수정된 방식은 PdfCountDescriptorRefs 로, font 당 하나인 /FontDescriptor N G R 참조 형태만 세고 embedded program 수가 진짜로 더 적을 때만 pvaiUnembeddedFont 를 올립니다

K := PdfCountDescriptorRefs(Struct);                 // font dict마다 하나
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

수정 이후에도 여전히 거칩니다. 모든 descriptor 에 우연히 어떤 FontFile 이든 하나씩만 있어도 개별적으로 규격을 어긴 font 가 섞인 문서는 빠져나갈 수 있습니다. object stream 확장에도 알려진 부작용이 있습니다. AcroForm /DR 가 들고 있는 standard-14 기본 리소스, 예를 들어 /Helv 같은 것이 드러나고, heuristic 은 이를 임베드되지 않은 것으로 성실하게 보고합니다. 그러나 veraPDF 는 그것들이 실제 렌더링에 쓰이지 않는다는 이유로 통과시킵니다. content-stream operator 수준 검사(6.2.10)는 아예 범위 밖입니다. 그건 바이트 스캔이 아니라 완전한 content parsing 이 필요하기 때문입니다. 이 validator 는 dependency 없이 빠르게 1차 게이트를 수행하며 marker injection 만으로는 해결할 수 없는 위반을 잡아내는 용도로 보고, 최종 인증은 완전한 validator 에 맡기는 편이 맞습니다

여기까지가 검사 쪽 이야기입니다. 반대편인 작성 쪽에서는 SaveAsPdfA 가 XMP, OutputIntent, sRGB ICC profile 을 주입하고 tagged structure 가 없는 Level A 요청은 정직하게 낮춰 저장하는데, 이 역시 동일한 바이트 수준 기계 위에 세워져 있습니다. 두 기능은 모두 PDFium Component for Delphi 에 포함되어 있으며, 외부 런타임 설치가 필요 없는 순수 Pascal 기반 PDF/A 구현 위에 얹힌 단일 VCL 패키지입니다