기술 문서

델파이에서의 취소 가능한 점진적 PDF 렌더링 (PDFium)

대부분의 PDF 페이지는 몇 밀리초 내에 래스터화(rasterise)되므로 여러분은 이를 의식하지 못합니다. 그러나 사용자가 A1 엔지니어링 도면, 수만 개의 벡터 스트로크로 가득 찬 페이지, 또는 투명도 그룹과 소프트 마스크로 붐비는 포스터를 열 때 이를 그리는 단일 호출에 2~3초가 걸립니다. 해당 호출이 UI 스레드에서 실행되는 경우 창은 다시 그리기를 중지하고 제목 표시줄이 회색으로 변하며 운영 체제는 애플리케이션을 종료할 것을 제안합니다. 작업은 정상적입니다. 페이지는 실제로 그만큼 오래 걸립니다. 결함은 렌더링이 숨을 쉴 방법이 없고 멈출 방법이 없는 나눌 수 없는(indivisible) 하나의 차단(blocking) 호출이라는 점입니다

이 아티클은 UI를 멈추지 않고 길게 이어지는 단일 페이지 렌더링을 취소하는 문제, 이 두 가지 문제 중 정확히 하나에 관한 것입니다. 사용자가 다음 페이지를 클릭했거나 확대/축소했거나 문서를 닫았으며, 진행 중인 렌더링은 이제 끝까지 실행되기보다 다음 기회에 끝나야 할 헛수고입니다. 이미 래스터화된 항목을 캐시하여 스크롤 및 확대/축소를 부드럽게 만드는 것은 그 자체의 설계가 있는 별개의 관심사이며 마지막에 링크된 동반 아티클에서 다룹니다. 여기서 유일한 질문은 하나의 점진적 렌더링이 어떻게 빠르고 깔끔하게 취소 요청에 응답할 수 있게 만드는가 하는 것입니다

PDFium이 이미 제공하고 있는 점진적 렌더링 API

PDFium은 문제의 절반인 멈춤(freezing) 현상을 예상했습니다. 단발성(one-shot)인 FPDF_RenderPageBitmap과 더불어, 페이지를 여러 작업 청크(chunks)로 쪼개는 점진적 변형을 노출합니다. FPDF_RenderPageBitmap_Start를 한 번 호출하여 대상 비트맵에 대한 렌더링을 설정한 다음, FPDF_RenderPage_Continue를 반복적으로 호출합니다. 각각의 Continue는 경계가 지정된 조각을 래스터화하고 상태를 반환합니다. FPDF_RENDER_TOBECONTINUED는 할 일이 더 남았음을 의미하고, FPDF_RENDER_DONE은 페이지가 끝났음을 의미하며, FPDF_RENDER_FAILED는 오류가 발생하여 중지되었음을 의미합니다. 루프가 끝나면 FPDF_RenderPage_Close를 호출하여 페이지별 점진적 상태를 해제합니다. 조각과 조각 사이 제어권이 코드로 돌아오기 때문에 메시지를 펌핑(pump)하거나, 진행 상태 표시기를 업데이트하거나, 해당 작업이 여전히 필요한지 여부를 확인할 수 있습니다

제어권을 양보할 시점을 결정하기 위해 PDFium이 제공하는 매커니즘은 IFSDK_PAUSE라는 이름의 콜백 구조체입니다. 이를 Start와 모든 Continue에 전달합니다. 각 청크 이후 PDFium은 그 구조체의 NeedToPauseNow 함수 포인터를 호출하고, 이것이 0이 아닌(non-zero) 값을 반환하는 경우 현재 Continue는 일찍 멈추고 FPDF_RENDER_TOBECONTINUED와 함께 제어권을 반환합니다. 이 구조체에는 1로 설정해야 하는 version 필드와, PDFium이 절대 건드리지 않고 그대로 통과시키는 자유 형식의 user 포인터도 들어 있습니다. 아무도 건드리지 않는 이 포인터가 앞으로 이어질 설계의 핵심(hinge)입니다

일시 정지를 취소로 재설정하기

NeedToPauseNow의 본래 목적은 타임 슬라이싱(time-slicing)이었습니다. 프레임 예산을 다 썼다면 0이 아닌 값을 반환하고, 렌더링을 계속 유지하려면 0을 반환합니다. 그러면 PDFium은 동일한 렌더링을 다시 재개하기 전에 여러분이 다른 작업을 할 수 있게 일시 중지합니다. PDFium 컴포넌트는 다른 동사(verb)를 위해 그 똑같은 신호를 재사용합니다. 콜백은 "일시 중지하고 재개할 수 있도록 해드릴까요"라고 대답하는 대신 "이 작업이 취소되었습니까"라고 대답합니다. 두 가지는 이 플래그를 보았을 때 루프가 어떤 일을 하는가에 따라 서로 깔끔하게 맵핑됩니다. 진짜 일시 정지라면 추후의 Continue를 예상하지만 취소는 그렇지 않습니다. 호출 루프가 토큰이 취소되었음을 관찰하고 나면, 렌더링 컨텍스트를 닫고 다시는 Continue를 호출하지 않으므로, PDFium이 "이 청크 중단"으로 읽는 것과 동일한 0이 아닌 반환값이 사실상 "완전한 중단"이 됩니다

취소는 IPdfCancellationToken이라는 인터페이스를 통해 표현되며, 프로그램의 다른 부분에서 렌더링 중지를 요청할 때 해당 인터페이스의 IsCancelled 속성이 false에서 true로 뒤집힙니다. 그 파스칼 인터페이스와 PDFium의 C 콜백 간의 다리(bridge)는 단일 포인터입니다. 토큰의 인터페이스 참조가 IFSDK_PAUSE.user에 기록되고, 정적 cdecl 콜백이 그것을 다시 읽어 쿼리합니다. 이것이 C 라이브러리가 파스칼로 다시 호출하게 하는 전형적인 문제입니다. 콜백은 메서드가 아니라 C 호출 규약(calling convention)이 있는 평범한 함수여야 하는데, PDFium은 파스칼 객체나 Self에 대해 아무것도 모르는 원형 함수 포인터(bare function pointer)를 저장하고 호출하기 때문입니다

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

이 콜백은 pThis^.user를 다시 인터페이스 타입으로 캐스팅하여 토큰을 복구하고 IsCancelled를 읽어옵니다. 그 안에서 어떠한 것도 할당(allocates)하거나 잠그거나(locks) 막지(blocks) 않는데, 이는 매우 중요합니다. PDFium이 각 청크 이후 렌더링 스레드에서 이를 호출하며 여기서 수행된 모든 작업은 렌더링 자체 비용에 추가되기 때문입니다. nil 구조체나 nil user 필드에 대한 가드(guard)는 동일한 함수가 실제 토큰을 전혀 부여받지 못한 렌더링에도 안전하게 설치(install)될 수 있음을 의미합니다

루프 전반에 걸쳐 토큰을 활성 상태로 유지하기

인터페이스 포인터를 원시 Pointer를 통해 캐스팅하고 다시 되돌리는 과정에서 수명(lifetime) 버그가 발생합니다. 델파이의 IInterface는 참조 카운트가 계산(reference counted)되며, 컴파일러가 인터페이스 유형 변수가 할당되는 것을 볼 수 있을 때만 카운트가 이동합니다. IFSDK_PAUSE.user 내에 토큰을 단순히 원형 포인터(bare pointer)로만 저장하면 참조 카운터에서 완전히 숨겨집니다. 해당 토큰에 대한 유일한 다른 참조가 Continue 루프가 실행되는 도중 스코프를 벗어나게 된다면 객체는 콜백 밑에서 해제될 것이고, 다음 청크는 댕글링 포인터(dangling pointer)를 역참조(dereference)하게 될 것입니다

그렇기 때문에 이 디스크립터(descriptor)는 하나가 아닌 두 가지 항목을 담고 있는 레코드인 것입니다. Pause 필드는 PDFium이 읽는 구조체입니다. Token 필드는 컴파일러가 카운트하는 실제 인터페이스형 참조이며, 이 레코드가 살아있는 한 메모리에 토큰을 고정해두는 것 이외의 다른 이유는 없습니다. 해당 레코드는 렌더 루틴의 스택상에 존재하는 지역 변수이므로, 루프가 진행되는 전체 기간 동안 내내 유효성을 유지하며 오직 루틴이 종료될 때에만 파괴됩니다. user 안의 원형 포인터와 Token 안의 카운팅된 참조는 같은 객체를 가리킵니다. 전자는 PDFium이 읽을 수 있는 것이고, 후자는 해당 객체가 수거(collected)되지 않도록 막아주는 것입니다

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

루프가 어떻게 끝나든 상관없이 렌더링 컨텍스트 닫기

FPDF_RenderPageBitmap_Start에 대한 모든 호출은 PDFium이 페이지와 연관짓는 점진적 상태(progressive state)를 할당하며, 이 상태는 FPDF_RenderPage_Close에 의해서만 해제됩니다. 드라이브 루프를 빠져나가는 방법은 세 가지입니다. 페이지가 끝나고 마지막 상태가 FPDF_RENDER_DONE인 경우, 토큰이 발동되어 취소를 보고하며 루프가 일찍 종료되는 경우, 무언가 실패하여 상태가 FPDF_RENDER_FAILED인 경우. 이 셋 모두 반드시 Close를 호출해야 하며 취소 경로가 가장 틀리기 쉽습니다. "취소됨을 확인하면 빠져나온다"는 자연스러운 형태가 출구로 향하는 도중에 정리를 건너뛰는 경향이 있기 때문입니다. Close에 도달하지 않은 상태로 두면 페이지별 상태가 유출(leak)되고 사용자가 렌더링을 계속해서 취소할 수 있도록 허용하는 뷰어는 중단된 페이지마다 이 유출이 누적될 것입니다

견고한 형태는 루프와 결과 분류를 try 안에 두고 FPDF_RenderPage_Close를 그에 맞는 finally에 넣는 것입니다. 대상 비트맵은 동일한 블록 내에서 파괴됩니다. 조기 Exit를 통해 루프를 취소 상태로 빠져나가더라도 finally가 여전히 실행되므로 점진적 상태를 해제하는 곳이 정확히 한 곳 있으며 이를 우회할 수 없습니다

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

이 루프는 각 Continue 전면에 토큰을 확인하고 그 안의 콜백에 의존합니다. 콜백은 현재 청크를 줄이고, 루프 확인은 다음 청크가 시작되는 것을 막습니다. 두 기능은 합쳐서 취소가 효력을 발휘하는 데 걸리는 시간을 대략 한 청크의 지속 시간으로 제한합니다

세 가지 결과, 그리고 취소 후 비트맵에 남아 있는 내용

퍼블릭 진입점은 TPdf.RenderPageProgressive이며 prsDone, prsCancelled 또는 prsFailed 중 하나인 TPdfProgressiveStatus를 반환합니다. 이 값들은 PDFium의 FPDF_RENDER_* 상수를 파스칼 관용구로 반영하지만 취소 사례를 오류가 아니라 1급(first-class) 결과로 접어 넣습니다

사람들이 걸리는 지점은 prsCancelled 이후 대상 비트맵에 무엇이 포함되어 있는가입니다. 비어 있지 않습니다. PDFium은 청크를 거듭하며 동일한 비트맵에 점진적으로 렌더링하므로, 취소가 루프를 중지하면 비트맵은 그 순간까지 그려진 내용을 보관합니다. 즉, 부분적인 이미지입니다. 일부 밴드는 완료되었고 나머지는 여전히 채우기 색상을 표시합니다. 이 부분적인 결과가 유용한지 여부는 호출자에게 달려 있습니다. 사용자가 다른 곳으로 탐색했기 때문에 비트맵을 버리려는 뷰어는 비트맵을 무시하면 됩니다. 저비용 미리보기를 표시하려는 뷰어는 비트맵을 유지할 수 있습니다. prsCancelled가 비어 있거나 정의되지 않은 비트맵을 의미한다고 가정해서는 절대 안 됩니다. 완성되지 않은 렌더링에 대한 진실된 스냅샷을 암시하는 것입니다

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

nil 토큰과 분기 없는 콜백 경로

취소는 옵트인(opt-in) 방식입니다. 취소할 의도 없이 단지 메시지 펌핑의 이점을 위해 점진적 렌더링을 원하는 호출자는 토큰에 nil을 전달할 수 있어야 합니다. 이를 지원하는 순진한 방법은 콜백과 루프 전체에 "토큰이 제공된 경우"를 확인하는 검사를 분산시키는 것입니다. 이는 모든 청크에 분기(branch)가 있고 콜백은 진짜 토큰과 토큰이 없는 상황 모두를 처리해야 한다는 것을 의미합니다

이 구현은 호출자가 아무것도 전달하지 않을 때 싱글턴(singleton)을 대입함으로써 이를 피합니다. nil 토큰은 IsCancelled가 항상 거짓(false)인 인터페이스인 PdfNoCancellationToken으로 바뀝니다. 그 시점부터 콜백과 루프는 매 경우마다 조회(query)할 토큰을 갖게 되므로 어느 쪽도 nil 검사나 특별한 경로가 필요하지 않습니다. 취소되지 않는(never-cancel) 토큰은 단순히 언제나 거짓(false)으로 응답하고, 콜백은 항상 0을 반환하며 렌더링은 취소할 수 없는 것과 정확히 똑같이 끝까지 실행됩니다. 선택적 동작을 토큰의 부재가 아니라 토큰이 결코 점화(fires)되지 않는 형태로 모델링함으로써 빈번하게 쓰이는 핫 패스(hot path)를 한결같이 유지합니다

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

도출되는 모양은 작지만 다시 설명할 가치가 있습니다. 이것이 재사용 가능한 부분이기 때문입니다. 콜백을 지원하는 C 라이브러리는 콜백에 상태를 전달할 수 있는 정확히 하나의 채널, 즉 불투명한 사용자 포인터를 제공합니다. 그 포인터 뒤에 카운트된 파스칼 인터페이스 참조를 두고, 객체가 호출 중에 수집될 수 없도록 구조체 옆에 두 번째 실제 참조를 활성 상태로 유지하며 정적 cdecl 함수 내부에서 인터페이스를 다시 읽으십시오. 전체 드라이브 루프를 try 안에 감싸고 finally 안에서 기본 컨텍스트(native context)를 해제하십시오. 동일한 템플릿은 C가 포인터를 잡고 있는 동안 파스칼 코드가 수명을 제어해야 하는 모든 점진적 또는 콜백 기반 PDFium 작업에 전달됩니다

취소는 반응형 뷰어의 절반에 불과합니다. 나머지 절반은 이미 그린 페이지를 다시 렌더링하지 않는 것과, 캐시된 비트맵을 제공하여 확대/축소 및 스크롤을 부드럽게 유지하는 것인데, 이는 렌더링 캐싱 및 확대/축소 성능에 대한 기사에서 다룹니다. 취소 가능한 렌더링이 탐색, 선택, 검색과 더불어 어떻게 완전한 뷰어에 들어맞는지 알아보려면 PDFium 컴포넌트를 사용하여 풍부한 기능의 PDF 뷰어 구축하기를 참조하세요. 여기에 설명된 점진적 렌더링은 이 블로그의 다른 곳에서 다루는 로드, 렌더링 및 폼 API와 함께 델파이 및 Lazarus용 PDFium 컴포넌트의 일부로 배포됩니다