기술 문서

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 을 어디에서 해야 하느냐인데, PDF Library for Delphi 의 답은 다시 쓰고 싶지 않은 renderer 를 확장하는 법에 대해 유용한 시사점을 줍니다

PDF Library for Delphi 는 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 를 위한 기능이라면 이 편이 맞습니다

PDF Library for Delphi 파이프라인: PDF 페이지가 임시 24비트 BMP로 렌더링되고, GDI HALFTONE blit으로 1비트 모노크롬 비트맵으로 축소된 뒤, 팩스, 아카이브, OCR 워크플로가 소비
새 메서드는 풀컬러 페이지를 먼저 렌더링한 뒤 렌더러 바깥에서 완성된 래스터를 다운컨버트합니다. 팩스 게이트웨이, 보관 저장소, OCR 프런트엔드는 진짜 pf1bit 비트맵을 받습니다
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI는 전형적인 Group 4 팩스 해상도입니다; 페이지 인덱스는 1부터 시작
    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 으로 읽고, 같은 크기의 두 번째 TBitmap 을 PixelFormat := pf1bit 로 만든 뒤, pixel 을 단 한 번의 blit 로 옮깁니다

PDF Library for Delphi: GDI 축소 세부로 회색을 점 패턴으로 디더링하는 HALFTONE stretch blit과 덩어리진 BLACKONWHITE 기본 임계값 비교
RenderPageToMonochromeFile 내부에서 하나의 StretchBlt가 모든 픽셀을 동일 크기의 pf1bit 표면으로 옮깁니다. HALFTONE이 활성화되면 회색은 기본 임계값이 만드는 울퉁불퉁한 형태 대신 디더링된 잉크 점 패턴이 됩니다
// RenderPageToMonochromeFile 내부, 24비트 ColorBmp를 로드한 이후
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE은 GDI에게 24비트 소스를 1비트로 디더링하도록 지시합니다
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');

핵심은 SetStretchBltMode 와 HALFTONE 입니다. 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 로 뽑아낼 때입니다. 시그니처는 단순합니다

PDF Library for Delphi: PDF 포인트 단위 영역 클립 렌더링: 전체 해상도로 렌더링된 PDF 페이지의 72,72,180,72 사각형이 150 DPI에서 375x150 픽셀 비트맵이 되는데, 클립이 스케일링 대신 크롭하기 때문
RenderPageRegionToFile은 전체 해상도 렌더에서 PDF 포인트로 측정된 창을 잘라 냅니다. 출력 비트맵 크기는 width와 height에 DPI/72를 곱해 정해지지, 축소된 전체 페이지에서 정해지지 않습니다
// Clip은 PDF 포인트 단위의 "Left,Top,Width,Height"입니다 (72 pt = 1인치)
// 여기서는 페이지 좌상단에서 1인치 들어간 2.5인치 x 1인치 상자입니다
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 패턴입니다

// TPDFPageTree.RenderPageToDC 내부, Clip이 비어 있지 않을 때
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... 렌더러가 여기서 페이지를 그립니다 ...
// finally 블록 안에서:
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 의 좌상단을 기준으로 측정됩니다. 즉 Left 와 Top 은 page 위쪽에서 아래로 계획해야 합니다. PDF Library for Delphi 가 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 는 아닙니다. 이유는 구체적입니다. PDF Library for Delphi 의 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 를 보려면 PDF Library for Delphi Delphi PDF Library 제품 페이지가 가장 완전한 출발점입니다