기술 문서

HotPDF로 델파이에서 PDF 프리딕터와 LZWDecode 디코딩하기

/Predictor 12로 표시된 스트림이 모든 행에서 PNG 필터 2를 사용한다는 뜻은 아닙니다. 델파이와 C++Builder용 네이티브 VCL PDF 컴포넌트인 HotPDF는 프리딕터 값 10부터 15까지를 하나의 계열로 취급합니다: 실제 필터 태그, 즉 0부터 4까지는 인코딩된 각 행의 첫 바이트이며, HPDFDecodePredictor는 그 태그를 행 단위로 읽고 검증합니다. 이 구분이야말로 PDF의 이 구석에서 발생하는 거의 모든 버그의 형태입니다. 여러분이 잘못 처리해도 아무것도 예외를 던지지 않기 때문입니다. 필터 체인은 실행되고, 래스터는 예상한 크기로 나오며, 이미지는 대각선 노이즈나 스캔라인마다 점점 더 어긋나는 그러데이션으로 나옵니다. /DecodeParms(ISO 32000-1 §7.4.4)의 다섯 숫자는 대부분 바이트의 길이가 아니라 의미를 바꾸므로, 잘못된 값 하나가 오류가 아니라 그럴듯한 쓰레기 데이터를 만들어냅니다

/Predictor 12가 모든 행에서 PNG 필터 2를 뜻하지 않는 이유는 무엇인가

프리딕터 번호는 "PNG 예측이 사용 중"이라는 것만 말할 뿐, 어떤 필터인지는 말하지 않기 때문입니다. PNG 인코더는 스캔라인마다 필터를 선택하고 PDF 필터는 그것을 그대로 물려받으므로, 프리딕터 값 10(None), 11(Sub), 12(Up), 13(Average), 14(Paeth), 15(Optimum)은 모두 동일하게 디코딩됩니다: 디코더가 따라야 하는 것은 각 행의 선행 태그 바이트입니다. 이 레이아웃이 갖는 결과는 그 의미론만큼이나 중요합니다. 인코딩된 각 행은 1 + RowBytes바이트 길이이므로, 입력은 정확히 행 개수만큼 출력을 초과하며, 길이가 RowBytes + 1의 정수배가 아닌 스트림은 정의상 잘린 것입니다. HotPDF는 바이트를 건드리기 전에 그 경계를 확인하고, 4를 넘는 태그는 Invalid PNG predictor row tag로 거부하며, 2차원 행 배열을 따로 만드는 대신 단일 출력 버퍼에서 직접 이전 행을 읽습니다. 필터 1과 3은 현재 행 안에서 BytesPerPixel만큼 뒤로 손을 뻗고, 필터 2는 바로 위를 읽으며, 필터 4는 왼쪽, 위, 왼쪽-위에 대해 Paeth 선택을 실행합니다 — 이 네 가지 모두 이미 복원된 출력에 대해 동작하며, 그래서 위쪽 행은 반드시 디코딩된 행이어야지 필터링된 입력이어서는 안 됩니다

uses
  HPDFPredictor;

var
  Filtered, Raster: AnsiString;
  ErrorText: string;
begin
  // /DecodeParms << /Predictor 12 /Colors 3 /BitsPerComponent 8 /Columns 1024 >>
  if HPDFTryDecodePredictor(Filtered, 12, 3, 8, 1024,
       Int64(1024) * 3 * 8192, Raster, ErrorText) then
    ConsumeRaster(Raster)
  else
    LogStreamDefect('predictor', ErrorText);   // no exception, no partial raster
end;

MaxOutputBytes 인수는 장식이 아닙니다. 프리딕터 단계는 위장한 압축 해제 단계이며, 악의적이거나 그저 망가진 /Columns 값은 몇 킬로바이트의 입력을 수 기가바이트짜리 할당 요청으로 바꿔버릴 수 있습니다. HotPDF는 행당 비트 수, 행당 바이트 수, 전체 래스터 크기를 먼저 Int64로 계산하고, 오버플로되는 형상은 거부하며, 호출자가 지정한 상한을 존중합니다. 이미지 딕셔너리에서 유도한 실제 상한을 전달하면, 실패 모드는 고객 머신의 메모리 부족 대화상자가 아니라 로그에 남는 메시지가 됩니다

TIFF Predictor 2가 4비트 이미지를 손상시키는 이유는 무엇인가

Predictor 2는 바이트 단위가 아니라 샘플 단위의 수평 차분이며, 컴포넌트당 1, 2, 또는 4비트에서는 여러 샘플이 한 바이트를 공유하기 때문입니다. 흔한 구현은 바이트 N-Colors를 바이트 N에 더하는데, 이는 컴포넌트당 8비트에서는 우연히 맞지만 그 외 모든 경우에는 조용히 틀립니다. 8비트 RGB 스캔은 완벽하게 디코딩되고, 그러다 같은 코드가 4비트 인덱스 이미지가 처음 등장하는 순간 그것을 파괴합니다

올바른 산술은 비트 필드 내부에서 이루어집니다. HotPDF는 인덱스 Colors부터 Colors * Columns - 1까지 샘플을 순회하며, (1 shl BitsPerComponent) - 1 마스크를 적절한 시프트 위치에 적용해 샘플과 같은 컴포넌트의 왼쪽 이웃을 추출하고, 그 마스크를 법으로 둘을 더한 다음, 같은 바이트에 패킹된 다른 샘플들을 건드리지 않고 결과를 다시 씁니다. 꼬리 부분도 중요합니다. 행은 바이트 경계까지 패딩되므로, 마지막 샘플 뒤의 패딩 비트는 산술에 접혀 들어가지 않고 그대로 살아남아야 합니다. 컴포넌트당 16비트에서는 각 샘플이 빅엔디언 바이트 쌍이며, 덧셈은 바이트끼리 독립적으로 캐리되는 것이 아니라 그 쌍을 가로질러 $FFFF에서 랩합니다. 8비트에서는 단순한 바이트 순환식이 맞으며, Colors만큼 건너뛰어 빨강은 빨강끼리, 알파는 알파끼리 누적됩니다. 어떤 변형에서든 행의 첫 픽셀은 차이가 아니라 리터럴 값이며, 순환은 각 행 경계마다 다시 시작됩니다 — TIFF 예측은 결코 위쪽 행을 읽지 않으며, 이것이 PNG 계열과의 전체 차이입니다

LZWDecode에서 EarlyChange는 실제로 무엇을 제어하는가

리더가 언제 코드 크기를 1비트 넓히는지를 제어하며, 코드 하나만 어긋나도 그 뒤의 모든 것이 손상됩니다. HotPDF는 이 규칙을 하나의 불변식으로 표현합니다: 딕셔너리 항목을 추가한 뒤, 다음 읽기는 NextCode(1 shl CodeSize) - Ord(EarlyChange)에 도달하면 넓어집니다. ISO 32000-1 §7.4.4의 기본값인 /EarlyChange 1에서는 전환이 한 코드 일찍 일어나고, /EarlyChange 0에서는 정확히 경계에서 일어납니다. 둘 다 실제 파일에 등장하며, 비트스트림의 그 무엇도 인코더가 어느 쪽을 사용했는지 알려주지 않습니다. 나머지 상태 기계도 발맞춰 움직여야 합니다: 클리어 코드는 코드 크기, 비트 마스크, 다음 여유 코드, 구문 저장소를 함께 재설정하며, 정보 끝 코드는 초기 9비트가 아니라 그 순간 적용 중인 폭으로 읽힙니다. HotPDF는 InitialCodeSize 9에서 시작하여 코드 크기를 12로, 딕셔너리를 4096개 항목으로 상한을 두며, PDF가 상위 비트 우선으로 코드를 패킹하기 때문에 FillOrder를 기본값으로 foTop으로 둡니다 — foBottom은 그렇지 않은 TIFF 방식 스트림을 위해 존재합니다

uses
  HPDFLZW;

var
  Decoder: TPDFLZWDecompressor;
  Parms: TPDFLZWParms;
  Plain: AnsiString;
begin
  Decoder := TPDFLZWDecompressor.Create;
  try
    Decoder.EarlyChange := True;      // /EarlyChange 1 is the PDF default
    Decoder.FillOrder := foTop;       // high-order bit first
    Decoder.MaxOutputBytes := 256 * 1024 * 1024;
    Decoder.RequireInitialClear := False;
    Decoder.RequireEndOfInformation := False;

    Parms.Predictor := 12;
    Parms.Colors := 3;
    Parms.BitsPerComponent := 8;
    Parms.Columns := 1024;
    Parms.ExpandedTo8Bit := False;
    Parms.ColorSpace := 'DeviceRGB';

    if Decoder.TryDecompress(RawStreamBytes, Parms, Plain) then
      LogDecodeStats(Decoder.PeakCodeSize, Decoder.DictionaryAdds,
        Decoder.KwKwKExpansions, Decoder.OutputBytes)
    else
      LogStreamDefect('lzw', Decoder.LastError);
  finally
    Decoder.Free;
  end;
end;

이 통계는 자랑용이 아니라 트리아지를 위해 존재합니다. 파일이 올바른 길이로 디코딩되었지만 픽셀이 틀렸을 때, PeakCodeSizeDictionaryAdds는 리더가 라이터가 넓혔던 지점에서 실제로 넓혔는지 즉시 알려줍니다. EarlyChange를 뒤집어 다시 디코딩하고 둘을 비교하십시오. 숫자가 움직인다면, 비트 리더를 한 단계씩 훑는 대신 한 번의 실행으로 답을 얻은 것입니다

KwKwK 분기, 그리고 스트림이 그냥 실패해야 하는 순간

불법처럼 보이지만 합법인 유일한 경우는 Code = NextCode이며, HotPDF는 항목을 방출하기 전에 그것을 먼저 구성함으로써 이를 처리합니다. 인코더는 같은 단계에서 정의 중인 구문의 코드를 방출할 수 있는데, 이는 입력이 K w K w K 형태의 패턴을 포함할 때마다 일어납니다. 디코더는 그 코드를 아직 존재하지 않으므로 조회할 수 없고, 대신 Previous + First(Previous)를 구성하여 새 항목으로 추가한 다음, 방금 만든 그 항목을 방출해야 합니다. HotPDF는 이를 KwKwKExpansions에 세고, 자신이 추가한 코드가 요청받은 코드와 같은지 교차 검사합니다. NextCode 위의 모든 것은 손상이며, 그 지점에서 디코더는 즉흥적으로 처리하는 대신 멈춰야 합니다: HotPDF는 미래 코드, 구문 영역 밖을 가리키는 딕셔너리 접두사, 가득 찬 딕셔너리, 그리고 리터럴이 아닌 첫 코드에 대해 예외를 던집니다. 두 개의 엄격성 스위치, RequireInitialClearRequireEndOfInformation은 의도적으로 기본값이 꺼짐입니다. 많은 실제 운영 PDF가 선행 클리어 코드를 생략하거나 종료자 없이 데이터가 끝나기 때문입니다. 자신의 출력을 검증할 때는 켜고, 야생에서 온 파일을 소비할 때는 꺼두십시오

로드된 문서 쪽에서 /DecodeParms가 실제로 읽히는 곳

HotPDF는 이미지 스트림 딕셔너리에서 /DecodeParms나 그 축약형 /DP를 해석하며, 딕셔너리와 배열 중 어느 쪽이든 받아들이고 배열일 때는 마지막 요소를 취한 다음, Predictor, Colors, BitsPerComponent, Columns, EarlyChange를 래스터 경로로 넘깁니다. 배열의 경우는 사람들이 잊어버리기 쉬운 경우입니다: [/ASCII85Decode /FlateDecode]로 필터링된 스트림은 병렬 매개변수 배열을 함께 갖고 있으며, 프리딕터 설정은 첫 번째가 아니라 마지막 필터에 속합니다

var
  Pdf: THotPDF;
  Info: THPDFLoadedImageInfo;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned.pdf', '') > 0 then
      for I := 0 to Pdf.GetLoadedImageCount - 1 do
        if Pdf.GetLoadedImageInfo(I, Info) then
        begin
          Bmp := Pdf.ExtractLoadedImage(I);   // nil when the raster is unusable
          if Bmp <> nil then
          try
            Bmp.SaveToFile(Format('image-%d.bmp', [I]));
          finally
            Bmp.Free;
          end;
        end;
  finally
    Pdf.Free;
  end;
end;

이 경로에 있는 과거의 결함 하나는 언급할 가치가 있습니다. 그 버그의 유형이 반복되기 때문입니다. 예전의 매개변수 포함 Flate 루틴은 압축 해제 스트림을 만든 다음 원본 압축 입력에서 복사했으므로, 프리딕터 단계는 압축된 바이트를 받아 그것을 충실하게 역예측했습니다: 항상 틀렸지만 결코 예외를 던지지 않았습니다. 현재 코드는 결과를 공유 프리딕터로 넘기기 전에 오직 디코더로부터만 읽으며, 계산된 크기보다 짧은 래스터는 여전히 압축된 바이트로 폴백하는 대신 거부합니다 — 그 폴백은 예전에는 디코드 실패를 손상된 비트맵으로 바꿔놓곤 했습니다. 같은 프리딕터 구현은 이제 크로스 레퍼런스 스트림도 처리하는데, 객체 스트림과 증분 업데이트를 함께 다루고 있다면 유용한 일관성입니다. 주변의 추출 메커니즘은 로드된 이미지와 그 디코드 필터 추출에 관한 자매 글에서 다룹니다. DCTDecode나 JPXDecode로 도착하는 이미지는 프리딕터에 전혀 도달하지 않습니다. 이들은 자신만의 압축된 픽셀 모델을 갖고 있습니다

처리량: 연속 구문 영역 대 항목별 문자열

항목별 문자열 딕셔너리를 연속된 구문 영역으로 교체하자 병적인 입력에서 약 1.61배 빨라졌습니다: 가장 긴 단일 구문이 7,370,880바이트에 달하는 벤치마크에서 1558 MiB/s 대 969 MiB/s였습니다. 이 입력의 형태가 그 격차를 설명합니다. 고전적인 구현들은 두 가지 나쁜 트레이드오프 중 하나를 선택하기 때문입니다. AnsiString 값의 딕셔너리는 최대 4096개 항목 각각에 대해 새 문자열을 할당하고 복사하며, 각 새 항목은 부모 전체를 통째로 복사합니다. 접두사/접미사 스택은 그 메모리를 피하지만 체인을 거꾸로 한 바이트씩 순회하며 각 구문을 재구성하고 그것을 뒤집습니다. 이는 일반적인 텍스트에는 괜찮지만 어떤 구문이 수 메가바이트에 이를 때는 고통스럽습니다. HotPDF는 각 구문을 기하급수적으로 성장하는 영역에 연속으로 덧붙이고, 오프셋과 길이로 항목을 인덱싱하며, 단일 Move로 구문을 출력 버퍼에 방출합니다. 정직한 비용은 메모리입니다: 모든 구문을 통째로 담는 영역은 항목 개수가 아니라 모든 구문 길이의 합에 의해 제한되며, 이것이 바로 압축 해제기와 프리딕터 양쪽 모두에 MaxOutputBytes가 존재하는 이유입니다. 그 한계를 이미지 딕셔너리가 주장하는 래스터 크기로부터 도출하면, 거짓말하는 스트림은 빠르게 실패합니다

여기서 보여드린 LZW 압축 해제기, 공유 프리딕터, 로드된 이미지 추출 경로는 델파이와 C++Builder용 표준 HotPDF Component에 포함되어 제공됩니다. 전체 필터 및 DecodeParms 레퍼런스는 제품 페이지에서 확인할 수 있습니다