기술 문서

Delphi PDFium FPDFAvail로 다운로드 중 열기와 취소

PDFium Component는 아직 내려받는 중인 PDF를 TPdfProgressiveDocument, 즉 PDFium의 FPDFAvail_* 가용성 API를 감싼 TPdf 서브클래스로 엽니다. BeginProgressiveLoad가 세션을 시작하고, CheckDocumentAvailability가 PDFium이 아직 필요로 하는 바이트 범위를 보고하고, 충분한 바이트가 모이면 OpenProgressiveDocument가 파일을 열고, CancelProgressiveLoad가 네이티브 핸들을 새지 않고 중단된 다운로드를 버립니다. 어려운 부분은 해피 패스가 아닙니다. 불안정한 연결의 뷰어에서는 사용자가 25퍼센트에서 탭을 닫고, 생각을 바꿔 같은 링크를 다시 여는 일이 벌어지는데, 그 중단된 세션마다 네이티브 가용성 핸들, C 콜백 레코드 두 개, 스트림 어댑터, 그리고 정확히 옳은 순서로 해제되어야 하는 진행 중 range 요청들이 붙어 있습니다

TPdfProgressiveDocument는 다운로드 중인 PDF를 어떻게 로드할까요?

TPdfProgressiveDocument는 랜덤 액세스 스트림이 채워지는 동안 PDFium 가용성 제공자를 살려 두고, 파스 단계마다 그 제공자에게 원하는 바이트가 있는지 묻습니다. BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount)는 백킹 스트림과 원격 파일의 논리 크기를 받아 IsDataAvail 콜백과 AddSegment 콜백을 두 레코드에 연결하고 FPDFAvail_Create를 호출합니다. PDFium이 어떤 범위가 있는지 묻으면, 컴포넌트는 그 범위가 AvailableByteCount가 묘사하는 연속 프리픽스 안에 있거나 RangeRequests 스케줄러를 통해 이미 완료된 범위 안에 있으면 yes라고 답하고, OnDataAvailable 이벤트는 희소 스토어를 위해 판정을 재정의할 수 있습니다. CheckDocumentAvailability 호출은 세 TPdfDataAvailability 값(pdaAvailable, pdaNotAvailable, pdaError) 중 하나를 반환하고, PDFium이 요청한 범위들을 정렬되고 병합된 TPdfDownloadRanges 배열로 돌려주는데, 이미 rrpImmediate 우선순위로 스케줄러에 큐에 들어가 있습니다

// FetchRange는 당신의 전송 계층입니다 (HTTP Range GET, 소켓, blob reader):
// Offset에 Size 바이트를 Store에 쓰고 도착한 개수를 반환합니다
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // 힌트는 이미 큐에 있습니다. 바이트를 먼저 쓴 다음 완료하세요
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

그 루프의 두 디테일이 하중을 지고 있습니다. 라운드 상한이 중요한 이유는 죽은 링크가 CheckDocumentAvailability로 하여금 같은 범위를 영원히 묻게 만들 수 있고, 무한 루프는 네트워크 실패를 멈춘 UI로 바꾸기 때문입니다. 순서가 중요한 이유는 스케줄러가 크리티컬 섹션으로 자기 상태를 직렬화하지만 백킹 스토어의 TStream.Position에 대해서는 아무것도 하지 않기 때문입니다. 전송 스레드는 CompleteRequest를 호출하기 전에 응답 바이트를 스트림에 써야 합니다. 완료가 발표되는 순간 PDFium이 그 범위를 읽을 수 있고, 동시 쓰기는 positioned I/O나 자체 잠금이 필요합니다

PDFium Component의 TPdfProgressiveDocument 가용성 루프: BeginProgressiveLoad가 FPDFAvail 제공자를 만들고, CheckDocumentAvailability는 rrpImmediate 우선순위로 큐에 들어간 정렬·병합된 다운로드 힌트를 돌려주며, 전송은 CompleteRequest가 각 범위를 PDFium에 발표하기 전에 스토어에 바이트를 씁니다. 루프는 죽은 링크가 같은 범위를 계속 묻으므로 64라운드로 제한됩니다
바이트를 쓴 다음 요청을 완료하세요. 완료가 발표되는 순간 PDFium이 그 범위를 읽을 수 있고, 스트림 위치를 대신 지켜 주는 것은 없습니다

AvailableByteCount는 뒤로 가기를 거부하는 이유는?

AvailableByteCount는 커지기만 하고, 세터는 줄이려 하면 "Available byte count cannot move backwards"라는 EPdfError를 일으킵니다. IsDataAvail 콜백이 일단 PDFium에게 어떤 범위가 존재한다고 말했다면 파서는 이미 그로부터 개체를 읽어 캐시했을 수 있으므로, 그 바이트를 나중에 거두면 가용성 답변이 PDFium이 이미 소비한 것과 어긋납니다. 같은 세터는 LogicalFileSize보다 큰 값도 거부하고 세션 밖에서는 "No progressive load is active"를 일으키므로, 로드 시작 전에 이미 쥐고 있는 바이트는 너무 이른 속성 대입 대신 BeginProgressiveLoad의 AInitialAvailableByteCount 인수로 들어가야 합니다. 다운로드 스토어가 순서 없이 채워진다면 프리픽스로 표현하려 애쓰지 마세요. 범위는 스케줄러로 완료하거나 OnDataAvailable로 답하세요

부분 다운로드된 PDF는 실제로 언제 열릴까요?

파일 전체가 도착하기 전에 열리는 것은 linearized PDF(ISO 32000-1 부록 F, "Fast Web View" 레이아웃)뿐입니다. non-linearized PDF는 여전히 모든 바이트를 필요로 합니다. OpenProgressiveDocument는 Linearization 속성(plnUnknown, plnNotLinearized, plnLinearized)을 검사해 그에 따라 분기합니다. linearized 파일은 첫 페이지 섹션과 힌트 테이블이 있는 대로 FPDFAvail_GetDocument로 열리고, non-linearized 파일은 같은 파일 액세스 레코드로 FPDF_LoadCustomDocument를 거쳐 열리며 통째로만 읽을 수 있는 것으로 취급됩니다. 이 분기에는 구체적인 이유가 있습니다. non-linearized 파일에 FPDFAvail_GetDocument를 호출하면 페이지 수가 0인 non-null 핸들, 즉 열린 것처럼 보이는 빈 문서가 돌아올 수 있습니다. 컴포넌트의 자체 테스트 스위트에서 51페이지 linearized 피쳐는 희소 다운로드 스토어가 아직 파일을 다 커버하지 않는데도 pdaAvailable에 도달해 완전한 페이지 트리로 열립니다

PDFium Component에서 OpenProgressiveDocument가 부분 다운로드를 분기하는 방식: linearized 파일은 첫 페이지 섹션과 힌트 테이블이 도착하는 대로 FPDFAvail_GetDocument로 열리고, non-linearized 파일은 FPDF_LoadCustomDocument와 모든 바이트가 필요하며, LoadAvailablePage는 페이지 검사 전에 FPDFAvail_IsFormAvail로 폼 가용성을 검사해 non-null 제로 페이지 핸들 함정을 피합니다
출발을 앞서는 것은 linearized 파일뿐입니다. 그 외에는 FPDFAvail_GetDocument가 0페이지짜리 열린 것 같은 문서를 돌려줄 수 있는데, 분기가 정확히 그것을 막습니다
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumber가 이제 활성 페이지입니다
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePage는 1 기반 페이지 번호를 받아 PDFium이 기대하는 순서를 강제합니다. 첫 페이지 검사 전에 FPDFAvail_IsFormAvail을 감싼 CheckFormAvailability를 돌리고, 그다음에야 FPDFAvail_IsPageAvail을 호출합니다. pfaNotPresent는 AcroForm이 없는 문서의 정상적인 답이고 아무것도 막지 않습니다. 페이지가 준비되면 LoadAvailablePage는 그 페이지를 활성 페이지로 만드므로, 뷰어는 나머지 페이지가 전송 중인 동안 linearized 브로슈어의 1페이지를 렌더링할 수 있습니다. FirstAvailablePageNumber는 linearization 사전이 첫 페이지로 지정한 페이지를, PDFium의 0 기반 인덱스에서 이미 변환해 알려 줍니다

CancelProgressiveLoad는 무엇을 어떤 순서로 해제할까요?

CancelProgressiveLoad는 재배치할 수 없는 네 단계로 세션을 해체합니다. range 스케줄러를 취소하고, 문서를 닫고, FPDFAvail_Destroy로 가용성 핸들을 파괴한 다음, 콜백 레코드를 폐기하고 스트림 어댑터를 해제합니다. 스케줄러를 먼저 취소하면 세대 카운터를 올리고 대기 중과 진행 중인 모든 요청을 떨어뜨리고 진행 중인 것마다 OnCancelRequest를 발사하므로, 나중에 도착하는 전송 완료는 옛 세대를 실어 CompleteRequest가 아무것도 건드리지 않고 False를 반환합니다. 문서는 가용성 핸들과 어댑터가 사라지기 전에 닫혀야 합니다. PDFium은 문서를 닫으면서 파일 액세스 제공자로 되호출할 수 있고, 어댑터가 이미 사라졌다면 그 콜백은 해제된 메모리를 읽습니다

PDFium Component의 CancelProgressiveLoad 고정 해체 순서: 먼저 range 스케줄러를 취소해 늦은 완료가 올라간 세대 카운터에 걸려 False를 반환하게 하고, 파일 액세스 어댑터가 사라지기 전에 문서를 닫고, FPDFAvail_Destroy로 가용성 핸들을 파괴하며, 그다음에야 콜백 레코드를 폐기하고 스트림 어댑터를 해제합니다
멱등한 메서드 하나가 실패한 시작, 사용자 취소, 소멸자를 모두 청소합니다. 워커 스레드가 스토어에 쓰는 동안에는 스트림 소유권이 당신에게 남습니다
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // 스케줄러는 FPdf와 수명을 같이하므로 한 번만 연결합니다
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // 당신의 코드: 그 소켓이나 요청을 닫습니다
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

이 메서드는 멱등하고 세 상황의 유일한 정리 경로입니다. 생성 도중 실패한 BeginProgressiveLoad, 명시적인 사용자 취소, 그리고 소멸자입니다. BeginProgressiveLoad도 시작 전에 이를 호출하므로, 같은 개체를 새 URL로 재시작하는 것은 명시적 취소 없이도 안전합니다. 한 가지 소유권 결정은 당신이 옳게 해야 합니다. 워커 스레드가 백킹 스트림에 쓴다면 AOwnsStream = False로 넘기고 워커가 멈춘 뒤 스트림을 직접 해제하세요. 소유권을 넘겨 버리면 취소가 스트림을 해제하는 시점에 늦은 쓰기가 아직 도중에 있을 수 있습니다. OnCancelRequest 안에서 일어나는 예외는 요청마다 삼켜지므로, 하나의 고장 난 전송이 나머지 취소를 막지 못합니다

라이프사이클 스위트는 취소 경로가 새지 않음을 어떻게 증명할까요?

PDFium Component 라이프사이클 스트레스 스위트는 모든 혼합 사이클에서 중단된 네트워크식 다운로드를 연습합니다. 각 사이클은 피쳐 바이트의 4분의 1만 담은 스토어로 progressive 로드를 시작하고, 비어 있지 않은 힌트 목록과 함께 pdaNotAvailable을 요구하고, CancelProgressiveLoad를 호출하며, 개체가 ProgressiveLoading도 Active도 보고하지 않음을 단언합니다. 그다음 완전한 가용성으로 같은 스트리밍 경로를 끝까지 돌립니다. OpenProgressiveDocument, 렌더 하나, 닫기 하나요. 기본 혼합 실행은 600회 오픈, 2300회 렌더, 100회 progressive 취소를 곁들인 100개 측정 사이클을 커버하고, 표본 프라이빗 메모리는 32 MiB 예산 대비 8.21 MiB 늘었습니다. 스위트는 progressive 취소를 렌더 콜백 취소와 따로 셉니다. 중단된 다운로드와 일찍 멈춘 렌더 루프는 수용 기준이 다른 다른 이벤트이기 때문입니다

progressive 경로가 힘을 못 쓰는 지점

이 위에 뷰어를 세우기 전에 알아 둘 한계가 몇 가지 있습니다. 원본 파일 바이트가 필요한 기능은 불완전한 progressive 소스를 추측하는 대신 거부합니다. ReadXmpPacket은 명시적으로 실패하고 서명 검증은 파일 전체가 도착할 때까지 Indeterminate를 보고합니다. 기본 가용성 검사는 연속 프리픽스를 가정하므로, 범위를 순서 없이 가져오는 전송은 RangeRequests로 완료하거나 OnDataAvailable로 답해야 합니다. 아니면 PDFium은 당신이 이미 쥔 바이트를 계속 요구합니다. non-linearized 파일은 첫 페이지까지의 시간에서 아무것도 얻지 못하므로, 첫 그리기 속도가 중요하다면 서버 쪽에서 파일을 linearize하세요. 그리고 CancelProgressiveLoad는 소켓을 직접 닫지 않습니다. OnCancelRequest가 그 일이 일어나는 훅입니다

완전한 로컬 파일을 온디맨드로 로드하는 평범한 스트림 어댑터 경로는 PDFium으로 대용량 PDF 온디맨드 스트리밍을 보세요. 더 큰 버퍼 안에 들어 있는 PDF를 여는 것은 임베디드 PDF의 byte range 로딩을 보세요. 이미 로드된 페이지의 느린 렌더를 취소하는 것은 별도의 메커니즘으로 취소 가능한 progressive 페이지 렌더링에서 다룹니다. TPdfProgressiveDocument와 그 range 스케줄러는 Delphi 및 C++Builder용 PDFium Component와 함께 실려 나옵니다