기술 문서

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 이상의 객체 스트림이 어떤 딕셔너리 파서도 읽을 수 없는 차분 처리된 바이트로 펼쳐지고 있었기 때문이다

Delphi에서 FlateDecode만 도는 PDF 객체 스트림은 Predictor 되돌리기 단계가 원시 객체 데이터를 내놓을 때까지 PNG 또는 TIFF 예측기 차분 바이트를 그대로 지님
FlateDecode는 인플레이션만 합니다. /DecodeParms가 선언한 /Predictor를 되돌려야 차분화된 바이트가 파서가 읽을 수 있는 객체로 돌아옵니다

왜 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 = prediction 없음, 되돌릴 것 없음
  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;                                   // 위험한 row geometry 거부
  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;                                 // 8-bit layout만 복원
    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은 아래 PNG row-filter reconstruction으로 이어짐
end;
// Predictor>= 10이면 PdfApplyPredictor 내부에서 계속됨(PNG row filter)
// Rows:= Length(Src) div (RowLen+ 1); 각 row는 1-byte filter tag
// 뒤에 RowLen data byte가 오며 왼쪽에서 오른쪽으로 decode
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
    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)는 A, B, 위쪽 왼쪽 byte 중
    // linear predictor A+ B- C에 가장 가까운 값을 더함; tag 0(None)은
    // filtered byte를 그대로 통과
    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 딕셔너리를 안전하게 파싱하기에 관한 자매 글의 주제다

PDF 1.5 ObjStm 컨테이너는 catalog, OutputIntents, Metadata 객체를 포장하는데, Delphi에서 예측기 행이 재구축되지 않으면 PDFiumPas 스캔에 보이지 않음
같은 ObjStm이 예측기 행이 재구성되었는지에 따라 패킹된 객체를 내놓거나 구조 스캔에서 조용히 숨깁니다
// PdfInflate() 실행 직후 PdfReadAndDecodeStream 내부
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 검증, 구조 스캔, 서명 기능을 뒷받침한다

PDF 객체 스트림의 PNG 예측기 행은 각각 필터 태그 바이트를 지니고, Delphi의 Sub, Up, Average, Paeth 재구축은 행마다 A, B, C 이웃 바이트를 섞음
각 PNG 행은 자체 필터 태그를 실으며, 재구성은 그 태그가 규정한 대로 정확히 왼쪽, 위, 왼쪽 위 바이트를 섞습니다