기술 문서

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

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

왜 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;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

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 입니다. 이 루틴은 파일을 순회하면서 모든 streamendstream 사이 바이트를 공백으로 덮어쓰되 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 가 더해집니다

파트 인지형 게이트: 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 를 되돌릴 필요는 없었습니다

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

이것은 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);                 // one per 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 패키지입니다