기술 문서

PDFium을 사용한 자동화된 PDF 프리플라이트 및 위험 감사

프로덕션 경계(인쇄 대기열, 아카이브, 고객 업로드 포털 등)에 도착하는 PDF는 렌더링하기 전에 감사를 거쳐야 합니다. 이 파일에는 외부 프로그램을 시작하도록 연결된 실행(Launch) 액션, 인쇄 품질을 유지하기에 해상도가 너무 낮은 이미지, 파일이 제출된 그 인쇄 작업을 금지하는 암호화 딕셔너리(dictionary), 또는 기준에 미달하는 PDF/A 레이블이 포함되어 있을 수 있습니다. 문서가 워크플로에 진입하기 전에 이러한 규칙들에 대해 문서를 검사하는 것을 프리플라이트(preflighting)라고 부르며, PDFium C API는 단일 페이지를 렌더링하지 않고도 검사를 직접 구현하는 데 필요한 모든 것을 Delphi에 제공합니다

이 문서는 검사 자체를 구축합니다: 공유 결과 목록에 탐지 결과를 추가하는 각각의 작은 루틴인 네 개의 감사 클래스. 대화형 요소, 리소스 지표, 보안 상태, 그리고 표준 마커 모두 산술 계산을 포함하는 작동 코드를 얻게 됩니다. 일괄 폴더 루프, JSON 및 HTML 보고서 파일, 파일별 격리(isolation)와 같이 검사 주변의 기계적 구조가 필요한 경우, PDFium 컴포넌트는 미리 준비된 프리플라이트 엔진을 제공하며, 일괄 프리플라이트 CLI 기사가 배관 작업을 다룹니다. 두 가지 모두 동일한 종료 코드(exit-code) 어휘를 의도적으로 공유하므로, 여기서 작성된 감사기(auditor)는 해당 일괄 드라이버 아래에 곧바로 들어맞습니다

탐지 레코드와 종료 코드 계약

모든 검사는 하나의 평평한(flat) 레코드 유형으로 기록됩니다. 왜냐하면 대안으로 각 검사가 자체 산문(prose)을 출력하게 되면 나중에 세거나, 필터링하거나, 임계값을 적용할 수 없기 때문입니다. 네 개의 필드면 충분합니다

uses
  System.SysUtils, System.Math, System.IOUtils,
  System.Generics.Collections, pdfium_lib;

type
  TFindingSeverity = (fsInfo, fsWarning, fsError);

  TPreflightFinding = record
    Severity: TFindingSeverity;
    Code: string;       // stable machine key, e.g. 'ACT-LAUNCH'
    Page: Integer;      // 1-based; 0 means document level
    Message: string;    // for humans; free to reword between releases
  end;

  TFindings = TList<TPreflightFinding>;

procedure Add(Findings: TFindings; Severity: TFindingSeverity;
  const Code: string; Page: Integer; const Msg: string);
var
  F: TPreflightFinding;
begin
  F.Severity := Severity;
  F.Code := Code;
  F.Page := Page;
  F.Message := Msg;
  Findings.Add(F);
end;

하위 도구(Downstream tooling)는 변경될 수 있는 Message 텍스트가 아니라 Code를 기준으로 합니다. 프로세스 종료 코드는 일괄 처리(batch) 기사와 동일한 세 가지 값 계약을 따릅니다: 0은 파일에서 탐지 결과가 생성되지 않았음을 의미하고, 1은 탐지 결과가 있음을 의미하며, 2는 파일 파싱 실패나 비밀번호 요구로 인해 감사 자체를 실행할 수 없었음을 의미합니다. 코드 2를 별도로 유지하는 것이 중요합니다. 손상된 스캔 폴더는 상위 시스템의 망가진 스캐너 문제이지 갑작스러운 규정 준수 붕괴가 아니며, 두 가지를 합치면 누군가가 잘못된 문제를 쫓게 만들기 때문입니다

대화형 요소: 스크립트, 실행 대상, 외부 링크

PDFium은 발견하는 모든 액션을 정수 유형으로 분류하며, fpdf_doc.h의 상수들을 정확하게 고정해 두는 것이 가치 있습니다. 잘못 복사된 값은 스캐너를 조용히 눈멀게 만들기 때문입니다. 실제 열거형은 PDFACTION_UNSUPPORTED = 0, PDFACTION_GOTO = 1, PDFACTION_REMOTEGOTO = 2, PDFACTION_URI = 3, PDFACTION_LAUNCH = 4, 그리고 PDFACTION_EMBEDDEDGOTO = 5입니다. 무엇이 없는지 주목하세요: JavaScript 멤버가 없습니다. 문서 수준 스크립트는 링크 액션이 아니며 FPDFAction_GetType을 통해 나타나는 일이 결코 없습니다. 별도의 호출 제품군에 의해 열거됩니다. 상상 속의 JavaScript 상수와 액션 유형을 대조 테스트하는 감사기는 컴파일되고 실행되지만 결코 아무것도 찾아내지 못할 것입니다

const
  PDFACTION_GOTO         = 1;   // in-document jump: harmless
  PDFACTION_REMOTEGOTO   = 2;   // jump into another local file
  PDFACTION_URI          = 3;   // opens an external URL
  PDFACTION_LAUNCH       = 4;   // starts an external program
  PDFACTION_EMBEDDEDGOTO = 5;   // jump into an embedded file

function ActionTarget(Doc: FPDF_DOCUMENT; Action: FPDF_ACTION;
  AType: ULONG): string;
var
  Buf: array[0..2047] of AnsiChar;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  if AType = PDFACTION_URI then
    FPDFAction_GetURIPath(Doc, Action, @Buf, SizeOf(Buf))
  else
    FPDFAction_GetFilePath(Action, @Buf, SizeOf(Buf));
  Result := string(UTF8String(PAnsiChar(@Buf)));
end;

procedure AuditPageActions(Doc: FPDF_DOCUMENT; Page: FPDF_PAGE;
  PageNo: Integer; Findings: TFindings);
var
  StartPos: Integer;
  Link: FPDF_LINK;
  Action: FPDF_ACTION;
  AType: ULONG;
begin
  StartPos := 0;
  while FPDFLink_Enumerate(Page, @StartPos, @Link) <> 0 do
  begin
    Action := FPDFLink_GetAction(Link);
    if Action = nil then
      Continue;                 // destination-only link, nothing to flag
    AType := FPDFAction_GetType(Action);
    case AType of
      PDFACTION_LAUNCH:
        Add(Findings, fsError, 'ACT-LAUNCH', PageNo,
          'Launch action targets "' + ActionTarget(Doc, Action, AType) + '"');
      PDFACTION_URI:
        Add(Findings, fsWarning, 'ACT-URI', PageNo,
          'link opens ' + ActionTarget(Doc, Action, AType));
      PDFACTION_REMOTEGOTO, PDFACTION_EMBEDDEDGOTO:
        Add(Findings, fsWarning, 'ACT-XFILE', PageNo,
          'cross-file destination "' + ActionTarget(Doc, Action, AType) + '"');
    end;                        // PDFACTION_GOTO stays silent by design
  end;
end;

procedure AuditDocumentBehaviors(Doc: FPDF_DOCUMENT; Findings: TFindings);
var
  N: Integer;
begin
  N := FPDFDoc_GetJavaScriptActionCount(Doc);
  if N > 0 then
    Add(Findings, fsError, 'JS-DOC', 0,
      Format('%d document-level JavaScript action(s) run on open', [N]));
  N := FPDFDoc_GetAttachmentCount(Doc);
  if N > 0 then
    Add(Findings, fsWarning, 'ATT-EMB', 0,
      Format('%d embedded file attachment(s)', [N]));
end;

심각도(severity)의 분할은 정책을 인코딩합니다. 실행(Launch) 액션은 임의의 프로그램을 시작하는 것이 PDF 내의 클릭이 할 수 있는 가장 위험한 일이며 송장에는 그런 기능이 전혀 필요 없기 때문에 오류입니다. 외부 URI는 경고입니다: 합법적인 문서에서 흔하지만, 시각적인 링크 텍스트와 실제 대상이 일치하지 않을 수 있으므로 리뷰어는 클릭하지 않고도 대상을 확인할 수 있어야 합니다. 문서 내 GoTo 점프는 행동이 아닌 구조이므로 보고서에서 완전히 배제됩니다. 모든 목차 항목에서 양치기 소년처럼 허위 경보를 울리는 프리플라이트는 사용자들이 이를 무시하도록 길들입니다. JavaScript 카운트 이면의 스크립트 본문을 읽고, 서명 MDP 수준 및 XFA 감지를 알아보기 위해 보안 위험 감사 문서는 컴포넌트의 객체 래퍼(wrapper)를 통해 동일한 표면을 안내합니다

리소스 지표: 유효 이미지 DPI

PDF 내부의 이미지는 자체 DPI를 가지고 있지 않습니다. 픽셀을 가지고 있으며, 페이지는 인치당 72포인트 단위로 측정되는 직사각형 안에 픽셀을 배치합니다. 해상도는 이 두 가지 사이의 비율로만 존재합니다. 이것이 동일한 600x400 크기의 사진이 섬네일에서는 아주 선명하고, 꽉 찬 히어로 이미지에서는 흐릿하게 깨져 보이는 이유입니다. 따라서 감사는 모든 이미지에 대해 두 숫자 모두가 필요합니다: 이미지 메타데이터에서 가져온 원본 픽셀 크기와, 객체 경계 영역에서 가져온 배치된 사각형

procedure AuditPageImages(Page: FPDF_PAGE; PageNo: Integer;
  Findings: TFindings);
var
  I, ObjCount: Integer;
  Obj: FPDF_PAGEOBJECT;
  Meta: FPDF_IMAGEOBJ_METADATA;
  L, B, R, T: Single;
  WidthPt, HeightPt, DpiX, DpiY, EffDpi: Double;
begin
  ObjCount := FPDFPage_CountObjects(Page);
  for I := 0 to ObjCount - 1 do
  begin
    Obj := FPDFPage_GetObject(Page, I);
    if FPDFPageObj_GetType(Obj) <> FPDF_PAGEOBJ_IMAGE then
      Continue;
    if FPDFImageObj_GetImageMetadata(Obj, Page, @Meta) = 0 then
      Continue;
    if FPDFPageObj_GetBounds(Obj, @L, @B, @R, @T) = 0 then
      Continue;

    WidthPt  := R - L;              // placed size on the page, in points
    HeightPt := T - B;
    if (WidthPt <= 0) or (HeightPt <= 0) or
       (Meta.Width = 0) or (Meta.Height = 0) then
      Continue;

    // 72 points = 1 inch, so placed inches = points / 72, and
    // effective DPI = source pixels / placed inches.
    DpiX := Meta.Width  / (WidthPt  / 72.0);
    DpiY := Meta.Height / (HeightPt / 72.0);
    EffDpi := Min(DpiX, DpiY);      // the worse axis decides print quality

    if EffDpi < 150.0 then
      Add(Findings, fsWarning, 'IMG-LOWRES', PageNo,
        Format('image %dx%d px placed at %.1fx%.1f pt = %.0f DPI effective',
          [Meta.Width, Meta.Height, WidthPt, HeightPt, EffDpi]))
    else if EffDpi > 600.0 then
      Add(Findings, fsInfo, 'IMG-BLOAT', PageNo,
        Format('image is %.0f DPI at placed size; resampling would ' +
          'shrink the file with no visible loss', [EffDpi]));
  end;
end;

임계값은 물리학이 아니라 정책입니다. 150 DPI는 그 아래로 내려가면 사무용 인쇄에서 눈에 띄게 픽셀화되는 하한선이고, 300은 일반적인 상업적 목표치이며, 600을 넘는 수치는 시각적인 품질 향상은 없이 파일 크기만 부풀립니다. 이것이 결함(defect)이 아니라 정보성 팽창(bloat)으로 보고되는 이유입니다. 한 가지 솔직한 주의 사항: FPDFPageObj_GetBounds는 축 정렬된 상자를 반환하므로 회전하여 배치된 이미지의 경우 계산된 수치가 실제 밀도를 과소평가합니다. FPDF_IMAGEOBJ_METADATA 구조체는 또한 PDFium이 전체 변환(transform) 행렬에서 파생하는 horizontal_dpivertical_dpi 필드를 가지고 있으며, 이 두 결과를 비교하는 것이 회전된 배치를 발견하는 값싼 방법입니다. 동일한 포인트-대-픽셀(points-to-pixels) 산술이 역방향의 렌더링을 구동하며, 이는 JPEG 내보내기 기사에서 다룹니다

보안 상태: 암호화 및 권한 비트

PDF 암호화는 각기 다른 역할을 하는 두 가지 비밀번호를 정의합니다. 사용자 비밀번호는 복호화를 제어합니다: 이것 없이는 파일이 아예 열리지 않으며 FPDF_LoadDocumentnil을 반환하고 FPDF_GetLastErrorFPDF_ERR_PASSWORD를 보고합니다. 소유자 비밀번호는 권한을 제어합니다: 소유자 비밀번호로만 보호된 파일은 인증 정보 없이 열리지만, 준수하는 리더가 지켜야 하는 제한 비트를 담고 있습니다. 따라서 불러오기 시도 자체가 첫 번째 보안 탐침(probe)이며, 이 차이가 종료 코드를 결정합니다 — 사용자 비밀번호 파일은 감사할 수 없고(코드 2), 반면 소유자 비밀번호 파일은 정상적으로 감사되며 단순히 탐지 결과를 누적합니다

const
  FPDF_ERR_PASSWORD = 4;

function AuditSecurity(const FileName: string;
  Findings: TFindings): FPDF_DOCUMENT;
var
  Perms: ULONG;
  Revision: Integer;
begin
  Result := FPDF_LoadDocument(PAnsiChar(AnsiString(FileName)), nil);
  if Result = nil then
  begin
    if FPDF_GetLastError() = FPDF_ERR_PASSWORD then
      Add(Findings, fsError, 'SEC-USERPW', 0,
        'user (open) password required; audit cannot proceed')
    else
      Add(Findings, fsError, 'DOC-BROKEN', 0, 'file failed to parse');
    Exit;
  end;

  Revision := FPDF_GetSecurityHandlerRevision(Result);
  if Revision >= 0 then       // -1 means the file is not encrypted
  begin
    // Opened with an empty password yet encrypted: owner-password-only.
    // Anyone may read it, but the permission bits restrict what a
    // conforming reader lets them do. Unencrypted files report all
    // bits set, which is why the revision gate comes first.
    Perms := FPDF_GetDocPermissions(Result);
    Add(Findings, fsInfo, 'SEC-ENC', 0,
      Format('encrypted, security handler revision %d', [Revision]));
    if (Perms and 4) = 0 then      // bit 3: print
      Add(Findings, fsWarning, 'SEC-NOPRINT', 0,
        'printing is not permitted');
    if (Perms and 16) = 0 then     // bit 5: copy / extract content
      Add(Findings, fsInfo, 'SEC-NOCOPY', 0,
        'content extraction is not permitted');
    if (Perms and 2048) = 0 then   // bit 12: high-resolution print
      Add(Findings, fsWarning, 'SEC-LOWPRINT', 0,
        'only low-resolution printing is permitted');
  end;
end;

마스크(masks)는 ISO 32000-1의 표 22에서 가져온 것으로, 1부터 비트 번호를 매깁니다: /P 값의 비트 3은 마스크 4, 비트 5는 16, 비트 12는 2048입니다. 특정 탐지 결과가 중요한지는 라우팅 결정 사항입니다. 인쇄소는 마감 3시간 전에 RIP에서 튕겨내는 것이 아니라 접수(intake) 단계에서 제출자가 명확한 메시지를 받을 수 있도록 SEC-NOPRINT 파일을 거절(bounce)해야 합니다. 암호화와 장기 보존은 서로 어울리지 않으므로 아카이브는 SEC-ENC 자체를 차단 요소로 처리해야 합니다. 이는 표준 검사가 곧 공식적으로 지적할 부분입니다

표준 마커: PDF/A 클레임 읽기

파일은 XMP 메타데이터 패킷의 pdfaid:part 속성(1~4)과 pdfaid:conformance(시각적 충실도를 위한 b, 전체 구조적 태깅을 위한 a와 같은 레벨 문자)를 통해 PDF/A 준수를 선언합니다. PDFium의 C API는 XMP 접근자(accessor)를 제공하지 않습니다. FPDF_GetMetaText는 정보(Info) 딕셔너리만 읽는데, 식별 정보는 거기에 없습니다. 비상구는 표준 자체의 규칙에 있습니다: ISO 19005는 전체 PDF 파서(parser)가 없는 도구도 그것을 찾아낼 수 있도록 XMP 메타데이터 스트림이 압축되지 않은 상태로 저장될 것을 요구합니다. 따라서 원시 바이트 스캔은 합법적인 클레임 탐지기이며, 압축된 스트림 내부에 클레임을 숨긴 파일은 이미 그 주장의 표준을 위반한 것입니다

function PdfAClaim(const FileName: string): string;
var
  Bytes: TBytes;
  S: RawByteString;
  P, Limit: Integer;
begin
  Result := '';                     // empty = no PDF/A claim present
  Bytes := TFile.ReadAllBytes(FileName);
  if Length(Bytes) = 0 then
    Exit;
  SetString(S, PAnsiChar(@Bytes[0]), Length(Bytes));
  P := Pos('pdfaid:part', S);       // XMP identification schema
  if P = 0 then
    Exit;
  // Handles both <pdfaid:part>2</pdfaid:part> and pdfaid:part="2":
  // take the first digit after the property name.
  Limit := Min(P + 32, Length(S));
  Inc(P, Length('pdfaid:part'));
  while (P <= Limit) and not (S[P] in ['1'..'4']) do
    Inc(P);
  if P <= Limit then
    Result := 'PDF/A-' + Char(S[P]);
end;

이 코드가 생성하는 탐지 결과는 의도적으로 정보를 제공할 뿐입니다. 왜냐하면 클레임(claim)은 선언이지 파일의 속성이 아니기 때문입니다. XMP 항목은 망가진 것을 포함하여 어떤 제작기든 쓸 수 있는 한 줄의 XML입니다. 적합성(conformance)은 내장 폰트, 기기 독립적인 색상, 금지된 기능 등에 대한 수백 가지의 규칙을 파일이 실제로 만족시키는 것입니다. 클레임을 감지하는 것은 실제 검증 단계로 넘겨야 할 파일이 무엇인지 알려주는 것 외에 아무런 의미가 없습니다. 컴포넌트의 내장 프리플라이트 엔진은 PDF/A, PDF/UA, PDF/X 프로필 전반에 걸쳐 해당 검증을 수행하며, 일괄 CLI 기사는 나중에 감사자가 열어볼 수 있는 보고서 파이프라인에 이 엔진을 연결하는 방법을 보여줍니다

문제가 있는 파일에 대한 실행

드라이버는 검사를 다음과 같이 연결합니다: 먼저 감사가 실행될 수 있을지 여부를 결정하는 보안, 그다음 문서 수준 동작 및 표준 클레임, 마지막으로 액션 및 이미지를 위한 페이지 루프로 이어집니다

function AuditFile(const FileName: string; Findings: TFindings): Integer;
var
  Doc: FPDF_DOCUMENT;
  Page: FPDF_PAGE;
  I: Integer;
  Claim: string;
begin
  Doc := AuditSecurity(FileName, Findings);
  if Doc = nil then
    Exit(2);                        // audit failure, not a verdict
  try
    AuditDocumentBehaviors(Doc, Findings);
    Claim := PdfAClaim(FileName);
    if Claim <> '' then
      Add(Findings, fsInfo, 'STD-PDFA', 0,
        Claim + ' conformance claimed (declaration only, not validated)');
    for I := 0 to FPDF_GetPageCount(Doc) - 1 do
    begin
      Page := FPDF_LoadPage(Doc, I);
      if Page = nil then
      begin
        Add(Findings, fsError, 'PAGE-BROKEN', I + 1, 'page failed to parse');
        Continue;
      end;
      try
        AuditPageActions(Doc, Page, I + 1, Findings);
        AuditPageImages(Page, I + 1, Findings);
      finally
        FPDF_ClosePage(Page);
      end;
    end;
  finally
    FPDF_CloseDocument(Doc);
  end;
  if Findings.Count > 0 then
    Result := 1
  else
    Result := 0;
end;

외부 에이전시로부터 돌아온 브로슈어에 대한 출력 결과는 다음과 같습니다

> preflight_audit brochure_final.pdf
brochure_final.pdf: 5 finding(s)
  [ERROR]   ACT-LAUNCH   page 3   Launch action targets "..\tools\setup.exe"
  [ERROR]   JS-DOC       doc      2 document-level JavaScript action(s) run on open
  [WARNING] IMG-LOWRES   page 7   image 412x287 px placed at 396.0x275.8 pt = 75 DPI effective
  [WARNING] SEC-NOPRINT  doc      printing is not permitted
  [INFO]    STD-PDFA     doc      PDF/A-2 conformance claimed (declaration only, not validated)
exit code 1

각 라인은 그 자체로 조치 가능하지만, 결과의 조합이 실제 판결(verdict)을 나타냅니다. 이 파일은 암호화 딕셔너리와 라이브 JavaScript를 포함하면서도 PDF/A-2를 주장하는데, PDF/A는 이 둘 모두를 명백히 금지하고 있으므로 심층 검증기가 실행되기 전에 주장이 거짓임이 증명됩니다. 평탄한(flat) 탐지 결과 목록은 이러한 모순을 표면으로 끌어올리며, 불리언(boolean) 기반의 합격/불합격 방식은 이를 숨기게 됩니다

이 감사가 알려줄 수 없는 것

범위에 대한 정직성이 프리플라이트 도구에 대한 신뢰를 유지하는 비결입니다. 위의 모든 내용은 파일이 자신에 대해 선언한 것을 읽어냅니다: PDFium이 구조를 파싱하고, 이 감사가 그 목록을 작성합니다. 이 과정은 PDF/A 검증을 수행하지 않습니다. 내장 폰트에 대한 글리프 범위(glyph-coverage) 검사, 출력 인텐트(output intents)에 대한 색상 공간 분석, 그리고 주장을 적합성과 구별 짓는 조항 수준의 규칙 중 어느 하나도 수행하지 않습니다. 이를 위해서는 컴포넌트의 프리플라이트 엔진이나 veraPDF와 같은 전용 검증기가 필요합니다. 권한 비트는 규정을 준수하는 리더가 존중하는 선언일 뿐, 암호학적 장벽이 아닙니다. 따라서 SEC-NOPRINT는 강제적인 것이라기보다는 의도를 서술하는 것입니다. 액션 스캔은 링크 주석과 문서 수준 스크립트를 다룹니다. 양식 필드(form-field) 이벤트 딕셔너리에 묻혀 있는 스크립트는 폼(form) API를 추가로 사용해야 합니다. 감사를 확장해 서명 검사를 추가한다면, 이는 암호학적으로 검증된 내용이 아니라 선언된 의도만 보고합니다. 인증서 체인 검증은 별개의 작업입니다. 프리플라이트 감사는 일종의 접수 면접이지 재판이 아닙니다. 그 임무는 정보에 입각하여(informed) 빠르고 반복 가능한 라우팅 결정을 내리는 것입니다

참고: 이 감사에서 사용된 문서, 페이지, 주석 및 이미지 객체 API는 고급 Delphi 래퍼 및 모든 표준 검증을 갖춘 프리플라이트 엔진과 함께 PDFium 컴포넌트에 포함되어 배포됩니다