기술 문서

Delphi의 비파괴적 PDF 하이라이트: HotPDF 리뷰 레이어

검토 중 단락 주위에 그린 사각형이 PDF 내부의 실제 마크가 될 필요는 없다. HotPDF의 THPDFViewerModel은 모든 하이라이트를 로드된 문서에 대한 변경이 아니라 메모리 상의 기록으로 유지하는 메서드인 AddHighlightRegion을 제공하므로, 검토자는 수십 페이지에 마크업을 남기면서도 디스크 상의 파일은 바이트 단위로 예전 그대로 유지할 수 있다. 6400%로 확대하거나, 페이지를 90도 회전하거나, Fit Width에서 Fit Page로 전환해도 동일한 사각형은 여전히 같은 단락 위에 놓이는데, 이는 좌표 연산이 마크를 그린 순간의 실제 렌더링 지오메트리를 통해 이루어지기 때문이다

PDF 뷰어를 중심으로 만든 검토 도구는 이 문제를 끊임없이 마주한다. 교정 화면, 생성된 인보이스에 대한 QA 검토, 내부 승인 워크플로 — 이 모든 것은 임시 마크마다 파일에 대한 영구적 변경으로 이어지지 않으면서, 그리고 마크가 유효한지 아직 판단 중인 동안 색칠된 상자 하나를 보여주기 위해 전체 주석 서브시스템까지 동원하지 않으면서, 누군가 페이지의 특정 영역에 주의를 표시할 수 있어야 한다. HotPDF는 Delphi에서 MVC 아키텍처로 커스텀 PDF 뷰어 구축하기에서 설명한 분리 구조 중 완전히 Model 쪽에 자리 잡은 전용 하이라이트 레이어로 이 문제에 답하며, 바로 그 이유로 동일한 하이라이트 목록을 창 핸들 없이 유닛 테스트에서도 구동할 수 있다

HotPDF의 AddHighlightRegion은 실제로 무엇을 저장하는가?

AddHighlightRegion은 마크당 정확히 세 가지, 즉 0부터 시작하는 페이지 인덱스, PDF 사용자 공간 좌표의 THPDFRectangle, TColor를 저장하며, 이 모두를 THPDFViewerModel 내부의 THPDFViewerHighlight 레코드 하나로 묶는다. Viewer.HighlightRegion(PageIndex, PageRect, clYellow)를 호출하거나 그와 동등한 Model.AddHighlightRegion을 호출하면 비공개 배열에 이런 레코드 하나가 추가되고 그 인덱스가 반환되는데, 호출자가 돌려받는 핸들은 이 인덱스뿐이다: 별도의 객체도, 참조 카운트 인터페이스도, 해제할 것도 없다. 이 글에서 다루는 다른 모든 기능 — 마크 그리기, 확대율 변경 후 재매핑, 삭제 — 는 이 작은 레코드 하나를 기반으로 만들어져 있다

모든 사각형은 받아들여지기 전에 정규화되고 클리핑된다. AddHighlightRegion은 검토자가 오른쪽에서 왼쪽으로 드래그하면 좌우 모서리를 뒤바꾸고, 위로 드래그하면 상하 모서리를 뒤바꾼 뒤, GetLoadedPageBox로 가져온 페이지의 MediaBox를 기준으로 결과를 클리핑한다. 폭이 0이거나 높이가 0이거나 페이지 완전히 바깥에 놓이게 되는 사각형은 곧바로 거부된다: 이 메서드는 -1을 반환하고 목록에는 아무것도 추가되지 않는다. 이 반환값은 장식이 아니다: 외부 검토 파일에서 재구성된 하이라이트 묶음이나 페이지가 교체된 뒤의 낡은 좌표는, 호출자가 확인하지 않으면 소리 없이 항목을 잃어버릴 수 있다

확대나 회전 후에도 하이라이트는 어떻게 정렬을 유지하는가?

하이라이트가 정렬을 유지하는 이유는 확대율이 바뀌는 순간 낡아버릴 화면 좌표 사각형을 저장하는 대신, HotPDF가 이를 PDF 페이지 공간에 저장하고 다시 그릴 때마다 화면 공간으로 재투영하기 때문이다. THPDFViewerModel.PagePointToView와 그 역함수인 ViewPointToPage는 이 투영을 두 단계로 수행한다: 먼저 페이지 자체의 /Rotate 항목, 그다음 PDF에 결코 다시 기록되지 않고 뷰어가 표시하는 내용에만 영향을 주는 뷰어 고유의 ViewRotation이다. 마우스를 놓을 때 변환을 되돌리는 과정은 같은 두 단계를 역순으로 실행하며, 이것이 270도 회전된 페이지에서 고배율로 그린 하이라이트가 검토자가 뷰를 Fit Page로 되돌린 뒤에도 정확히 제자리에 놓이게 하는 이유다

이 투영에 사용되는 DPI는 회전 못지않게 중요하다. HotPDF의 뷰어는 각 렌더링 직후 현재 화면에 있는 비트맵의 정확한 DPI를 FRenderedDPI에 캡처하고, ImageMouseUp은 그 값을 그대로 ViewPointToPage에 전달하여, 마우스 좌표가 항상 실제로 그려진 해상도로 변환되도록 하지 현재 확대율 속성에서 다시 계산한 해상도로 변환되지 않도록 한다. CreatePageSnapshot과 그 계열 메서드들은 DPI를 12에서 2400 범위로 제한하지만, 인터랙티브 렌더링 경로에는 그런 상한이 없다: 표준 확대 단계는 6400%에서 정점을 찍는데, 기본 96 DPI 기준으로 계산하면 2400 DPI를 훌쩍 넘어서므로, 스냅샷 스타일의 제한을 좌표 매핑에 재사용하면 확대 범위 최상단에서 모든 하이라이트가 몇 픽셀씩 어긋나게 된다. 두 가지 작은 기본 동작이 이 상호작용을 마무리한다: 어느 한 축으로든 2픽셀보다 짧은 드래그는 클릭으로 취급되어 하이라이트를 만들지 않으며, FRenderedDPI가 0에서 시작하기 때문에 최소 한 페이지가 실제로 렌더링되기 전에는 하이라이트 작업을 시작할 수 없다

인터랙티브 하이라이트를 검토 화면에 연결하기

인터랙티브 하이라이트를 켜는 것은 THPDFViewer 컨트롤 자체에서 속성 세 개만 설정하면 되는 작업이다: 기본값인 vimBrowse 대신 InteractionModevimHighlight로 설정하고, 기본값이 clYellowHighlightColor를 고르고, 검토자가 방금 무엇을 그렸는지 알기 위해 OnMarqueeSelect를 처리한다. 마우스를 캡처하고, 검토자가 드래그하는 동안 점선 선택 사각형을 그리고, 놓은 지점을 페이지 공간으로 되돌려 변환하고, AddHighlightRegion을 호출하는 나머지 모든 작업은 이 이벤트가 발생하기 전에 컨트롤 내부에서 처리된다

type
  TReviewForm = class(TForm)
    Viewer: THPDFViewer;
    ReviewLog: TMemo;
    procedure FormCreate(Sender: TObject);
  private
    procedure ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
      PageIndex: Integer; const PageRect: THPDFRectangle;
      HighlightIndex: Integer);
  end;

// PdfDoc is a THotPDF already loaded elsewhere on the form
procedure TReviewForm.FormCreate(Sender: TObject);
begin
  Viewer.PDFDocument := PdfDoc;
  Viewer.InteractionMode := vimHighlight;
  Viewer.HighlightColor := clLime;
  Viewer.OnMarqueeSelect := ViewerMarqueeSelect;
end;

procedure TReviewForm.ViewerMarqueeSelect(Sender: TObject; Shift: TShiftState;
  PageIndex: Integer; const PageRect: THPDFRectangle; HighlightIndex: Integer);
begin
  ReviewLog.Lines.Add(Format('page %d, mark #%d at (%.1f, %.1f)-(%.1f, %.1f)',
    [PageIndex + 1, HighlightIndex, PageRect.Left, PageRect.Bottom,
     PageRect.Right, PageRect.Top]));
end;

OnMarqueeSelect는 실제로 하이라이트를 만들어낸 드래그에서만 발생한다: 드래그로 인정되기에 너무 작은 클릭은 선택 오버레이를 즉시 지우고, 페이지 완전히 바깥에 놓인 드래그는 AddHighlightRegion까지 도달하지만 프로그램적 호출과 동일한 방식으로 그 자리에서 거부되므로, 어느 경우든 이벤트는 조용히 지나간다. 컨트롤 가장자리에서 하이라이트가 반응을 멈춘 것처럼 보인다면 알아둘 가치가 있는 구현 세부사항 하나: 마우스 캡처는 페이지 비트맵을 보여주는 내부 TImage가 아니라 TScrollBox를 상속한 THPDFViewer 자신에게 있으며, 이것이 검토자가 렌더링된 페이지의 경계를 넘어 드래그해도 깔끔하게 마우스를 놓을 수 있게 하는 이유다

코드로 하이라이트를 추가, 삭제, 다시 읽기

하이라이트가 반드시 마우스 드래그에서 나올 필요는 전혀 없다. 인터랙티브 드래그가 내부적으로 호출하는 것과 동일한 Model.AddHighlightRegion으로 흘러 들어가는 Viewer.HighlightRegion(PageIndex, PageRect, Color)는, 검토 화면이 이미 가지고 있는 데이터 — 데이터베이스에서 불러온 코멘트, 텍스트 검색 결과, 이전 세션에서 복원한 마크 — 로부터 하이라이트를 재구성할 수 있도록 공개되어 있다. 좌표가 순수한 PDF 사용자 공간 숫자이기 때문에, FRenderedDPI에 실제 값이 이미 들어 있어야 하는 인터랙티브 드래그와 달리, 이 경로의 어떤 부분도 페이지가 먼저 렌더링되었는지에 의존하지 않는다

var
  I: Integer;
  Item: TPriorComment;    // your own record: PageIndex + PageRect
  NewIndex: Integer;
begin
  for I := 0 to PriorComments.Count - 1 do
  begin
    Item := TPriorComment(PriorComments[I]);
    NewIndex := Viewer.HighlightRegion(Item.PageIndex, Item.PageRect, clAqua);
    if NewIndex < 0 then
      LogWarning('comment %d fell outside the page and was dropped', [I]);
  end;
end;

하이라이트 하나를 삭제할 때 배열 기반 저장 방식의 특성이 드러난다. RemoveHighlightRegion은 레코드 하나를 삭제하고 그 뒤의 모든 레코드를 한 자리씩 앞으로 당겨 빈틈을 메우는데, 이는 OnMarqueeSelect 이벤트나 이전 열거에서 미리 확보해 둔 인덱스가 목록에서 그보다 앞선 무언가가 삭제되는 순간 더 이상 신뢰할 수 없게 된다는 뜻이다. OnHighlightChange는 추가, 삭제, ClearHighlightRegions 호출마다 발생하지만 무엇이 바뀌었는지에 대한 정보는 담고 있지 않으므로, 안전한 패턴은 캐시된 인덱스를 그 자리에서 패치하는 대신 이를 신호로 삼아 검토 패널이 보여주는 목록 전체를 HighlightCountTryGetHighlightRegion으로부터 다시 구성하는 것이다

procedure TReviewForm.ViewerHighlightChange(Sender: TObject);
var
  I: Integer;
  Mark: THPDFViewerHighlight;
begin
  MarkList.Items.Clear;
  for I := 0 to Viewer.Model.HighlightCount - 1 do
    if Viewer.Model.TryGetHighlightRegion(I, Mark) then
      MarkList.Items.AddObject(Format('page %d', [Mark.PageIndex + 1]),
        TObject(I));
end;

마크는 언제 실제 Highlight 주석이 되어야 하는가?

하이라이트 영역은 그 하나의 THPDFViewer 인스턴스 바깥에서도 살아남아야 하는 순간 실제 주석이 되어야 한다. HotPDF는 새 페이지용 AddHighlightAnnotation과 이미 로드된 문서용 AddLoadedHighlightAnnotation도 제공하는데, 이름이 거의 같음에도 이는 완전히 다른 메커니즘이다: 둘 다 정확한 글리프 범위를 표시하는 /QuadPoints와 함께 실제 ISO 32000-1 §12.5.6.10 텍스트 마크업 주석(PDF /Subtype /Highlight)을 페이지의 /Annots 배열에 기록하며, 파일이 저장되면 HotPDF뿐 아니라 표준을 준수하는 모든 PDF 뷰어가 이를 렌더링한다. 마크가 XFDF를 통해 왕복 변환되는지 여부도 동일한 메커니즘 경계가 결정한다: AddLoadedHighlightAnnotation으로 만든 주석은 Delphi에서 PDF 주석을 XFDF로 임포트·익스포트하기에서 다루는 대로 ExportLoadedAnnotationsToXFDF가 인식해 ISO 19444-1 마크업으로 Acrobat이나 다른 검토 도구에 전달하는 정상적인 PDF 객체인 반면, AddHighlightRegion으로 추가된 영역은 애초에 객체 그래프에 전혀 기록되지 않았기 때문에 그 익스포트에게는 보이지 않는다: 그 영역은 자신을 만든 THPDFViewerModel이 존재하는 동안만 존재한다. 페이지에서 사용 가능한 전체 마크업 및 기하학적 주석 유형과 사각형이 각각을 어떻게 배치하는지는 Delphi에서 HotPDF로 다루는 PDF 주석에 관한 글에서 다루며, 실무 원칙은 단순하다: 문서가 아직 논의 중인 동안에는 마크를 일회용으로 유지하고, 결정이 확정되면 그때 주석으로 커밋하라

하이라이트 레이어가 멈추는 지점

하이라이트 레이어는 반투명 형광펜처럼 보이려 하지 않는다: RefreshDocument는 밑에 있는 텍스트 위에 색을 채운 채우기를 블렌딩하는 대신, 검색 결과를 그리는 것과 동일한 방식으로 캐시된 페이지 비트맵 위에 각 영역을 자신의 색으로 된 2픽셀 윤곽선 사각형으로 그린다. 그래서 고전적인 노란색 워시 느낌을 원한다면 애플리케이션 코드에서 직접 그리거나, 승격된 주석 자체의 appearance stream으로 미뤄야 한다. 영역이 만들어진 뒤 재사용할 가치가 있는 기능 하나는 CreateCurrentPageRegionSnapshot인데, 이미 하이라이트가 가지고 있는 동일한 THPDFRectangle을 받아 그 영역만 비트맵으로 렌더링하며, 전체 페이지를 내보내지 않고도 검토 코멘트에 작은 미리보기 이미지를 붙이는 데 유용하다. 검토 빌드가 처음부터 두 메커니즘 중 하나를 선택할 필요는 없다: 코멘트 스레드가 열려 있는 동안에는 모든 새 마크를 기본적으로 일회용 THPDFViewerHighlight 영역으로 두고, 검토자가 결론을 내린 뒤에야 AddLoadedHighlightAnnotation을 호출하면, 가장 많은 변경이 오가는 논의 과정 동안 로드된 PDF를 건드리지 않은 채로 유지할 수 있다. 여기서 설명한 뷰어 컨트롤은 위에서 언급한 다른 주석 및 폼 API들과 함께 Delphi와 C++Builder용 HotPDF 컴포넌트 표준판의 일부다