기술 문서

Delphi에서 일부 PDF 객체 스트림이 쓰레기로 디코딩되는 이유

오류 없이 압축 해제되는데도 여전히 잡음처럼 읽히는 PDF 객체 스트림은 보통 한 단계를 빠뜨리고 있다: ISO 32000-1 Predictor를 역변환하는 것이다. 스트림의 /DecodeParms 딕셔너리가 /Predictor 2 이상을 가지고 있으면, FlateDecode가 돌려주는 바이트는 원본 데이터가 아니다 — 이는 PNG 방식으로 행 단위 차분 처리되었거나 TIFF 방식으로 수평 차분 처리된 값으로, 어떤 딕셔너리 조회가 말이 되기 전에 두 번째 재구성 패스가 필요하다. Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트 라이브러리인 PDFiumPas는 v2.16.0에서 이 재구성 패스를 추가했는데, 정확히 PDF 1.5 이상의 객체 스트림이 어떤 딕셔너리 파서도 읽을 수 없는 차분 처리된 바이트로 펼쳐지고 있었기 때문이다

왜 FlateDecode만으로는 충분하지 않은가

FlateDecode 자체는 DEFLATE 압축 해제일 뿐이다(ISO 32000-1 §7.4.4.1): 이는 인코더가 압축기에 넘긴 바이트를 그대로 재현할 뿐, 그 이상은 아니다. Predictor는 한 단계 위, 스트림의 /DecodeParms 딕셔너리 안에 있으며, 압축 전에 인코더가 적용한 변환을 설명한다 — 차분 처리는 크로스 레퍼런스 스트림이나 객체 스트림 안에 빽빽하게 채워진 정수처럼 구조적으로 비슷한 값이 길게 이어지는 것을 DEFLATE가 훨씬 잘 압축하는 작은 숫자들의 긴 연속으로 바꾼다. ISO 32000-1 §7.4.4.3(Table 8)은 이 변환을 되돌리는 것이 필터링된 스트림을 디코딩하는 과정의 일부이지 선택적인 정리 단계가 아니라고 명시하지만, inflate만 호출하고 거기서 멈추는 FlateDecode 헬퍼를 작성하기는 쉽다

이 증상은 찾아볼 줄 알면 뚜렷하다. Predictor로 차분 처리된 바이트는 무작위 잡음이 아니다 — 여전히 압축된 스트림의 형태를 유지하고 있어서, 순진한 파서는 흔히 유효해 보이는 토큰 몇 개를 지나친 뒤에야 PDF 이름, 숫자, 구분자일 수 없는 바이트 시퀀스에 부딪히며, 밑에 있는 값이 이웃과 얼마나 우연히 다른지에 따라 서로 다른 행이 서로 다른 오프셋에서 실패한다. 이 불일치가 단일 실패 파일만으로 이 버그를 짚어내기 어렵게 만드는 이유다: 같은 생성기가 만든 두 PDF가 그저 어떤 값이 우연히 반복되는지에서만 차이가 나서, 하나는 거의 우연히 파싱되고 다른 하나는 완전히 실패할 수 있다

PDF Predictor 매개변수는 실제로 무엇을 하는가?

/DecodeParms 안의 /Predictor 항목은 표준을 준수하는 리더에게 어떤 역변환을 실행할지 알려준다. ISO 32000-1 Table 8은 실무에서 중요한 값들을 정의한다: 1은 예측이 적용되지 않았음을, 2는 TIFF Predictor 2(수평 차분)를 선택하고, 10부터 15까지의 어떤 값이든 PNG 방식 예측을 선택한다. 세 개의 키 — /Colors, /BitsPerComponent, /Columns — 가 이와 함께 다니며, 스트림에 이미지 데이터가 전혀 없을 때조차 이들이 함께 차분 처리가 계산된 행 지오메트리를 서술한다: 객체 스트림은 그림이 아니지만, PDF 작성기는 델타-후-디플레이트가 빽빽하게 채워진 정수와 객체 오프셋을 원시 상태로 디플레이트하는 것보다 더 조밀하게 압축하기 때문에 같은 행 기반 predictor 메커니즘을 이를 위해 재사용한다

TIFF Predictor 2는 두 방식 중 더 단순하다: 모든 성분은 같은 행의 이전 픽셀 안의 같은 성분과의 차이로 저장되며, 각 행은 위쪽 행에서 넘어오는 차분을 이어받는 대신 자신의 왼쪽 가장자리에서 초기화된다. PNG 예측은 더 까다로운데, 실제 필터가 행마다 바뀔 수 있기 때문이다: 모든 행은 단일 태그 바이트로 시작한다 — None은 0, Sub는 1, Up은 2, Average는 3, Paeth는 4 — 그리고 선언된 /Predictor 값이 아니라 그 태그가 그 특정 행을 어떻게 재구성할지 결정한다. /Predictor 12는 실제로는 그저 인코더가 Up 필터를 선호했다는 힌트일 뿐이다. Up 필터에서는 각 바이트가 이전 행에서 바로 위에 있는 바이트를 더해 복원되지만, 올바른 디코더는 그럼에도 Up이 전체에 걸쳐 적용된다고 가정하는 대신 매 행마다 태그를 읽어야 한다

객체 스트림은 왜 빠뜨린 Predictor를 보이지 않게 만드는가?

객체 스트림은 이 문제를 그저 반복하는 대신 복합적으로 만든다. ISO 32000-1 §7.5.7은 PDF 1.5 이상 작성기가 여러 간접 객체를 하나의 압축된 컨테이너인 /ObjStm에 담도록 허용하며, 검증기가 가장 필요로 하는 바로 그 객체들 — 카탈로그, /OutputIntents, XMP /Metadata 스트림 — 이 /Predictor 12를 붙인 채로 그 컨테이너를 통해 이동하는 것이 흔한데, 이 객체들은 짧고 반복적이어서 행 차분 처리로부터 이득을 보기 때문이다. predictor 단계가 빠지면, 객체 스트림을 펼치는 것은 오류를 일으키지 않는다: 겉보기에는 그럴듯해 보이지만 기대되는 객체로 토큰화되지 않는 바이트 시퀀스를 만들어내고, 그래서 그 안에 담겨 있던 것은 그저 나타나지 않는다. 렌더링은 거의 알아차리지 못하는데, 표준을 준수하는 렌더링 엔진은 레이아웃에 도달하기 전에 이미 predictor로 차분 처리된 데이터를 재구성하기 때문이다. 이를 알아차리는 코드는 정확히 이 버그가 숨어 있던 그런 종류다 — 구조적 질문에 답하기 위해 원시 PDF 바이트를 직접 순회하는 검증기, 서명기, 버전 검사기로, 객체 스트림에 대한 자신의 시각이 잘못 돌아오면 폴백이 전혀 없다

PDFiumPas는 v2.16.0 이전에 정확히 이 실패를 겪었다. /Predictor 12로 만들어진 객체 스트림 — PDF 1.5 이상 작성기의 흔한 경우 — 은 PdfExpandObjectStreams를 통해 구조 스캐너가 파싱할 수 없는 차분 처리된 바이트로 펼쳐졌고, 그래서 그 안에 담긴 카탈로그, /OutputIntents, /Metadata 객체는 컴플라이언스 스캔에는 사실상 보이지 않았다 — 예외도, 경고도 없이, 그저 그 객체들이 마치 존재하지 않는 것처럼 조용히 동작하는 스캔이었을 뿐이다. PDFiumPas가 하이브리드와 순수 xref 스트림 경우를 포함해 활성 크로스 레퍼런스 테이블에 대해 객체 스트림을 어떻게 해석하는지에 대한 더 깊은 메커니즘은 PDFiumPas로 객체 스트림과 xref 스트림 검증하기에 관한 글에서 별도로 다룬다; 여기서 설명하는 predictor 단계는 그 해석 이후, 각 압축된 객체가 실제로 담고 있는 바이트에 대해 실행된다

파스칼로 PNG와 TIFF Predictor 행 역변환하기

PDFiumPas는 단일 루틴인 PdfApplyPredictor로 차분 처리를 역변환하며, 그 지오메트리 계산은 이를 호출하든 여러분 자신의 Delphi 코드에서 그 발상을 재구현하든 알아둘 가치가 있다. 바이트 단위 행 너비는 ceil(Columns × Colors × BitsPerComponent ÷ 8)이고 두 알고리즘 모두 사용하는 픽셀당 바이트 너비는 ceil(Colors × BitsPerComponent ÷ 8)이다 — 어느 쪽 반올림이든 잘못하면 재구성이 한 행 안이 아니라 행 경계를 가로질러 읽게 된다. /Predictor가 2 미만이면 손대지 않는데, 1은 인코더가 변환을 전혀 적용하지 않았다는 뜻이기 때문이다; 2는 아래 보이는 TIFF 분기를 선택하고, 10 이상의 어떤 값이든 PNG 행 필터 재구성으로 넘어가며, 각 행 시작의 태그 바이트가 — 선언된 /Predictor 값이 아니라 — 그 특정 행이 어떻게 되돌려지는지 결정한다

function PdfApplyPredictor(const Src: TBytes;
  Predictor, Colors, Bpc, Columns: Integer): TBytes;
var
  RowLen, Bpp, R, I: Integer;
begin
  Result:= Src;
  if Predictor< 2 then
    Exit;                                   // 1 = no prediction, nothing to undo
  if Colors<= 0 then Colors:= 1;
  if Bpc<= 0 then Bpc:= 8;
  if Columns<= 0 then Columns:= 1;
  if (Colors> 64)or (Bpc> 32)or (Columns> 1 shl 24) then
    Exit;                                   // reject hostile row geometries
  RowLen:= (Columns* Colors* Bpc+ 7) div 8;  // ceil(), per ISO 32000-1 Table 8
  Bpp:= (Colors* Bpc+ 7) div 8;
  if Predictor= 2 then
  begin
    if Bpc<> 8 then
      Exit;                                 // only the 8-bit layout is reconstructed
    Result:= Copy(Src, 0, Length(Src));
    R:= 0;
    while R+ RowLen<= Length(Result) do
    begin
      for I:= R+ Bpp to R+ RowLen- 1 do
        Result[I]:= Byte(Result[I]+ Result[I- Bpp]);
      Inc(R, RowLen);
    end;
    Exit;
  end;
  // Predictor >= 10 falls through to PNG row-filter reconstruction below
end;
// Continues inside PdfApplyPredictor once Predictor>= 10 (PNG row filters).
// Rows:= Length(Src) div (RowLen+ 1); each row is a 1-byte filter tag
// followed by RowLen data bytes, decoded left to right.
for R:= 0 to Rows- 1 do
begin
  SrcOfs:= R* (RowLen+ 1);
  DstOfs:= R* RowLen;
  Tag:= Src[SrcOfs];
  Inc(SrcOfs);
  for I:= 0 to RowLen- 1 do
  begin
    if I>= Bpp then A:= Result[DstOfs+ I- Bpp] else A:= 0;   // byte to the left
    if R> 0 then B:= Result[DstOfs+ I- RowLen] else B:= 0;   // byte above
    case Tag of
    1: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ A);            // Sub
    2: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ B);            // Up
    3: Result[DstOfs+ I]:= Byte(Src[SrcOfs+ I]+ (A+ B) div 2); // Average
    // Paeth (tag 4) adds whichever of A, B or the byte above-left sits
    // closest to the linear predictor A+ B- C; tag 0 (None) copies the
    // filtered byte through unchanged
    else Result[DstOfs+ I]:= Src[SrcOfs+ I];
    end;
  end;
end;

PDFiumPas가 v2.16.0에서 바꾼 것

PDFiumPas v2.16.0에 배포된 수정은 PdfReadAndDecodeStream 안에 자리한다. 이는 PDF 구조를 바이트 수준에서 검사해야 하는 모든 호출자를 위해 스트림의 원시 바이트를 읽어 디코딩하는 루틴으로, 객체 스트림 펼치기도 포함한다; 이는 /Filter가 연쇄가 아니라 순수한 FlateDecode임을 확인한 뒤에야 재구성을 시도하는데, 연쇄된 필터는 이 계층에서 안전하게 predictor로 보정될 수 없기 때문이다. 스트림 딕셔너리에서 /Predictor, /Colors, /BitsPerComponent, /Columns를 다시 읽는 것도 일반적인 딕셔너리 파서를 필요로 하지 않는다: PdfDictRefNum은 그 하나의 딕셔너리 바이트 범위 안에서 각 키를 직접적인 이름 토큰 검색으로 찾아내는데, 이는 이 네 키가 단일 스트림 딕셔너리 안에서 반복되거나 중첩될 수 없기 때문에 여기서는 안전하다. 같은 이름 토큰 검색이 PDF 파일의 더 크거나 덜 제한된 영역을 향할 때는 훨씬 더 위험해지며, 이는 PDF 딕셔너리를 안전하게 파싱하기에 관한 자매 글의 주제다

// Inside PdfReadAndDecodeStream, right after PdfInflate() has already run:
if PdfFilterIsPureFlate(DictTxt) then
begin
  Inflated:= PdfInflate(Raw);
  Predictor:= PdfDictRefNum(Data, DS, DE, 'Predictor');
  if Predictor>= 2 then
  begin
    PColors:= PdfDictRefNum(Data, DS, DE, 'Colors');
    PBpc:= PdfDictRefNum(Data, DS, DE, 'BitsPerComponent');
    PColumns:= PdfDictRefNum(Data, DS, DE, 'Columns');
    Result:= PdfApplyPredictor(Inflated, Predictor, PColors, PBpc, PColumns);
  end
  else
    Result:= Inflated;
end;

v2.16.0 이전에는 /Predictor 12로 만들어진 객체 스트림이 아무 오류도 일으키지 않고 차분 처리된 바이트로 펼쳐졌으므로, 그 안에 담긴 어떤 카탈로그, /OutputIntents, /Metadata 객체든 아무 경고 없이 PDFiumPas의 구조 스캔에서 사라졌다. 수정 이후에는 같은 객체 스트림이 압축 해제된 뒤 올바르게 재구성되며, 그 안에 담긴 객체들은 그 스캔에 다시 보이게 된다. 이 수정과 함께 방어적 경계도 도입되었다: PdfApplyPredictor는 이제 /Colors 64 초과, /BitsPerComponent 32 초과, /Columns 2^24 초과를 곧바로 거부하는데, 이런 조합은 어떤 실제 PDF 생성기도 필요로 하지 않는 행 지오메트리를 서술하며 주로 디코더가 입력 바이트가 정당화하는 것보다 훨씬 많은 메모리를 할당하도록 만들기 위해 존재하기 때문이다

var
  Pdf: TPdf;
  Report: TPdfAValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'incoming.pdf';
    Pdf.Active := True;
    Report := Pdf.ValidatePdfA;
    if not Report.IsCompliant then
      LogNonCompliance(Report); // caller-supplied handler
  finally
    Pdf.Free;
  end;
end;

알아둘 가치가 있는 제한

PDFiumPas의 predictor 재구성에는 이를 신뢰하기 전에 알아둘 가치가 있는 두 가지 경계가 있다. TIFF Predictor 2 재구성은 성분당 8비트 경우만 다룬다; PDF는 더 좁은 패킹도 허용하지만, 바이트 미만으로 TIFF 방식으로 차분 처리된 데이터는 추측되는 대신 재구성되지 않은 채로 그냥 통과하므로, /BitsPerComponent 1, 2, 4와 함께 /Predictor 2를 선언하는 스트림은 오늘날 이 경로를 통해서는 올바르게 디코딩되지 않는다. PNG 예측에는 그런 제한이 없다 — 모든 행이 자신만의 필터 태그를 제공하며, 선언된 /Predictor 값이 10에서 15 사이 무엇이든 상관없이 정의된 다섯 가지 유형 모두가 재구성된다. 이는 PNG 방식 필터링이 실제로 작동하는 방식과 일치한다: 선언된 값은 모든 행에 대한 약속이라기보다 인코더가 대체로 무엇을 사용했는지에 대한 힌트에 더 가깝다

PDFium의 네이티브 렌더링 엔진은 이미 predictor로 차분 처리된 이미지와 콘텐츠 스트림 데이터를 올바르게 재구성한다. 바로 이 때문에 파일이 어떤 일반적인 뷰어에서든 완벽하게 렌더링되면서도, 그 위에 만들어진 바이트 수준 검증기, 서명기, 버전 검사기는 같은 바이트를 잘못 읽을 수 있다. 여기서 설명한 predictor 인식 디코딩은 Delphi와 C++Builder용 네이티브 VCL PDFium 컴포넌트인 PDFiumPas의 PDF/A 검증, 구조 스캔, 서명 기능을 뒷받침한다