Technical Article

Delphi에서 PDF 페이지를 1비트 흑백으로 렌더링하기

fax gateway 는 당신의 24-bit page render 를 원하지 않습니다. 백만 장의 스캔 같은 invoice 를 저장하는 archival pipeline 도 마찬가지고, 문자를 보기 전에 모든 것을 흑백으로 threshold 하는 OCR front-end 도 마찬가지입니다. 이 셋이 원하는 것은 동일합니다. pixel 당 1 bit, 모든 점이 잉크 아니면 종이인 깔끔한 monochrome bitmap 입니다. 여기에 full-color BMP 를 넘기면 어차피 pixel 당 23 bit 를 버리게 되며, 대개 당신이 직접 할 수 있는 것보다 질이 낮은 dithering pass 로 그렇게 합니다. 흥미로운 질문은 이 down-conversion 을 어디에서 해야 하느냐인데, PDFlibPas 의 답은 다시 쓰고 싶지 않은 renderer 를 확장하는 법에 대해 유용한 시사점을 줍니다.

PDFlibPas 는 Delphi 와 C++Builder 용 native Object Pascal PDF 라이브러리입니다. 렌더링 코어는 page 를 bitmap 으로 rasterize 하고 BMP, PNG, JPEG, WMF 등 여러 형식을 낼 수 있습니다. 최근까지 없던 것은 true monochrome bitmap 을 직접 돌려주는 기능과 page 일부만 렌더링하는 기능이었습니다. 둘 다 v3.83.0 에 들어왔고, 둘 다 rasterizer 자체를 바꾸지 않고 기존 renderer 위에 얇은 convenience layer 로 구축되었습니다. 제약 자체가 곧 이야기의 핵심입니다.

왜 renderer 안이 아니라 렌더링 후에 down-convert 하는가

1-bit 이미지를 만드는 가장 직접적인 방법은 rasterizer 에게 1-bit 로 그리라고 지시하는 것입니다. 동시에 다른 모든 것을 망가뜨리는 방법도 그것입니다. renderer 내부 bitmap 은 하드코딩된 PixelFormat := pf24bit 로 생성되며, 이 값은 PDFlibRenderer constructor 안에 들어 있습니다. 그리고 이 24-bit surface 는 PNG export, device-context preview, JPEG output 등 모든 render path 가 공유합니다. 이것을 근원에서 pf1bit 로 뒤집는 순간 monochrome feature 하나를 추가하는 것이 아니라, 라이브러리의 모든 caller 에 대해 color fidelity 를 떨어뜨리고 수많은 downstream regression 을 디버깅해야 하는 상황을 만들게 됩니다.

그래서 RenderPageToMonochromeFile 는 반대 방향을 택합니다. page 를 먼저 임시 24-bit BMP 로 평소처럼 렌더링한 뒤, 그 다음 단계에서만 1-bit 로 접습니다. renderer 는 손대지 않습니다. monochrome 동작은 전부 convenience method 안에만 존재하므로, 그것을 호출하지 않는 사람에게는 아무 영향도 주지 않습니다. 이런 trade-off 는 이름을 붙여 둘 가치가 있습니다. post-process 는 bitmap allocation 하나와 temp file 하나를 더 치르지만, 그 대가로 부하를 지는 core 를 완전히 범위 밖에 둘 수 있습니다. fax 와 archival 같은 edge case 를 위한 기능이라면 이 편이 맞습니다.

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI is the classic Group 4 fax resolution; page index is 1-based
    Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
  finally
    Pdf.Free;
  end;
end;

1-bit 로 접는 실제 방식

down-conversion 은 hand-rolled threshold loop 가 아니라 GDI 에 기대며, 이 선택은 출력 품질에 직접 영향을 줍니다. 메서드 내부에서 24-bit temp bitmap 을 TBitmap 으로 읽고, 같은 크기의 두 번째 TBitmapPixelFormat := pf1bit 로 만든 뒤, pixel 을 단 한 번의 blit 로 옮깁니다.

// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
  ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');

핵심은 SetStretchBltModeHALFTONE 입니다. source 와 destination 이 같은 크기라 실제 scaling 은 일어나지 않아도, stretch mode 는 GDI 가 색을 1-bit palette 로 어떻게 매핑할지 계속 지배합니다. HALFTONE 을 쓰면 halftone dithering 이 적용되어 회색 영역과 anti-aliased text edge 가 단순한 threshold 가 아니라 흑백 점 패턴으로 바뀝니다. 이 호출을 빼거나 기본값인 BLACKONWHITE 를 쓰면 grayscale content 는 거친 임계값 도형처럼 posterize 됩니다. 스캔 문서나 OCR 전처리 출력이라면 대개 dithered 결과가 원하는 쪽입니다.

절대로 틀리면 안 되는 디테일 하나도 있습니다. 임시 렌더는 반드시 BMP 여야 합니다. RenderPageToMonochromeFile 는 일반 renderer 를 options code 0 으로 호출하는데, 이것이 BMP 입니다. RenderPageToFile 의 options 인자는 작은 정수 enum 이고, 이 목적에서는 서로 바꿔 쓸 수 없습니다. 0 은 BMP, 1 은 JPEG, 2 는 WMF, 3 은 EMF, 5 는 PNG 입니다. down-converter 는 이후 temp file 에 대해 TBitmap.LoadFromStream 를 호출합니다. 여기에 2 를 넘겨 WMF 를 넣으면 "Bitmap image is not valid" 예외가 납니다. Windows Metafile 은 DIB 가 아니라 vector record stream 이기 때문입니다. monochrome down-conversion 은 끝까지 raster 작업이므로, 중간 형식도 raster 여야 합니다.

페이지 일부 영역만 렌더링하기

두 번째 메서드 RenderPageRegionToFile 는 page 전체가 아니라 rectangle 하나만 렌더링합니다. document viewer 를 한 번이라도 만든 적이 있다면 쓰임새는 익숙합니다. 계약서에서 서명 block 만 잘라 내거나, 큰 도면의 확대 tile 을 만들거나, 전체 page 를 고 DPI 로 rasterize 하는 비용을 치르지 않고 특정 stamp 영역만 thumbnail 로 뽑아낼 때입니다. 시그니처는 단순합니다.

// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');

clip 문자열은 PDF point 단위의 comma-separated double 네 개이며, locale 과 DelimitedText 의 까다로운 동작을 피하기 위해 메서드 내부에서 직접 parse 됩니다. width 와 height 로부터 메서드는 출력 bitmap 크기를 Round(Width * DPI / 72)Round(Height * DPI / 72) 로 계산하고, 정확히 그 크기의 in-memory pf24bit bitmap 을 만든 뒤, RenderPageToDCClip 를 통해 그 device context 에 렌더링합니다. 결과 파일에는 전체 page 가 아니라 clip 된 rectangle 만, 그 영역 크기에 맞게 들어 있습니다.

아무 일도 하지 않던 clip 매개변수

여기서 작업이 보기보다 날카로웠습니다. RenderPageToDCClip 는 오랫동안 Clip 매개변수를 가지고 있었지만 사실상 거짓말이었습니다. 이 호출은 인자를 받아 TPDFPageTree.RenderPageToDC 로 넘겼지만, 그 구현은 이를 완전히 무시하고 renderer 에 전달하지 않았습니다. 어떤 rectangle 을 넘겨도 결과는 항상 전체 page 였습니다. RenderPageToDCClip 에 crop 을 기대하고 wiring 해 둔 사람은 full-page render 를 받고 있었고, layout 에 따라서는 그것조차 눈치채지 못했을 수 있습니다.

v3.83.0 이 그 선을 연결했습니다. RenderPageToDC 는 이제 같은 "Left,Top,Width,Height" point rectangle 을 parse 하고, renderer 가 그리기 전에 target device context 에 실제 GDI clip region 으로 적용합니다. point 에서 device pixel 로의 변환은 일반적인 DPI / 72 scale factor 이며 네 edge 모두에 적용됩니다. render 주변의 순서는 표준적인 save/clip/restore 패턴입니다.

// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);

SaveDC / RestoreDC(-1) 쌍이 있어야 이것을 반복 호출해도 안전합니다. clip region 이 DC state stack 에 push 되고, page 가 그려진 뒤, render 가 어떻게 끝나든 원래 clip 이 pop 됩니다. RestoreDC(TargetDC, -1) 는 가장 최근에 저장한 state 를 복원하며, 이것이 균형 잡힌 save/restore 의 표준 idiom 입니다. restore 를 빼먹으면 같은 DC 를 재사용하는 caller 가 다음 full-page render 에서도 이전 영역으로 mysteriously 잘려 있는 결과를 보게 됩니다. 죽어 있던 parameter 를 고치면서 RenderPageRegionToFile 도 공짜로 함께 고쳐졌는데, 새 메서드가 정확히 이 경로를 타기 때문입니다.

동작 면에서 꼭 기억할 점 하나가 있습니다. clip 은 crop 하지 scale 하지 않습니다. page 는 요청한 DPI 에 맞춰 여전히 정상 위치에 rasterize 되고, clip region 이 그 밖의 모든 것을 버릴 뿐입니다. region 을 출력 전체에 확대하는 것이 아니라, full-resolution render 에서 창 하나를 잘라 내는 것입니다. region 을 키워 보고 싶다면 DPI 를 올려야 합니다. rectangle 좌표는 point-to-pixel 변환 뒤 device space 에서 해석되며, 렌더된 surface 의 좌상단을 기준으로 측정됩니다. 즉 LeftTop 은 page 위쪽에서 아래로 계획해야 합니다. PDFlibPas 가 on-screen output 을 위해 device context 를 어떻게 구동하는지 더 보려면, companion piece 인 print preview 와 device-context 출력 글이 같은 DC plumbing 을 display 관점에서 설명합니다.

솔직한 경계: 1-bit BMP 이지 G4 TIFF 는 아니다

이 기능을 "fax-ready output" 이라고 과장하기는 쉽지만, 경계는 분명히 말해 두는 편이 좋습니다. RenderPageToMonochromeFile 가 만드는 것은 pf1bit BMP 입니다. 실제 fax workflow 나 TIFF archive 가 기대하는 CCITT Group 4 TIFF 는 아닙니다. 이유는 구체적입니다. PDFlibPas 의 CCITT unit 은 현재 G4 stream 을 decode 할 수는 있지만, G4 encoder 는 가지고 있지 않습니다. encoder 가 없으면 compressed monochrome run 을 쓸 곳이 없으므로, monochrome path 는 압축되지 않은 1-bit DIB 에서 멈춥니다.

실무에서는 그래도 충분히 유용합니다. 1-bit BMP 는 픽셀 형식 자체는 정확하고, dithering 도 이미 끝나 있으며, 대부분의 fax, archival, OCR toolchain 은 이를 그대로 받아들이거나 한 단계 더 거쳐 G4 로 변환할 수 있습니다. 하지만 요구사항이 말 그대로 라이브러리에서 바로 Group 4 TIFF 를 내놓는 것이라면, 현재는 거기까지 아닙니다. 별도의 compression stage 를 스스로 준비해야 합니다. 기능이 어디에서 멈추는지를 아는 것은, 기능이 무엇을 하는지 아는 것만큼 중요합니다.

두 메서드는 의도적으로 작습니다. 그리고 바로 그 점이 이 페이지에서 가져갈 설계 교훈입니다. renderer 위에 얹힌 convenience API 는 rasterizer 내부를 건드리지 않고도 진짜 capability, 즉 monochrome output 과 region cropping 을 추가할 수 있습니다. 밑바탕이 되는 renderer engine 을 어떻게 고를지 고민하는 경우라면, Delphi의 multi-engine PDF rendering 개요가 그 trade-off 를 자세히 다룹니다. 전체 rendering surface 와 나머지 API 를 보려면 PDFlibPas Delphi PDF Library 제품 페이지가 가장 완전한 출발점입니다.