HotPDF는 Delphi PDF 뷰어를 두 부분으로 분리한다: 확대/축소, 회전, 검색, 하이라이트, 내비게이션 상태를 소유하되 창 핸들 의존성이 전혀 없는 일반 클래스인 THPDFViewerModel, 그리고 그 상태를 픽셀로 그려내는 TScrollBox 기반 컨트롤인 THPDFViewer다. 이 분리 덕분에 뷰어 로직을 폼을 하나도 만들지 않고도 실행하고 테스트할 수 있다
대부분의 커스텀 뷰어 컨트롤은 이런 모습이 아니다. 확대 수준은 컨트롤의 비공개 필드에 저장되고, 페이지 내비게이션은 버튼의 OnClick 핸들러 안에서 경계를 조여내며, Ctrl+스크롤이 최대 확대 한도를 준수하는지 알아보는 유일한 방법은 앱을 실행해 클릭해 보고 눈으로 확인하는 것이다. 이런 식으로 만든 컨트롤은 회귀 테스트 스위트가 필요해지거나, 두 번째 호스트—인쇄 미리보기 대화상자, 썸네일 레일, 화면 자체가 아예 없는 배치 리뷰어—가 필요해지기 전까지는 문제없이 동작한다. 그런데 정작 필요한 상태는 실제 핸들을 요구하고서야 무엇이든 하는 TWinControl에 용접되어 있는 것으로 드러난다
PDF 뷰어 컨트롤에 MVC 분리가 왜 필요한가?
PDF 뷰어에 이런 분리가 필요한 이유는 상태와 표현이 서로 다른 이유로, 서로 다른 빈도로 변하기 때문이다. 페이지 인덱스, 확대율, 뷰 회전, 검색 결과, 하이라이트 영역은 비즈니스 상태다: 화면에 픽셀 하나 없이도 계산하고 검증하고 직렬화할 수 있다. 비트맵을 그리고, 마우스를 캡처하고, 마퀴 선택 사각형을 그리는 것은 컨트롤이 존재해야만 의미가 있는 표현 관심사다. HotPDF는 앞의 그룹을 VCL 윈도잉 조상이 전혀 없는 클래스인 THPDFViewerModel에 두고, 뒤의 그룹을 모델 인스턴스를 소유하고 그에 반응하는 THPDFViewer에 둔다—별도의 Controller 클래스가 없고 THPDFViewer 자체가 원시 키보드·마우스 이벤트를 모델 호출로 변환하기 때문에 교과서적인 3계층 MVC보다는 Model-View 쌍에 더 가깝다. 이름표보다 중요한 것은 의존성 방향이다: THPDFViewerModel은 Handle도, 메시지 루프도, 화면에 보이는 데스크톱도 요구하지 않으며, 바로 이 점 덕분에 HotPDF 자체 테스트 스위트가 창을 열지 않고도 DUnitX를 통해 페이징, 확대율 제한, 키보드 명령, 좌표 왕복 변환을 검증할 수 있다
uses
DUnitX.TestFramework,
HPDFDoc, HPDFViewerModel;
type
[TestFixture]
TViewerModelTests = class
public
[Test]
procedure ZoomInStopsAtTheTopPresetLevel;
end;
procedure TViewerModelTests.ZoomInStopsAtTheTopPresetLevel;
var
Doc: THotPDF;
Model: THPDFViewerModel;
begin
Doc := THotPDF.Create(nil);
Model := THPDFViewerModel.Create;
try
Doc.LoadFromFile('sample.pdf');
Model.Document := Doc;
Model.Zoom := 64.0; // top of the preset table (6400%)
Model.ZoomIn; // already at the ceiling
Assert.AreEqual(64.0, Model.Zoom, 0.0001);
finally
Model.Free;
Doc.Free;
end;
end;
THPDFViewerModel이 실제로 소유하는 것
THPDFViewerModel은 그리는 방법은 소유하지 않으면서, 지금 화면에 무엇이 있어야 하는지에 답하는 데 필요한 모든 것을 소유한다. PageIndex, PageNumber, PageCount는 위치를 추적하고, Zoom과 ZoomMode(vzmActualSize, vzmFitPage, vzmFitWidth, vzmCustom)는 배율을 추적하며, ViewRotation은 페이지 자체의 /Rotate 항목을 전혀 건드리지 않는 비파괴적 화면상 회전을 추적한다. 내비게이션 메서드(FirstPage, PriorPage, NextPage, LastPage)와 확대 메서드(ZoomIn, ZoomOut, 5%에서 6400%까지 19개의 사전 설정 레벨로 이루어진 고정 테이블을 순회)도 여기 있으며, 텍스트 검색을 위한 FindAll/FindNext/FindPrevious와 호출자가 렌더링 사이에도 유지하고 싶어하는 영구적 페이지 주석을 위한 AddHighlightRegion/RemoveHighlightRegion/ClearHighlightRegions도 함께 있다. 모델은 입력뿐 아니라 출력도 소유한다: CreateCurrentPageSnapshot과 CreateCurrentPageMetafile은 정확히 지금 화면에 있는 페이지를 내보내고, PrintCurrentView는 현재 페이지, 현재 확대율에서 파생된 DPI, 현재 회전으로 이루어진 바로 그 현재 뷰를 TPrinter에 전송한다. 이는 HotPDF의 TPrinter 인쇄 안내에서 다루는 문서 전체 인쇄 파이프라인보다 좁고 뷰 범위에 한정된 작업이다. 의미 있는 변경마다 그에 대응하는 이벤트도 발생한다—OnPageChange, OnZoomChange, OnSearchChange, OnHighlightChange, OnViewRotationChange—덕분에 구독자는 폴링 없이 무엇이 바뀌었는지 알 수 있다
THPDFViewer는 언제 다시 그려야 할지 어떻게 아는가?
THPDFViewer는 추측하는 대신 모델을 구독하기 때문에 언제 다시 그려야 할지 안다. THPDFViewer의 생성자는 비공개 THPDFViewerModel을 하나 만든 다음, 그 알림 이벤트 전부—OnBeginUpdate, OnEndUpdate, OnHighlightChange, OnPageChange, OnSearchChange, OnViewRotationChange, OnZoomChange—를 각각에 대응하는 비공개 핸들러에 연결한다. 각 핸들러가 하는 일은 작다: HotPDF의 페이지-비트맵 렌더링 내부 구조에서 설명하는 것과 동일한 캐시된 페이지 렌더러를 통해 실제로 현재 페이지를 래스터화하는 RefreshDocument를 호출한 뒤, 하이라이트 박스와 검색 결과를 그 위에 합성하고 현재 뷰 회전을 적용한다. PageIndex, Zoom, ZoomMode, ViewRotation 같은 공개 속성은 얇은 전달자에 불과하다—게터는 FModel.PageIndex를 읽고, 세터는 FModel.PageIndex에 쓴다—그래서 Object Inspector에서 보든 코드에서 보든 컨트롤이 직접 상태를 보유하는 것처럼 보이지만, 실제로 그 상태가 존재하는 곳은 THPDFViewerModel뿐이다. 호출자가 전달되는 부분집합에만 갇혀 있는 것도 아니다: THPDFViewer는 읽기 전용 Model: THPDFViewerModel 속성을 통해 모델 자체를 노출하므로, 컨트롤이 다시 노출하지 않는 FindFormFieldAt이나 PrefetchCurrentPageSnapshots를 원하는 코드는 래퍼를 건너뛰어 모델을 직접 호출할 수 있다
procedure THPDFViewer.RefreshDocument;
var
Bitmap: TBitmap;
DPI: Integer;
begin
// simplified: the real method also resolves fit-mode DPI
// and composites highlight and search-hit rectangles first
if (FModel.Document = nil) or (FModel.PageIndex < 0) then Exit;
DPI := Round(96 * FModel.Zoom);
Bitmap := FModel.Document.RenderLoadedPageToBitmapCached(FModel.PageIndex, DPI);
try
FModel.ApplyViewRotation(Bitmap);
FImage.Picture.Bitmap.Assign(Bitmap);
finally
Bitmap.Free;
end;
end;
BeginUpdate와 EndUpdate: 재도장 폭풍 막기
BeginUpdate와 EndUpdate가 존재하는 이유는 논리적으로 하나의 변경이 흔히 여러 상태 조각을 한꺼번에 건드리기 때문이고, 조각마다 다시 그리면 낭비이자 시각적으로 어수선해지기 때문이다. 로드된 문서를 교체하는 것이 가장 명확한 예다: THPDFViewerModel.Document에 값을 대입하면 뷰 회전이 초기화되고, 검색 결과가 지워지고, 하이라이트 영역이 지워지고, 첫 페이지로 이동하는데, 이 각 단계는 보통 자신만의 변경 이벤트를 발생시킨다. THPDFViewerModel은 이 시퀀스를 참조 카운트 방식의 BeginUpdate/EndUpdate 쌍으로 감싸며, 중첩 호출은 가장 바깥쪽 호출로 진입할 때만 OnBeginUpdate를, 다시 빠져나올 때만 OnEndUpdate를 발생시킨다. THPDFViewer도 자기 쪽에서 동일한 깊이를 추적하며, 카운트가 0보다 큰 동안에는 세세한 이벤트마다 RefreshDocument를 건너뛰다가 배치가 닫히는 순간 정확히 한 번만 다시 그린다. 세세한 이벤트들은 배치 도중에도 여전히 발생하므로 OnSearchChange에만 관심 있는 구독자는 그 소식을 여전히 듣는다. 축소되는 것은 오직 컨트롤 자신의 다시 그리기뿐이며, 네 번이 아니라 한 번의 호출로 합쳐진다
마퀴 하이라이트는 마우스 드래그를 어떻게 PDF 좌표로 되돌리는가?
마퀴 하이라이트는 정확히 그 왕복 변환을 위해 만들어진 모델 메서드 한 쌍, PagePointToView와 ViewPointToPage를 통해 마우스 드래그를 PDF 좌표로 되돌린다. 두 메서드 모두 페이지 인덱스, DPI, 점을 받으며, 둘 다 변환을 두 단계로 해석한다—먼저 페이지 자체의 /Rotate 항목과 좌하단 PDF 원점, 그다음 뷰 고유의 별도 비파괴적 ViewRotation과 뷰어의 좌상단 디바이스 원점 순서다—이는 역방향 변환이 이 두 단계를 정확히 역순으로 되돌려 페이지 회전과 뷰 회전의 16가지 조합 전부에서 올바르게 왕복하도록 하기 위해서다. THPDFViewer는 사용자가 vimHighlight 상호작용 모드에서 사각형을 드래그한 뒤 마우스를 놓을 때 ViewPointToPage를 호출해, 두 디바이스 점을 페이지 공간의 THPDFRectangle로 바꾸고 이를 Model.AddHighlightRegion에 넘긴다. 비슷한 것을 직접 만든다면 알아둘 가치가 있는 세부사항 하나: 마우스 캡처는 비트맵이 그려지는 자식 TImage가 아니라 TScrollBox를 상속한 뷰어 자신에게 있다. TControl.MouseCapture가 protected이고 오직 부모 컨트롤만 이를 획득할 수 있기 때문이다. 그래서 버튼을 놓기 전에 드래그가 이미지의 경계를 벗어나더라도, 자식 컨트롤이 조용히 놓쳐버리는 대신 뷰어 자신의 오버라이드된 MouseMove/MouseUp을 통해 여전히 해석된다
var
ViewPt, PagePt: THPDFViewerPoint;
Rect: THPDFRectangle;
begin
ViewPt.X := 240; // device pixels inside the rendered image
ViewPt.Y := 96;
if Model.ViewPointToPage(Model.PageIndex, ViewPt, PagePt,
RenderedDPI) then // DPI you last rendered at
begin
Rect.Left := PagePt.X - 40; Rect.Bottom := PagePt.Y - 10;
Rect.Right := PagePt.X + 40; Rect.Top := PagePt.Y + 10;
Model.AddHighlightRegion(Model.PageIndex, Rect);
end;
end;
초록 테스트 스위트를 넘어서 이 분리가 주는 것
이 분리의 성과는 데스크톱 세션 없는 CI 작업에서 테스트가 통과하는 데 그치지 않는다. THPDFViewer가 로직을 중복하지 않고 THPDFViewerModel에 전달만 하기 때문에, HotPDF는 세 번째 소비자—내비게이션, 확대, 검색, 회전을 표준 Delphi TActionList에 꽂아 넣는 THPDFViewerAction과 그 구체 서브클래스인 THPDFZoomInAction, THPDFFindNextAction—를 추가할 수 있었다. 덕분에 툴바 버튼이나 메뉴 항목은 뷰어가 현재 액션의 대상으로 해석되는지에 따라 자동으로 활성화되며 뷰어를 선언적으로 제어할 수 있다. 이 계층 어디에도 비트맵이나 GDI에 대한 지식은 필요 없었다: 그저 Viewer.NextPage나 Viewer.Model.FindNext를 호출할 뿐이고, 기존 이벤트 체인이 다시 그리기를 알아서 처리한다. 그리고 THPDFViewerModel 안 어디에도 TScrollBox, TImage, 창 핸들에 대한 참조가 없기 때문에, 그 밑에 있는 상태 머신도 그 하나의 컨트롤에 용접되어 있지 않다—같은 모델이 내비게이션, 확대, 검색 로직을 한 줄도 건드리지 않고 다른 렌더링 표면 뒤에 자리 잡을 수도 있다
렌더 캐시가 도움이 되는 곳과 되지 않는 곳
THPDFViewerModel의 렌더 캐시는 이미 로드된 문서 안에서는 도움이 되지만, 애초에 그 문서를 로드하는 비용 자체는 바꾸지 못한다. CreatePageSnapshot, CreateCurrentPageSnapshot, 그리고 프리페치 메서드인 PrefetchPageSnapshots/PrefetchCurrentPageSnapshots는 모두 페이지와 DPI를 키로 하는 동일한 캐시된 렌더러를 거치므로, 같은 확대율로 이미 본 적 있는 페이지로 되돌아가는 것은 재렌더링이 아니라 캐시 적중이 되며, 이웃 페이지 몇 개를 미리 프리페치하는 것은 독자가 한 번에 한 페이지씩 앞으로 넘기는 흔한 시나리오를 매끄럽게 만든다. 다만 이 중 어느 것도 최초 LoadFromFile 호출의 비용은 건드리지 않으며, 사용자가 드래그해 넣는 것은 무엇이든 열도록 만든 뷰어는 결국 그 호출 자체가 실제 병목이 될 만큼 큰 파일을 만나게 된다. 그날이 오기 전에 알아둘 가치가 있는, 전체 로드에 대한 계층적·핸들 기반 대안은 대용량 PDF를 위한 Direct File API에 관한 자매 글을 참고하라
여기서 설명한 Model과 View 클래스는 Delphi와 C++Builder용 HotPDF 컴포넌트 전반에서 사용되는 동일한 로드된 문서 표면의 또 다른 두 조각이며, 폼에서든, TActionList에서든, 또는 그 어느 쪽도 없이든 구동되도록 설계되었다