세 개의 래스터라이저가 같은 PDF를 읽고도 그 내용에 대해 서로 다른 결론을 낼 수 있다. PDF Library for Delphi에 내장된 엔진은 별도의 파일 없이 배포되며 모든 것을 무난하게 렌더링해 주는 엔진으로, 이것이 기본 자리를 차지하는 이유다. Cairo는 다른 투명도 및 안티에일리어싱 파이프라인을 가져오며, 다른 곳에서 소프트 마스크나 블렌드 모드가 잘못 나올 때 사람들이 손을 뻗는 엔진인 경향이 있다. PDFium은 Chrome의 렌더링 코드를 담고 있어서, 브라우저에서 올바르게 보이는 페이지는 보통 PDFium에서도 올바르게 보이지만, 그 대가로 상당한 크기의 DLL과 반드시 맞춰야 하는 비트 수를 요구한다. 이 셋 중 어느 것도 추상적으로 옳은 것은 없다. 옳고 그름은 문서마다 다르며, 어떤 엔진이 특정 문서 집합을 잘 처리하는지 알아내는 유일하게 정직한 방법은 그 문서 집합을 세 엔진 모두에 실제로 통과시켜 보는 것이다
바로 이것이 엔진을 빌드 타임의 선택이 아니라 런타임의 선택으로 다루어야 하는 이유다. Delphi와 C++Builder를 위한 losLab의 PDF 라이브러리인 PDF Library for Delphi는 세 엔진 모두를 하나의 렌더링 표면 뒤에 배치해서, 그 결정이 코드 분기가 아니라 정수 하나의 비용으로 끝나게 한다. 이 글의 나머지는 이 셋 사이에서 안전하게 선택하는 방법, 배포된 바이너리가 실제로 어떤 엔진을 담고 있는지 확인하는 방법, 그리고 렌더링 상태가 조용히 다음 작업을 오염시키지 않도록 지키는 방법으로 귀결된다
하나의 호출 표면 뒤에 있는 세 개의 래스터라이저
라이브러리는 엔진에 번호를 매긴다. 엔진 1은 내장 렌더러이자 기본값이며, Windows에서는 GDI+ 스무딩 옵션을 갖는다. 엔진 2는 Cairo, 엔진 3은 PDFium이며, 둘 다 SelectRenderer를 통해 런타임에 선택한다. 두 외부 엔진은 선택하기 전에 SetCairoFileName과 SetPDFiumFileName으로 지정한 경로의 DLL에서 로드된다. 어떤 엔진이 활성 상태이든 작업은 같은 호출을 거친다: RenderPageToFile, RenderPageToStream, RenderDocumentToFile이다. 엔진을 전환하는 것은 숫자 하나를 바꾸는 일이며, 나머지 렌더링 코드는 이를 전혀 눈치채지 못한다
대상 모델은 비트맵을 훨씬 뛰어넘는다. 렌더러 클래스는 메타파일(WMF, EMF, EMF+), EPS, 직접 디바이스 컨텍스트, 프린터, HTML5도 대상으로 삼으며, Cairo와 PDFium은 컴파일에 포함된 경우에만 추가 대상으로 나타난다. 세 엔진이 가장 눈에 띄게 갈라지는 지점은 래스터 출력이므로, 여기서는 그것을 예제로 사용한다
엔진이 존재한다고 가정하지 말라: 시작 시점에 확인하라
Cairo와 PDFium은 조건부 컴파일 기능이며, 이는 바이너리가 이 둘 없이 완전히 빌드될 수도 있다는 뜻이다. 그런 상황에서 엔진 2나 3을 요청하면 아무 예외도 발생하지 않는다. SelectRenderer는 그저 요청한 ID와 다른 값을 반환할 뿐이며, 반환값을 무시하는 코드는 이미 활성 상태였던 엔진으로 그대로 렌더링을 계속한다. 이에 대한 방어책은 각 엔진에게 스스로를 식별하도록 요청하고 그 답을 기록하는 시작 시점 점검이다:
function ProbeEngines(PDF: TPDFlib): string;
begin
Result := 'built-in'; // 엔진 1은 항상 존재함
if (PDF.SetCairoFileName('cairo.dll') = 1) and (PDF.SelectRenderer(2) = 2) then
Result := Result + ', cairo';
if (PDF.SetPDFiumFileName('pdfium.dll') = 1) and (PDF.SelectRenderer(3) = 3) then
Result := Result + ', pdfium';
PDF.SelectRenderer(1); // 실제 작업 전에 기본값을 복원함
end;
그 점검을 시작 시점에 한 번 실행하고, 그 결과를 모든 렌더링 작업 옆의 로그에 기록해 두라. 고객이 렌더링 차이를 신고했을 때 가장 흔히 나오는 단일 질문은 그 설치본이 실제로 어떤 엔진을 갖고 있느냐는 것인데, 로그에 남아 있는 한 줄짜리 답이 원격 데스크톱 세션 없이도 이를 해결해 준다. 유용한 부수 효과가 하나 있다: SetPDFiumFileName 자체가 0을 반환한다면, PDFium 지원 없이 컴파일된 바이너리가 아니라 DLL 쪽 문제(잘못된 경로, 잘못된 비트 수, 누락된 의존성)라는 것을 이미 알 수 있는데, SelectRenderer가 실행되기도 전에 경로 호출이 아무것도 찾아내지 못했기 때문이다
하나의 Options 정수 뒤에 있는 열 가지 출력 포맷
렌더링 호출의 Options 매개변수는 출력 인코딩을 선택한다: 0은 BMP, 1은 JPEG, 2는 WMF, 3은 EMF, 4는 EPS, 5는 PNG, 6은 GIF, 7은 TIFF, 8은 EMF+, 9는 HTML5다. PNG(5)는 미리보기와 아카이브용 페이지 이미지에 합리적인 기본값이다. SetJPEGQuality와 짝지은 JPEG(1)는 파일 크기가 선명한 가장자리보다 더 중요한 사진 스캔에 더 나은 선택이다
한 포맷은 대상 스트림에 관한 요구 사항을 숨기고 있다. BMP 경로는 이미지 데이터를 먼저 쓴 다음, 오프셋 0x26으로 되돌아가 헤더의 해상도 필드를 패치한다. 이를 앞으로만 진행하는 스트림, 압축 래퍼, 네트워크 소켓에 겨냥하면, 호출은 엔진 결함처럼 보이지만 실제로는 그렇지 않은 방식으로 실패한다. 되감을 수 없는 대상을 피할 수 없다면 대신 PNG를 렌더링하거나, BMP를 메모리 스트림에 임시로 만든 다음 완성되면 앞으로 복사하라
넘긴 DPI가 실제로 얻는 DPI는 아니다
모든 렌더링 호출은 DPI 인자를 받지만, 실제로 얻는 해상도는 그 값에 전역 렌더 스케일을 곱한 값이다. SetRenderScale은 1.0에서 시작하며, 이를 한 번 바꾸면 그 새 배율은 해당 인스턴스의 이후 모든 렌더링에 조용히 적용된다:
PDF.SetRenderScale(2.0); // 이후의 모든 렌더링이 두 배가 됨
PDF.RenderPageToFile(150, 1, 5, 'p1.png'); // 사실상 300 DPI
PDF.SetRenderScale(1.0); // 재설정하지 않으면 썸네일이 거대하게 나옴
같은 지속성은 SetRenderCropType과 JPEG 품질 설정에도 적용된다. 하나의 공유 인스턴스에서 썸네일, 미리보기, 인쇄 해상도 이미지를 모두 만들어 내는 서비스에서는, 이런 남겨진 설정이야말로 가끔 들어오는 "썸네일이 갑자기 40MB가 됐다"는 문의의 진짜 원인이다. 깔끔한 해결책은 두 가지다: 모든 작업 맨 앞에서 관련 상태를 재설정하거나, 출력 프로필마다 별도의 인스턴스를 두어 서로 새어 들어가지 않게 하는 것이다
다른 엔진에 손대기 전에 기본 엔진부터 조정하라
"다른 엔진이 필요하다"는 요청 중 놀라울 만큼 많은 비율이 실제로는 변장한 설정 문제로 밝혀진다. 내장 렌더러는 SetGDIPlusOptions와 더 폭넓은 SetRenderOptions 계열을 통해 스무딩 동작을 노출하며, SetGDIPlusFileName은 배포 환경이 특이한 GDI+ 런타임을 사용할 때 이를 특정 런타임으로 겨냥할 수 있게 해 준다. 낮은 DPI에서의 들쭉날쭉한 선 그림, 썸네일의 흐릿한 텍스트, 그러데이션에 걸친 밴딩: 이 모든 것은 이런 설정들에 반응하며, 이를 바꾸는 데는 설치 프로그램 측면에서 아무 비용도 들지 않는다. 반면 Cairo나 PDFium을 추가하는 것은 더 많은 DLL을 배포하고, 두 번째나 세 번째 비트 수 변형을 추적하고, 이를 업데이트할 책임을 떠안는 것을 의미한다
그러므로 품질 불만에는 자연스러운 처리 순서가 있다. 먼저 고객의 정확한 DPI와 스케일로 재현해 보라. 절반의 경우는 그 값들이 일치하는 순간 차이가 사라지기 때문이다. 그다음 내장 엔진의 스무딩 옵션을 시도해 보라. 그러고 나서야 다른 모든 변수를 고정한 채 엔진들 사이에 페이지를 나란히 놓고 비교하라: 동일한 DPI로 엔진 1, 2, 3을 통해 각각 PNG로 렌더링해서 세 개를 모두 첨부하라. 보통 셋 중 둘은 일치하며, 그 다수결이 이상값이 문서가 다르게 해석된 결과인지 아니면 자신의 기준 기대치가 잘못된 것인지를 알려 준다. 구체적인 이미지 세 장이 형용사로 가득한 문단보다 "잘못 렌더링된다"는 논쟁을 훨씬 빨리 해결해 준다
스스로를 설명하는 폴백 체인
점검과 상태 관리 원칙이 자리 잡고 나면, 폴백 체인 자체는 짧다. 실패 감지는 LastRenderError에 의존하는데, 이는 가장 최근 렌더링에 대한 엔진 자체의 메시지 텍스트를 담고 있으며 렌더링이 성공했을 때는 비어 있다:
procedure RenderPageWithFallback(PDF: TPDFlib; Page: Integer; const OutFile: string);
begin
PDF.SelectRenderer(1); // 내장 엔진을 먼저
PDF.RenderPageToFile(200, Page, 5, OutFile); // 5 = PNG
if PDF.LastRenderError = '' then Exit;
LogEngineFailure('built-in', Page, PDF.LastRenderError);
if PDF.SelectRenderer(3) = 3 then // 무거운 폴백으로서의 PDFium
begin
PDF.RenderPageToFile(200, Page, 5, OutFile);
if PDF.LastRenderError = '' then Exit;
LogEngineFailure('pdfium', Page, PDF.LastRenderError);
end;
raise Exception.CreateFmt('Page %d failed on all available engines', [Page]);
end;
여기서 두 가지 설계 지점이 중요하다. 이 체인은 각 전환이 왜 일어났는지를 기록하는데, "이 페이지는 릴리스 3.7부터 PDFium으로 폴백되었다"라고 읽히는 로그 한 줄은 유실되기보다 모니터링에서 추세로 남기고 싶은 회귀 신호이기 때문이다. 폴백 순서 자체도 워크로드마다 선택할 가치가 있는 정책이다. 내장 엔진은 별도의 DLL 없이 배포되므로 대부분의 설치본에서 첫 번째로 시도하기에 적절하지만, 투명도 그룹이나 특이한 셰이딩이 많은 문서는 팀이 애초에 대체 엔진을 도입하는 흔한 이유다. 어떤 엔진도 전반적으로 가장 빠르지는 않은데, 이것이 바로 호출마다 선택해야 하는 이유다: 실제 문서 샘플을 실제 DPI로 각각 벤치마크하고, 엔진 DLL이나 문서 구성이 바뀔 때마다 그 측정을 다시 하라. 논쟁은 항상 실제 문서 집합이 결정한다
단일 페이지를 넘어서: TIFF 배치와 실시간 디바이스 컨텍스트
페이지 단위 호출의 이웃 두 가지가 도구 상자를 완성한다. RenderAsMultipageTIFFToFile은 페이지 범위 표현식을 곧바로 다중 페이지 TIFF로 렌더링하는데, 이는 PDF 이전 시대의 문서 관리 시스템에 아카이브를 넘길 때 자연스러운 형태다. RenderPageToDC는 미리보기 컨트롤을 위해 Windows 디바이스 컨텍스트에 직접 그리며, 스케일 계수와 같은 재설정 원칙이 필요한 자신만의 세 가지 지속 설정(SetRenderDCOffset, SetRenderDCErasePage, 그리고 크롭 타입)의 지배를 받는다. 화면 미리보기와 인쇄 경로 렌더링은 그 자체로 전용 글을 쓸 만큼 충분한 함정을 갖고 있으며, 아래에 링크되어 있다
다음으로 살펴볼 것
앞으로도 계속 가져갈 만한 습관이 하나 있다: SelectRenderer는 해당 인스턴스의 이후 모든 호출에 적용되므로, 유독 말썽을 부리는 페이지 하나만 다른 엔진으로 재시도하면서 문서의 나머지는 기본 엔진에 그대로 둘 수 있다. 미리보기 그리기, 프린터 선택, DevMode 처리에 대해서는 인쇄 미리보기와 디바이스 컨텍스트에 관한 글을 이어서 참고하라. 렌더링이 매우 큰 파일에 걸친 대용량 파이프라인에 데이터를 공급할 때는, direct-access 가이드의 핸들 기반 접근 방식이 DARenderPageToFile을 통한 페이지 단위 렌더링과 자연스럽게 짝을 이룬다
엔진 패키징, 지원 포맷, 체험판 빌드에 관한 자세한 내용은 PDF Library for Delphi 제품 페이지에서 확인할 수 있다