기술 문서

Delphi에서 PDFium Component로 PDF 페이지를 JPEG 이미지로 렌더링하기

PDF 페이지를 JPEG로 렌더링하는 것은 사람들이 종종 한 번에 처리하려다가 결국 각각 따로 디버깅하게 되는 두 가지 작업입니다. 먼저 선택한 해상도로 페이지를 픽셀 비트맵으로 래스터화합니다. 그런 다음 해당 비트맵을 JPEG 인코더에 전달하고 품질을 선택합니다. PDFium Component는 RenderPage를 통해 전반부를 담당하고, 후반부는 순수 VCL인 Vcl.Imaging.jpegTJPEGImage입니다. 렌더링 측면에서 선택하는 해상도와 인코딩 측면에서 선택하는 품질이 서로, 그리고 파일 크기와 맞물려 트레이드오프 관계를 형성하며 이를 잘못 설정하기 쉽기 때문에 이 둘 사이의 경계선에 흥미로운 결정들이 존재합니다

코드를 작성하기 전에 반드시 숙지해야 할 점이 있습니다: PDF 페이지에는 픽셀이 없습니다. 1포인트가 1/72인치인 포인트 단위로 설명되며, 페이지는 이러한 포인트로 측정된 벡터 드로잉입니다. PDFium에 렌더링을 요청할 때, 해당 드로잉을 몇 개의 픽셀에 투사할지 선택하게 되며, 그 선택이 바로 DPI입니다. 계산을 잘못하면 인쇄용 마스터를 원할 때 흐릿한 썸네일을 렌더링하거나 120픽셀 미리보기를 위한 것에 2억 픽셀의 비트맵을 할당하게 됩니다

DPI에서 픽셀 크기로 변환

RenderPage는 DPI가 아니라 정수 픽셀인 WidthHeight를 원합니다. 따라서 첫 번째 작업은 변환입니다. 페이지는 PageWidthPageHeight(둘 다 Double)를 통해 크기를 포인트 단위로 보고하며, 모든 래스터라이저가 사용하는 것과 동일한 변환 방식을 사용합니다: 픽셀은 포인트에 대상 DPI를 곱하고 72로 나눈 값입니다. US Letter 페이지는 612x792 포인트입니다. 150 DPI에서는 1275x1650 픽셀이 되고, 72 DPI에서는 포인트당 1픽셀인 612x792가 유지됩니다. 사람들이 이 경우가 단지 동일한 크기(identity)라는 것을 종종 잊어버립니다

// Pdf.PageNumber must already point at the page you want.
PixelW := Round(Pdf.PageWidth  * Dpi / 72);
PixelH := Round(Pdf.PageHeight * Dpi / 72);
Bitmap := Pdf.RenderPage(0, 0, PixelW, PixelH, ro0, [], clWhite);
// ... use Bitmap ...
Bitmap.Free;   // the function-form RenderPage hands you ownership

이 네 줄의 코드에서 두 가지 세부 사항이 코드가 올바른지 여부를 결정합니다. 첫 번째는 함수 형태의 RenderPage사용자가 소유하는 TBitmap을 반환한다는 점입니다. PDFium은 이를 할당한 후 손을 떼므로, 매 반복마다 이를 Free(해제)하지 않으면 수백 페이지에 걸친 일괄 처리에서 수백 개의 비트맵이 누수되고 무언가 다운될 때까지 프로세스가 비대해집니다. 두 번째는 Color 인수인데 여기서는 clWhite입니다. PDF 페이지는 보통 불투명한 흰색 바탕을 가정하고 그려지며, 투명도가 있는 페이지를 잘못된 배경색으로 렌더링하면 가장자리가 탁해지거나 불필요한 어두운 후광이 생깁니다. 거의 모든 문서에서 흰색이 올바른 기본값이며, 이 매개변수는 그렇지 않은 드문 경우를 위해 존재합니다

0, 0은 크기가 조정된 좌표 공간에서 페이지의 LeftTop 오프셋이며 자르기를 수행하지 않는 한 0으로 둡니다. ro0은 회전입니다: 0으로 두면 PDFium이 페이지의 /Rotate 항목에 선언된 회전값을 존중하므로 가로로 작성된 페이지는 아무 작업도 하지 않아도 가로로 출력됩니다

비트맵을 JPEG로 인코딩하기

비트맵이 생성되면 JPEG는 쉬운 부분이며 순수 Delphi 코드입니다. TJPEGImage.Assign은 비트맵을 복사하고 CompressionQuality는 1에서 100 사이의 품질을 설정하며 SaveToFile은 파일을 씁니다. 유일한 순서 규칙은 SaveToFile이 트리거하는 인코딩을 제어하기 때문에 저장하기 전에 품질을 설정해야 한다는 것입니다

uses
  Vcl.Graphics, Vcl.Imaging.jpeg, PDFium;

procedure SavePageAsJpeg(Pdf: TPdf; PageNumber, Dpi, Quality: Integer;
  const FileName: string);
var
  Bitmap: TBitmap;
  Jpeg: TJPEGImage;
begin
  Pdf.PageNumber := PageNumber;
  Bitmap := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Dpi / 72),
    Round(Pdf.PageHeight * Dpi / 72),
    ro0, [], clWhite);
  try
    Jpeg := TJPEGImage.Create;
    try
      Jpeg.Assign(Bitmap);
      Jpeg.CompressionQuality := Quality;   // 1..100
      Jpeg.SaveToFile(FileName);
    finally
      Jpeg.Free;
    end;
  finally
    Bitmap.Free;
  end;
end;

이 중첩된 try/finally는 단일 페이지용 헬퍼 함수 치고는 까다로워 보이지만 일괄 처리에는 정확히 맞습니다. 내부 블록은 인코더를 해제하고, 외부 블록은 비트맵을 해제하며, 예외 상황에서 어느 하나가 실행되더라도 자신이 소유한 것은 해제합니다. 이를 하나로 합치면 인코딩 중 예외가 발생할 경우 비트맵이 메모리에 고립될 수 있습니다. 장시간 실행할 경우, 이것이 끝까지 완료되는 변환기와 손상된 파일과 메모리 부족 대화 상자를 띄우며 300페이지에서 죽어버리는 변환기의 차이입니다

DPI와 품질 함께 선택하기

이 두 가지 조절기는 출력 목적과 무관하지 않으며, 흔히 하는 실수는 신중을 기한다며 두 개를 모두 높이는 것입니다. 300 DPI로 렌더링하고 품질 95로 저장한 웹 썸네일은 수백 킬로바이트에 달하지만 120픽셀 이미지인 척합니다. 브라우저는 축소할 때 이를 거의 다 버립니다. 출력이 실제로 필요로 하는 픽셀에 해상도를 맞춘 다음, 가시적인 결함 없이 JPEG의 손실 압축을 견뎌내는 품질을 선택하세요

출력DPIJPEG 품질
목록 썸네일7260-70
화면 미리보기96-15080-85
고화질 열람200-30085-95
인쇄 마스터300-60090-100

JPEG 품질은 그 자체로 주의할 만한 가치가 있습니다. 이는 선형 다이얼이 아닙니다. 70에서 85로의 도약은 파일 크기의 완만한 증가와 함께 실질적인 시각적 개선을 가져오지만, 95에서 100으로의 도약은 거의 아무도 구별할 수 없는 차이를 위해 파일을 거의 두 배로 늘립니다. 왜냐하면 품질 100도 여전히 무손실이 아니라 많이 버리는 것을 멈춘 것일 뿐이기 때문입니다. 텍스트가 많은 페이지의 경우, JPEG의 블록 기반 압축은 글리프의 날카로운 가장자리를 희미한 울림(ringing)으로 번지게 하므로, 품질을 약 80 미만으로 설정하면 선명해야 할 출력이 스캔한 텍스트처럼 보이게 됩니다. 페이지가 대부분 텍스트이고 형식을 변경할 수 있다면 PNG는 이러한 울림 없이 텍스트를 렌더링합니다. JPEG는 압축 시 크기가 진정으로 더 작은 사진 및 혼합 콘텐츠에서 그 가치를 발휘합니다

더 빠르고 작은 썸네일

대상이 원본에 충실한 복제가 아닌 썸네일인 경우 렌더러가 더 적은 작업을 수행하도록 지시할 수 있습니다. Options 매개변수는 TRenderOption 플래그 세트를 사용하며, 그중 일부는 작은 미리보기가 원하는 방식과 정확히 일치하게 속도와 정확도를 맞바꿉니다. reGrayscale은 색상을 버리는데 이로 인해 렌더링 속도가 빨라질 뿐만 아니라 인코딩할 비트맵도 작아집니다. reNoSmoothImagereNoSmoothPath는 어차피 썸네일 크기에서는 보이지도 않는 안티앨리어싱을 생략합니다

function RenderThumbnail(Pdf: TPdf; PageNumber, MaxW, MaxH: Integer): TBitmap;
var
  Scale: Double;
begin
  Pdf.PageNumber := PageNumber;
  // Fit the page inside MaxW x MaxH while preserving aspect ratio.
  Scale := Min(MaxW / Pdf.PageWidth, MaxH / Pdf.PageHeight);
  Result := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Scale),
    Round(Pdf.PageHeight * Scale),
    ro0, [reGrayscale, reNoSmoothImage], clWhite);
end;

썸네일 사례는 크기를 조정하는 더 깔끔한 방법도 보여줍니다. DPI를 거치는 대신 페이지를 경계 상자 안에 맞추고 가로세로 비율을 유지하는 단일 배율을 계산하는데, 이는 두 비율 중 Min(최솟값)이 하는 일입니다. 세로 페이지와 가로 페이지 모두 왜곡 없이 같은 상자 안에 들어가며, "200x280에 맞추는 것"이 어떤 DPI에 해당하는지 추론할 필요가 없습니다. reGrayscale을 사용할 때 한 가지 주의할 점은 래스터 이미지 콘텐츠는 회색으로 변환되지만 벡터 채우기 및 텍스트는 엔진에서 색상 값을 유지하므로 대부분 벡터 아트인 페이지는 플래그 이름이 시사하는 것보다 단색이 덜 되어 돌아올 수 있다는 것입니다. 진정한 전체 그레이스케일 결과를 원한다면 렌더링된 비트맵을 GrayscalePdfBitmap으로 변환하는 것이 신뢰할 수 있는 방법입니다

전체 문서 일괄 처리

이것을 결합하여 전체 문서를 처리하려면 PageNumber를 한 번에 한 페이지씩 이동하며 PageCount에 대해 루프를 돕니다. 페이지는 1부터 시작합니다: 1페이지는 PageNumber := 1이며, 루프는 PageCount - 1이 아니라 PageCount까지(포함하여) 실행됩니다. 일괄 처리가 준수해야 할 또 다른 사항은 조용한 로드(silent-load) 규약입니다. Active := True로 설정해도 손상된 파일이나 잘못된 암호에 대해 예외를 발생시키지 않으며 단지 ActiveFalse로 남겨둡니다. 단일 페이지를 렌더링하기 전에 이를 확인하지 않으면 첫 번째 RenderPage가 아예 열리지도 않은 문서에 대해 실행됩니다

procedure ExportAllPages(const PdfPath, OutDir: string; Dpi, Quality: Integer);
var
  Pdf: TPdf;
  I, Digits: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := PdfPath;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Could not open ' + PdfPath);

    Digits := Length(IntToStr(Pdf.PageCount));   // zero-pad so files sort right
    for I := 1 to Pdf.PageCount do
      SavePageAsJpeg(Pdf, I, Dpi, Quality,
        Format('%s\page_%.*d.jpg', [OutDir, Digits, I]));
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Digits를 통한 0 채우기는 나중에 오후 시간을 아껴줄 사소한 작업입니다. 파일 이름을 page_1.jpg부터 page_10.jpg로 지정하면 이를 문자열로 정렬하는 도구가 page_10page_1 바로 다음에 두어 순서를 뒤섞습니다. 가장 높은 페이지 번호의 너비에 맞춰 채우면(예: 300페이지 문서에서 page_001.jpg 생성) 어디에서나 사전순 정렬과 페이지 순서가 동일하게 유지됩니다

변환에 눈에 띄게 시간이 걸릴 정도로 큰 문서의 경우, UI 스레드 밖에서 실행하거나 페이지 간 메시지를 처리(pump messages)하여 애플리케이션의 응답성을 유지하고 사용자에게 중지할 수 있는 방법을 제공해야 합니다. 매우 큰 페이지를 렌더링할 때 페이지 사이에서만 취소하는 것이 아니라 페이지 중간에서도 취소하기를 원한다면, PDFium Component에는 취소 토큰(cancellation token)을 사용하는 점진적 렌더링 경로(progressive render path)가 있습니다. 이는 대부분의 일괄 내보내기에서 필요한 것보다 무거운 메커니즘이지만, 단일 페이지를 600 DPI로 렌더링하는 작업 자체가 화면을 차단할 만큼 느릴 때 활용할 수 있습니다

마지막으로 알아두면 좋은 팁이 있습니다. 페이지를 래스터화하면 텍스트 레이어는 버려집니다: JPEG는 픽셀이므로 그 안에 있는 단어는 더 이상 선택하거나 검색할 수 없습니다. 이미지와 기본 텍스트가 모두 필요한 경우, 렌더링은 이미지용으로 하고 텍스트는 별도로 추출해야 하며, 이에 대해서는 자매 글인 Delphi에서 PDFium Component를 사용하여 PDF 문서에서 텍스트 추출하기에서 다룹니다. 여기에 표시된 RenderPage 오버로드 및 렌더링 옵션은 Delphi 및 C++Builder용 PDFium Component의 일부입니다