기술 문서

압축된 PDF 유효성 검사: 객체 및 XRef 스트림

작은 유효성 검사기를 작성합니다. PDF를 열고, 끝으로 이동하여 startxref를 찾고, 오프셋을 읽은 다음 고정폭 교차 참조(cross-reference) 테이블 아래에 있는 xref 키워드에 위치할 것을 기대합니다. 해당 테이블에서 객체 오프셋을 수집한 다음, /Root/Size를 알아내기 위해 역방향으로 trailer 키워드를 스캔합니다. 테스트용으로 생성한 모든 파일에서는 완벽하게 작동합니다. 그러다 최신 버전의 Word나 PDF 1.5를 대상으로 하는 라이브러리에서 생성한 파일이 도착하면, 유효성 검사기는 파일이 깨졌다고 선언합니다. 오프셋이 가리키는 곳에 xref 키워드가 없고, trailer 딕셔너리도 어디에도 없으며, 유효성 검사기가 구축한 객체 테이블은 텅 비어 있습니다. 파일은 유효합니다. 다만 유효성 검사기가 15년 전의 렌즈를 통해 파일을 읽고 있을 뿐입니다

이것이 기존(classic) 레이아웃에 맞춰 작성된 바이트 레벨 PDF 검사가 최신 문서에서 실패하는 가장 흔한 이유입니다. 검사기가 의존하는 구조인 일반 텍스트 교차 참조 테이블과 trailer 키워드는 PDF 1.5에서 선택 사항이 되었고, 지금은 흔히 생략됩니다. 이들을 대체하는 두 가지 기능이 있습니다: 교차 참조 스트림(cross-reference stream)과 압축된 객체 스트림(compressed object stream)입니다. 두 가지 모두 ISO 32000-1에 설명되어 있으며, 이를 모르는 유효성 검사기에게는 정상적인 파일이 누락된 객체 더미로 보입니다

PDF 1.5에서 파일 꼬리 부분이 변경된 내용

ISO 32000-1 §7.5.8은 교차 참조 스트림을 정의하고, §7.5.7은 /ObjStm 타입의 객체 스트림을 정의합니다. 이것들을 함께 사용하면 파일 작성자는 구형 파서가 핵심으로 삼는 두 구조를 제외할 수 있습니다. PDF 1.5 파일은 끝에 아예 xref 테이블이 없을 수 있습니다. 그 대신 startxref가 가리키는 객체는 /Type /XRef를 담은 딕셔너리를 가진 일반 스트림 객체이며, 해당 스트림은 작고 밀집된 바이너리 형태의 교차 참조 데이터를 보관합니다. 트레일러 키워드 역시 없습니다. 스트림의 딕셔너리 자체가 트레일러 역할을 하기 때문입니다. 구형 파서가 찾던 키들, 즉 /Root, /Size, /ID는 그 딕셔너리 안에 있습니다

두 번째 변화는 객체 자체를 옮깁니다. 파일 작성자는 모든 간접 객체를 고유한 바이트 오프셋에 일일이 기록하는 대신, 수많은 작은 객체들(페이지 딕셔너리, 주석 딕셔너리, 구조 트리 등)을 하나의 객체 스트림으로 묶어 전체 컨테이너를 Flate로 압축할 수 있습니다. 개별 객체는 더 이상 파일 내에서 바이트 오프셋을 가지지 않습니다. 압축된 blob 안에 위치하게 됩니다. 구형 파서가 원시 바이트에서 1 0 obj를 스캔하더라도 결코 찾을 수 없습니다. 해당 텍스트는 압축이 풀려야만 존재하기 때문입니다. 구형 파서의 관점에서는 문서의 절반이 통째로 증발한 셈입니다

압축된 파일에서도 트레일러 키는 일반 텍스트입니다

안심할 수 있는 점은 교차 참조 스트림의 트레일러를 읽기 위해 무언가의 압축을 풀 필요가 없다는 것입니다. 스트림 객체는 딕셔너리, 그 다음 stream 키워드, 그리고 압축된 바이트 순서로 작성됩니다. 딕셔너리는 일반 텍스트입니다. 따라서 startxref가 교차 참조 스트림을 가리킬 때 객체 번호 바로 다음에 오는 바이트들은 평범한 딕셔너리처럼 보이며 /Root, /Size, /IDstream 키워드와 Flate 데이터가 시작되기 전, 깨끗한 텍스트 상태로 놓여 있습니다

이는 유효성 검사기가 스트림 딕셔너리만 구문 분석하면 카탈로그의 위치, 파일이 주장하는 객체 개수, 파일 식별자 등 가장 필요한 세 가지 사실을 파악할 수 있음을 의미합니다. 교차 참조 데이터를 압축 해제할 필요도 없고, 그 내부의 바이너리 항목을 해석할 필요도 없습니다. 구형 파서를 꺾어버리는 주범은 트레일러를 읽는 과정이 아니라 객체들을 찾는 과정입니다. 이 둘은 분리 가능한 문제이며, 첫 번째 문제를 해결하는 비용은 매우 저렴합니다

객체 스트림: 헤더 다음에 오는 Flate blob

객체 스트림은 컨테이너입니다. 딕셔너리에는 /Type /ObjStm, 안에 패킹된 객체 수를 나타내는 /N 항목, 그리고 압축 해제된 데이터 내에서 첫 번째 객체의 본문이 시작되는 바이트 오프셋을 알려주는 /First 항목이 들어 있습니다. 압축된 페이로드는 팽창되면 /N개의 정수 쌍으로 이루어진 작은 헤더로 시작됩니다. 각 쌍은 객체 번호와 /First에 대한 해당 객체 본문의 상대 오프셋입니다. 헤더 뒤에는 객체 본문들이 연결되어 이어집니다

바이트가 팽창되고 나면 그 중 하나를 확장하는 작업은 기계적입니다. 딕셔너리를 읽어 /N/First를 얻고, Flate 디코더로 스트림을 팽창시킨 다음, 앞부분의 /N 쌍을 탐색하여 어떤 오프셋에 어느 객체 번호가 있는지 확인하고, 마치 평범한 간접 객체인 것처럼 각 본문을 들어 올립니다. 유일한 실질적 의존성은 Flate 디코더뿐이며 이미 보유하고 있을 것입니다: 델파이는 System.ZLib을 함께 제공하고 Free Pascal은 zstream 유닛을 제공하며, 둘 다 타사 코드 없이 zlib을 래핑하고 원시 Flate 스트림을 팽창시킵니다. 추출된 모든 객체를 검사기의 객체 테이블에 추가하는 루틴을 만들면, /Root를 돌고 페이지 트리를 확인하는 유효성 검사기의 나머지 부분은 기존 파일에서 동작하는 것과 완벽히 똑같이 작동합니다

구현할 필요가 없는 부분

작업량을 과대평가하기 쉽습니다. 압축된 파일에서 트레일러 키를 읽는 데에는 교차 참조 스트림의 바이너리 항목들을 디코딩하는 과정이 필요하지 않습니다. §7.5.8 교차 참조 스트림은 세 가지 진입 유형을 사용하는데, "이 객체는 인덱스 i의 객체 스트림 N 내에 존재한다"고 알려주는 타입 2 진입이 바로 여러분이 전체 오프셋 맵을 작성하기 위해 디코딩하게 될 대상입니다. 임의의 객체를 번호로 해결(resolve)하려면 그 맵이 필요합니다. 일반 텍스트 딕셔너리에 있는 /Root, /Size, /ID를 읽는 데는 필요하지 않으며 객체 스트림을 확장하는 데에도 필요하지 않습니다. 각각의 /ObjStm/N/First를 통해 자기 자신의 내용을 공표하기 때문입니다

단지 트레일러 키를 얻기 위해 교차 참조 스트림이 /DecodeParms를 통해 적용할 수 있는 PNG나 TIFF 예측(predictor) 함수도 다룰 필요가 없습니다. 예측기는 바이너리 교차 참조 행을 필터링하여 압축률을 높이기 위한 것으로, 스트림 앞에 있는 딕셔너리와는 아무런 관계가 없습니다. 기존 유효성 검사기를 최신 PDF를 인식할 수 있게 만드는 최소 업그레이드 작업은 작습니다: startxrefxref 키워드 대신 스트림에 도달하면, 스트림 딕셔너리를 구문 분석하여 트레일러 키를 찾고, 발견하는 모든 /ObjStm 객체를 확장하여 그 내용이 객체 테이블에 들어가도록 하십시오. 타입 2 진입 및 예측기 디코딩은 별도의 규모가 큰 작업으로, 실제로 무작위 객체 해결(resolution)이 필요해질 때까지 미뤄둘 수 있습니다

규정 준수(compliance) 검사에서 스트림을 먼저 확장해야 하는 이유

이것은 프로파일 검사를 실행하는 순간 학문적인 이론을 벗어나 실제 문제가 됩니다. PDF/A 또는 PDF/X 유효성 검사기는 특정 객체들을 검사합니다: /OutputIntents 배열을 위한 문서 카탈로그, 올바른 식별자가 있는 XMP 패킷을 위한 /Metadata 스트림, 내장된 폰트 파일을 위한 모든 폰트 기술자, /ID를 찾기 위한 트레일러 등입니다. 압축된 파일에서는 이러한 객체들의 대부분이 객체 스트림 안에 들어 있습니다. 객체 스트림을 확장하지 않은 유효성 검사기는 카탈로그의 키들을 볼 수 없고, 메타데이터를 찾을 수 없으며, 폰트를 열거할 수 없습니다. 완벽히 규칙을 준수하는 문서임에도, 검사기가 요구하는 증거 자료가 여전히 검사기가 팽창시키지도 않은 Flate blob 안에 방치되어 있다는 이유만으로, 출력 인텐트 누락, XMP 누락, 구조의 절반 누락 등으로 보고하게 될 것입니다

순서가 중요합니다. 모든 검사는 객체 번호로 객체에 접근할 수 있다고 가정하기 때문에, 확장은 검사와 동시에 진행되는 것이 아니라 반드시 검사가 실행되기 전에 이루어져야 합니다. 프로파일 검사를 원시 바이트 스캔 위에 직접 연결하면, 구식 파서의 맹점을 그대로 물려받게 되며, 애초에 교차 참조 스트림을 작성할 만큼 새 버전의 툴체인에서 생산된, 형식(well-formed)이 가장 잘 지켜진 최신 파일에서 오히려 거짓된(false) 위반 사항들을 보고하게 됩니다

PDFium에게 파싱 맡기기

PDFium 컴포넌트는 문서를 로드하는 과정의 일부로 교차 참조 스트림과 객체 스트림을 파싱하는데, 이는 팽창(inflate) 및 확장 단계를 수작업으로 구현하는 것을 피하는 실용적인 방법입니다. TPdf 컴포넌트로 파일을 로드하면, /ObjStm 컨테이너에 압축된 객체들이 이미 해결(resolved)된 상태이며, 유효성 검사 진입점은 완전히 확장된 문서를 봅니다. ValidatePdfATPdfAValidationResult 레코드를 반환하며, Conformance 필드는 pac1b 또는 pacNone과 같은 TPdfAConformance 값을 가지고, Issues 필드는 발견된 특정 문제들의 집합(set)입니다. IsCompliant 메서드는 규정 준수 수준이 감지되고 문제 집합이 비어 있을 때만 참(true)이 됩니다. 객체들은 로드 시 확장되었기 때문에 객체 스트림 내에 있던 /OutputIntents 배열이나 포함된 글꼴은 누락된 것으로 보고되지 않고 제대로 발견됩니다

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // 로드 시 xref/객체 스트림을 구문 분석합니다
    Result := Pdf.ValidatePdfA;    // 확장된 객체 테이블을 봅니다
  finally
    Pdf.Free;
  end;
end;

이는 동일한 형태를 반환하는 TPdfXValidationResult를 갖는 ValidatePdfX에도 똑같이 적용됩니다. PDFium을 거치는 것의 핵심은 위에서 설명한 구조적 압축 해제가 로더 내부에서 정확히 한 번 발생하므로, 유효성 검사 코드가 구형 파일과 완전 압축된 파일 간의 차이를 볼 일이 전혀 없다는 점입니다. 두 경우 모두 유효성 검사기에게는 해결된 객체 집합으로 도착합니다

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X 규정 준수: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues는 집합(set)입니다: 멤버 개수를 셉니다
        Inc(IssueCount);
      Writeln('규정을 준수하지 않음; 문제 수 = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

만약 디스크가 아닌 메모리에 이미 바이트가 있다면 LoadDocument(const Data: TBytes) 오버로드를 통해 동일한 로드-다음-유효성-검사 시퀀스가 작동합니다. 이 메서드는 원시 파일 콘텐츠를 취하여 파일 경로를 사용할 때와 같은 방식으로 교차 참조와 객체 스트림을 구문 분석합니다. 직접 유효성 검사기를 작성하는 경우 기억해야 할 것은 API가 아닌 구조적 규칙입니다: 일반 텍스트 상태의 스트림 딕셔너리에서 트레일러 키를 읽고, 문서를 탐색하기 전에 Flate 디코더로 모든 /ObjStm을 확장하며, 바이너리 교차 참조 항목을 디코딩하는 것은 거대하고 선택적인 작업으로 취급하십시오

구조가 한 번 확장되고 나면 유효성 검사기는 이 구조 위에서 워크플로의 나머지 부분들을 수행할 수 있습니다. 폴더 내 모든 입력 파일의 규정 준수 여부를 보고하는 명령줄 프리플라이트 환경을 구축하려면 일괄 프리플라이트 보고서 CLI 구축에 대한 안내서를 참조하십시오. 대형 문서를 여러 개로 분할하기 전 유효성 검사가 관문 역할을 할 때, PDF 문서를 여러 파일로 분할하는 가이드에 소개된 기법들이 여기서 보여준 로드 및 검사 패턴과 자연스럽게 결합됩니다. 이들 모두 Delphi 및 C++Builder용 PDFium Component의 로딩 및 유효성 검사 표면 위에서 구축됩니다