기술 문서

HotPDF로 Delphi에서 PDF 페이지를 비트맵 렌더링

HotPDF는 로드된 PDF 페이지를 RenderLoadedPageToBitmap(PageIndex, DPI)라는 호출 하나로 Delphi TBitmap에 렌더링합니다. 이 함수는 페이지의 콘텐츠 스트림을 해석해 여러분이 고른 해상도의 24비트 RGB 비트맵을 호출자 소유로 반환하며, 이는 썸네일 띠나 인쇄 미리보기, PDF-이미지 내보내기 파이프라인이 정확히 필요로 하는 것입니다. 이 글은 먼저 API를 살펴본 다음, 쓸 만한 렌더러와 장난감을 가르는 부분으로 넘어갑니다. 겉모습만 비슷한 시스템 폰트가 아니라 임베드된 폰트 프로그램 자체에서 텍스트를 그리는 일입니다

PDF 페이지를 렌더링하는 일이 왜 이미지 그리기보다 어려울까요?

PDF 페이지는 그림이 아닙니다. 프로그램입니다. 경로를 만들고, 폰트를 고르고, 색을 설정하고, 글리프를 배치하는 연산자의 흐름이며, ISO 32000-1 §8이 정의하는 그래픽 모델에 대고 실행됩니다. 파일 어디에도 각 픽셀이 어떤 모습인지는 적혀 있지 않습니다. 비트맵을 만들려면 그 프로그램을 실행해야 합니다 — 현재 변환 행렬, q/Q를 위한 그래픽 상태 스택, 클리핑 경로, 채우기 및 선 색 공간을 유지하면서 — 그리고 그 결과를 래스터화해야 합니다. "3페이지를 이미지로 보여 주기만 하면 되잖아"가 파일 형식 변환이 아니라 콘텐츠 스트림 인터프리터인 이유가 그것입니다

v2.253.0에서 도입된 HotPDF의 렌더러는 그 모델을 그대로 비추는 여섯 개의 느슨히 결합된 유닛으로 지어졌습니다. PDF [a b c d e f] 변환 대수를 위한 아핀 행렬 코어, 그래픽 상태 스택, 색 공간 해석기(DeviceRGB, DeviceGray, DeviceCMYK, Indexed), PDF 경로 연산자를 GDI로 잇는 경로 빌더, 올바른 진행량을 위해 /Widths 배열을 읽는 폰트 메트릭 계층, 그리고 연산자를 배분하며 나머지 다섯을 구동하는 인터프리터입니다. 이미지 XObject는 라이브러리가 추출에 쓰는 것과 같은 디코드 스택을 거치므로, HotPDF가 추출을 위해 디코딩할 수 있는 모든 이미지 필터가 — JPXDecode로 압축된 JPEG 2000 이미지를 포함해 — 렌더링 출력에도 그대로 나타납니다

HotPDF 페이지 렌더링 아키텍처: PDF 콘텐츠 스트림 인터프리터가 여섯 개의 느슨히 결합된 유닛을 구동해 호출자 소유의 24비트 RGB TBitmap을 만들어 냅니다
인터프리터가 연산자를 배분하는 동안 여섯 유닛이 행렬, 상태, 색, 경로, 메트릭, 글리프 작업을 나누어 맡습니다

로드한 페이지를 TBitmap으로 렌더링하기

RenderLoadedPageToBitmap은 0부터 시작하는 페이지 인덱스와 DPI 값을 받으며, 72 DPI에서 PDF 사용자 공간 단위 하나가 픽셀 하나로 대응됩니다. 실패하면(범위를 벗어난 인덱스, 리소스 누락) 예외를 일으키는 대신 nil을 반환하므로, 뷰어는 문제가 있는 페이지를 건너뛰고 계속 갈 수 있습니다. 반환된 비트맵은 호출자 소유이며 호출자가 해제해야 합니다

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('report.pdf') > 0 then
    begin
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);  // 1페이지를 144 DPI로
      if Bmp <> nil then
      try
        Image1.Picture.Assign(Bmp);
      finally
        Bmp.Free;  // 비트맵은 호출자 소유
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

DPI 인자가 흔한 모든 시나리오에서 배율 조정을 담당합니다. 썸네일 띠는 36이나 48 DPI로 렌더링해 작고 빠른 비트맵을 얻고, 화면 미리보기는 96이나 144 DPI로 일반적인 디스플레이 밀도에 맞추며, 내보내기 경로는 300 DPI로 인쇄 품질 이미지를 만듭니다. /Rotate 항목에서 오는 페이지 회전과 /MediaBox 원점 뒤집기(PDF는 원점을 좌하단에, GDI는 좌상단에 둡니다)는 페이지-장치 행렬 안에서 처리되므로, 72 DPI의 US Letter 페이지는 정확히 612×792 픽셀로, 올바른 방향으로 돌아옵니다

렌더링한 PDF 썸네일에 왜 엉뚱한 글리프가 나올까요?

렌더링된 PDF 출력에 엉뚱하거나 어림한 글리프가 나온다면, 거의 언제나 렌더러가 파일에 임베드된 폰트를 쓰지 않고 시스템 폰트로 대체하고 있다는 뜻입니다. 초기 HotPDF 렌더러가 정확히 그렇게 했습니다. /BaseFont에서 서브셋 접두부를 떼어 내고(ABCDEF+Arial을 Arial로 바꾸어), 그 이름의 시스템 폰트를 GDI에 요청한 뒤 그것으로 텍스트를 그렸습니다. 표준 인코딩으로 Arial이나 Times New Roman을 쓰는 문서라면 결과가 꽤 비슷해 보입니다. 하지만 그것은 근사이고, 분명히 정해진 방식으로 무너집니다

서브셋으로 임베드된 폰트가 최악의 경우입니다. 서브셋 폰트는 문서가 실제로 쓰는 마흔 개 글리프만 지닐 수 있고, 문자 코드는 그 파일에만 통하는 순서로 배정됩니다 — 코드 1이 "T", 코드 2가 "h"인 식입니다. 시스템 폰트는 그 사적인 배정을 전혀 모르므로, 텍스트가 사라지거나 완전히 다른 문자로 나옵니다. 커스텀 인코딩, 심볼 폰트, 바코드 폰트, 그리고 렌더링 머신에 설치되지 않은 서체는 모두 같은 방식으로 실패합니다. 시스템 폰트 대체에서 멈추는 렌더러는 알아볼 만한 페이지 썸네일을 만들어 냅니다 — 애초에 임베딩을 필요하게 만든 바로 그 폰트를 페이지가 쓰기 전까지는 말입니다

HotPDF: 시스템 폰트 대체가 ABCDEF+Arial에서 서브셋 접두부를 떼어 내고 엉뚱한 글리프를 그리는 반면, 임베드 글리프 렌더링은 FontFile 윤곽선을 재생해 정확한 모양을 냅니다
대체는 흔한 시스템 서체에서만 살아남고, 임베딩을 필요하게 만든 바로 그 서브셋 폰트에서 실패합니다

임베드 글리프 렌더링: 폰트 프로그램 자체에서 그리기

HotPDF는 다섯 번의 릴리스(v2.268.0부터 v2.272.0까지)에 걸쳐 임베드된 폰트 프로그램을 파싱하고 그 글리프 윤곽선을 채워진 GDI 벡터 경로로 재생함으로써 그 간극을 메웠습니다. 이제 렌더링된 페이지의 텍스트는 준수 뷰어가 쓰는 것과 같은 윤곽선 데이터에서 나오며, 그 덕에 서브셋 폰트, 커스텀 인코딩, 설치되지 않은 서체가 정확한 모양으로 그려집니다. 적용 범위는 폰트 종류별로 쌓아 올려졌습니다:

임베드된 TrueType 프로그램(FontFile2)을 지닌 Type0/CIDFontType2 폰트의 경우, 렌더러는 glyf와 loca 테이블을 직접 파싱합니다. 2차 윤곽은 GDI가 이해하는 3차 베지에로 변환되고, 연속된 오프커브 점 사이의 암묵적 온커브 점이 복원되며, 복합 글리프는 재귀적으로 재생됩니다. Identity와 명시적 스트림 CIDToGIDMap 배치가 모두 지원되고, CID 진행량은 /W와 /DW 폭 항목을 존중하므로 2바이트 Identity-H 텍스트가 올바르게 나아갑니다

CFF 프로그램(FontFile3이며 CIDFontType0C, Type1C, OpenType 래퍼 어느 쪽이든)에는 완전한 Type 2 charstring 인터프리터가 붙습니다. 직선, 곡선, flex 계열, 힌트 마스크, 그리고 올바른 서브루틴 바이어스를 적용한 지역/전역 서브루틴 호출까지 지원합니다. CID 키 방식 CFF 프로그램은 문자 코드를 폰트의 charset을 통해 대응시키는데, 글리프 순서가 CID 순서와 다른 서브셋 폰트에서 이것이 중요합니다. FDArray/FDSelect를 통한 글리프별 폰트 DICT 선택도 존중됩니다. 단순(비 CID) TrueType 폰트는 1바이트 코드를 임베드된 폰트 자신의 cmap 테이블에서 견고한 서브테이블 사슬로 해석합니다 — 유니코드 형식 4와 12를 먼저, 그다음 F000 사용자 정의 영역 미러를 지닌 심볼 서브테이블, 그다음 옛 Macintosh 형식 순서입니다 — 반면 단순 Type1 폰트는 CFF 프로그램의 내장 인코딩으로 해석합니다

두 가지 다듬기가 그림을 완성합니다. 첫째, 단순 폰트의 /Encoding 딕셔너리는 ISO 32000-1 §9.6.6이 규정한 우선순위대로 해석됩니다. /Differences 배열이 기본 인코딩을 덮고, 기본 인코딩이 폰트 프로그램 자신의 대응표를 덮습니다 — TeX와 PostScript 계열 툴체인이 의존하는 경로이며, 글리프 이름은 Adobe Glyph List나 CFF charset, 또는 TrueType cmap으로 해석됩니다. 둘째, 글리프 자체가 작은 콘텐츠 스트림인 Type3 폰트는 폰트 행렬, 폰트 크기, 텍스트 행렬을 합성한 채로 렌더러를 통해 재생됩니다. 글리프 공간의 /Widths는 ISO 32000-1 §9.6.5가 요구하는 대로 /FontMatrix를 거쳐 해석되고, d1 경계 상자를 선언한 글리프 프로시저는 그 상자로 잘리므로 잘못 만들어진 바코드 글리프가 자기 칸 밖으로 번질 수 없습니다. 코드를 대응시킬 수 없을 때는 — 손상된 프로그램이나 대응되지 않는 문자일 때는 — 렌더러가 그 텍스트 런을 버리는 대신 해당 글리프에 대해 시스템 폰트 그리기로 물러섭니다

반복 렌더링을 어떻게 빠르게 만들까요?

HotPDF가 내놓은 답은 최근 사용 기준 페이지 캐시입니다. RenderLoadedPageToBitmapCached는 렌더링된 페이지를 페이지 인덱스와 DPI를 키로 삼아 RenderCacheCapacity개(기본 8개)까지 보관하고, 캐시가 적중하면 콘텐츠 스트림을 건드리지 않고 호출자 소유의 새 사본을 반환합니다 — 페이지를 다시 해석하는 것보다 대개 수천 배 빠릅니다. 이 패턴은 뷰어에 딱 들어맞습니다. 두 페이지 사이를 오가는 사용자나, 같은 페이지를 같은 DPI로 다시 요청하는 크기 조정 이벤트는 매번 캐시에 적중합니다

HotPDF: MRU 렌더링 페이지 캐시 흐름으로, 적중하면 페이지를 다시 해석하지 않고 새 TBitmap 사본을 반환하고, 빗나가면 렌더링해 RenderCacheCapacity개까지 저장합니다
캐시 적중은 콘텐츠 스트림을 통째로 건너뛰고, 무효화와 메모리 예산 관리가 반복 렌더링을 정확하고 안전하게 유지합니다
// 썸네일 띠: 첫 순회는 렌더링하고, 되돌아가 스크롤하면 캐시에 적중합니다
for I := 0 to ThumbCount - 1 do
begin
  Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 48);
  if Bmp <> nil then
  try
    ThumbList.AddThumbnail(I, Bmp);
  finally
    Bmp.Free;
  end;
end;

// 로드한 페이지를 제자리에서 편집한 뒤:
Pdf.InvalidateRenderedPageCache;  // 다음 렌더링부터 변경이 반영됩니다

용량을 올리기 전에 메모리 청구서를 솔직하게 따져 보십시오. 300 DPI의 US Letter 페이지는 2550×3300 픽셀이고 24비트 비트맵으로 약 25 MB이므로, 내보내기 해상도로 여덟 페이지를 캐시하면 대략 200 MB를 붙들게 됩니다. 썸네일 DPI에서는 같은 여덟 항목이 1메가바이트에도 한참 못 미칩니다. RenderCacheCapacity는 실제로 캐시하는 DPI에 맞춰 잡고, 제자리 편집 뒤에는 InvalidateRenderedPageCache를 호출하십시오 — 캐시는 페이지와 DPI만을 키로 삼으므로 밑바탕 콘텐츠가 바뀐 것을 알아채지 못합니다. 새 문서를 로드하면 캐시는 자동으로 비워집니다

페이지 캐시 아래에서 두 번째 캐시가 일합니다. 디코딩된 이미지 XObject가 ImageCacheMaxBytes(기본 32 MB)로 제한된 바이트 예산 저장소에 최근 사용 기준 축출과 함께 보관됩니다. 모든 페이지에 반복되는 로고나 레터헤드 이미지는 Do 연산자마다가 아니라 문서 로드마다 한 번만 디코딩되며, 덕분에 이미지를 공유하는 페이지의 렌더링 시간이 대략 절반으로 줄고 여러 페이지 TIFF 내보내기도 같은 폭으로 빨라집니다. InvalidateRenderedPageCache는 이 캐시도 비웁니다

아직 근사로 그려지는 것들

이 렌더러는 흔한 문서형 PDF 부분집합을 겨냥하며, 경계가 어디인지는 알아 둘 만합니다. CalRGB, Lab, ICC 기반 색 공간은 색 관리가 아니라 근사로 처리됩니다 — 장치 색 공간, Indexed 팔레트, 샘플드 Type 0 함수 색 조회는 지원하지만, ICC 렌더링 인텐트에 기대는 인쇄 제작용 파일은 측색적으로 정확하지 않습니다. 셰이딩 패턴(sh)과 단순 알파를 넘어서는 혼합 모드도 마찬가지로 범위 밖이고, Form XObject 재귀는 순환 방지를 위해 깊이가 제한됩니다. 인보이스, 보고서, 계약서, 폼처럼 텍스트와 경로와 이미지로 이루어진 페이지라면 출력은 충실합니다. 그러데이션과 투명 그룹으로 가득한 디자인 교정본이라면 그 비트맵을 교정쇄가 아니라 미리보기로 대하십시오

실무적으로 읽자면 이렇습니다. 여러분의 파이프라인이 HotPDF로 문서를 만들거나 전형적인 업무용 PDF를 소비한다면, RenderLoadedPageToBitmap은 정확한 임베드 글리프 모양, 올바른 CID 진행량, 올바른 페이지 기하로 그것들을 왕복시킵니다. 근사가 남아 있는 곳은 업무 문서가 좀처럼 방문하지 않는 그래픽 모델의 구석입니다

RenderLoadedPageToBitmap, 그 캐시 변형, 그리고 여기서 설명한 임베드 글리프 렌더링 파이프라인은 Delphi 및 C++Builder용 HotPDF Delphi Component의 일부로 제공됩니다 — 외부 DLL 의존성이 없는 네이티브 VCL 라이브러리로, PDF 생성, 편집, 텍스트 추출, 페이지 렌더링을 한 패키지에 담고 있습니다