기술 문서

델파이에서 PDFium으로 대용량 PDF 온디맨드 스트리밍하기

스캔된 아카이브는 단일 PDF에서 수 기가바이트에 달할 수 있습니다. 이러한 파일을 여는 뷰어는 보통 한 페이지를 보여주거나, 목차를 보여주거나, 사용자가 책갈피에서 점프한 페이지를 보여주기를 원합니다. 두 페이지를 렌더링하기 위해 전체 파일을 메모리로 읽어들이는 것은 모든 면에서 낭비입니다: 주소 공간을 낭비하고, 사용자를 긴 초기 로딩 시간에 가둬두며, 32비트 델파이 프로세스에서는 단 한 페이지가 나타나기도 전에 실패할 수 있습니다. PDFium은 이를 염두에 두고 설계되었습니다. 필요한 특정 바이트 범위만 필요할 때 요청하는 콜백을 통해 문서를 로드할 수 있으며, 전체 파일을 한 번에 요구하지 않습니다. 이 스트리밍 채널은 32비트 길이로 파일을 설명하므로, 실제 대부분의 스캔된 아카이브를 포괄하는 최대 4 GiB의 단일 파일을 서비스할 수 있습니다. 그 제한을 넘어서는 파일은 이 기사의 영역이 아닙니다; 스캔 시 분할되거나 다른 직접 액세스 전략을 통해 열려야 하며, 천장을 강제하는 방어 로직에 대해서는 아래에 별도의 섹션으로 다루겠습니다

컴포넌트는 스트림 어댑터를 통해 그 경로를 노출합니다. 어떠한 TStream이라도 건네주면, PDFium은 필요에 따라 해당 스트림에서 블록을 가져옵니다. 파일은 디스크, 데이터베이스의 blob 필드, 또는 다른 TStream 파생 클래스 뒤에 있을 수 있으며, 사전에 어떠한 것도 메모리로 복사되지 않습니다

PDFium이 바이트를 요청하는 방법

PDFium의 C API는 FPDF_FILEACCESS 구조체로 설명되는 호출자 제공 객체에서 문서를 로드합니다. 이 구조체에는 여기서 중요한 세 부분이 있습니다: 길이 필드, 읽기 콜백, 그리고 불투명(opaque) 사용자 매개변수입니다. 이를 소비하는 진입점은 FPDF_LoadCustomDocument입니다. PDFium이 이 구조체를 보유하면 트레일러를 구문 분석하고 교차 참조(cross-reference) 테이블을 찾은 다음, 그 이후로는 특정 작업에 필요한 것만 읽습니다. 문서를 열 때 파일의 꼬리 부분과 소수의 카탈로그 객체를 건드립니다. 400페이지를 렌더링할 때는 해당 페이지의 콘텐츠 스트림과 리소스만 읽고 다른 것은 읽지 않습니다

이것이 버퍼링된 로드와 스트리밍 로드의 차이점입니다. 버퍼링된 로드는 PDFium이 0번 바이트를 보기 전에 파일을 끝에서 끝까지 읽습니다. 스트리밍 로드는 이 관계를 역전시킵니다: PDFium이 읽기를 주도하며, 건드리지 않는 바이트는 절대 읽지 않습니다. 한 번에 한 페이지씩 보는 수 기가바이트 파일의 경우, 이것은 사용할 수 없는 로딩과 즉각적인 로딩 사이의 차이입니다

스트림 어댑터

델파이의 TStreamFPDF_FILEACCESS에 연결하는 어댑터는 TPdfStreamAdapter입니다. 이 생성자는 스트림과 소유권 플래그를 가져오고, 스트림 길이를 한 번 캡처한 다음, FPDF_FILEACCESS 레코드를 채우고 읽기 콜백을 연결합니다. PDFium이 나중에 오프셋과 크기로 콜백을 호출하면 어댑터는 스트림을 해당 오프셋으로 이동시키고 PDFium이 제공한 버퍼에 정확히 그 범위를 복사합니다

// 컴포넌트에서 그대로 가져옴: 스트림에서 FPDF_FILEACCESS로의 브릿지
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen은 32비트 unsigned long입니다.
  // 4 GiB를 넘어서 조용히 잘리는 스트림은 거부합니다.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

소유권 플래그는 스트림을 누가 해제할지 결정합니다. False를 전달하면 호출자가 스트림을 유지하며 문서가 살아있는 동안 계속 유지해야 합니다. True를 전달하면 어댑터가 인계받아 문서가 닫힐 때 스트림을 해제합니다. 어느 쪽이든 스트림은 PDFium이 수행할 모든 읽기 작업보다 오래 살아남아야 합니다. 왜냐하면 PDFium이 FPDF_FILEACCESS 포인터를 유지하고 있으며 초기 로드 중뿐만 아니라 문서가 열려 있는 동안 언제든지 콜백을 호출할 것이기 때문입니다

콜백이 정적 함수(static function)인 이유

PDFium이 m_GetBlock에 저장하는 읽기 콜백은 cdecl 호출 규칙을 따르는 평범한 C 함수 포인터입니다. 델파이 메서드는 직접 사용할 수 없습니다. 왜냐하면 메서드는 C 호출자가 전혀 알지 못하고 절대 제공하지 않을 숨겨진 Self 인수를 전달하기 때문입니다. 따라서 어댑터는 콜백을 cdecl; static으로 표시된 class function으로 선언하며, 이는 PDFium이 기대하는 C 프레임 레이아웃을 갖고 암묵적인 Self가 없는 독립형 함수로 컴파일됩니다

이것은 호출 규칙 문제는 해결하지만 두 번째 질문을 제기합니다: Self가 없으면 콜백이 어떻게 자신이 읽어야 할 특정 스트림에 도달할까요? 해답은 불투명한 사용자 매개변수입니다. 어댑터가 레코드를 작성할 때 m_Param에 자신의 인스턴스 포인터를 저장합니다. PDFium은 모든 콜백의 첫 번째 인수로 그 동일한 포인터를 다시 넘겨줍니다. 정적 함수는 이를 TPdfStreamAdapter로 다시 캐스팅하고 해당 인스턴스의 스트림에 대해 읽기 작업을 파견합니다. 이것은 객체의 개념이 없는 C 경계를 넘어 객체 컨텍스트를 전달하기 위한 표준 트램펄린(trampoline)입니다

// 컴포넌트에서 그대로 가져옴: 인스턴스로 돌아가는 cdecl 트램펄린
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // m_Param에서 인스턴스 복구
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // 실패를 반환 값으로 보고하고 절대로 예외를 발생시키지 않음
  end;
end;

4 GiB 천장과 가드(guard)가 필요한 이유

시작 부분에서 언급한 경계가 바로 여기에서 비롯됩니다. FPDF_FILEACCESS의 길이 필드인 m_FileLen은 32비트 부호 없는(unsigned) 값입니다. 이 필드가 표현할 수 있는 최대 길이는 4 GiB에서 1바이트가 모자랍니다. TStream은 크기를 Int64로 보고하므로, 스트림은 이 필드가 담을 수 있는 것보다 훨씬 더 많은 바이트를 설명할 수 있습니다. 스트림의 크기가 그 천장을 초과하는 순간, PDFium에게 파일 길이를 정직하게 알릴 방법이 없습니다

잘못된 대응은 크기를 할당하고 랩(wrap)되도록 내버려두는 것입니다. 5 GiB 길이를 32비트 필드에 맞게 자르면 작고 그럴싸해 보이는 숫자가 생성되며, 그러면 PDFium은 파일이 대략 1기가바이트 지점에서 끝난다고 믿고 구문 분석할 것입니다. 트레일러와 교차 참조(cross-reference) 테이블은 잘려나간 길이를 한참 지나 파일의 실제 끝에 위치하므로, 구문 분석은 실제 원인과 전혀 관련 없는 방식으로 실패합니다. 정수가 두 계층 위에서 랩되었다는 힌트 없이, 완전히 유효한 파일에서 교차 참조 오류를 디버깅하게 될 것입니다

대신 어댑터는 입력을 거부합니다. 생성자는 스트림 크기를 High(FPDF_DWORD)와 비교하고 스트림이 설명하기에 너무 큰 순간 EPdfError를 발생시킵니다. 명시적이고 즉각적인 오류는 생성 지점에서 진짜 문제를 지적합니다. 조용히 자르는 것은 나중에 한참을 추적하게 될 오해의 소지가 있는 증상 뒤에 문제를 숨깁니다. 4 GiB 제한은 이 로딩 경로의 진정한 제약 조건이며, 우연히 컴파일되는 산술 연산으로 덮어두는 것보다 큰 소리로 드러내는 것이 정직한 방법입니다. 아카이브가 진정으로 선을 넘을 때, 서두에서 약속한 구제책은 이 API 밖에 있습니다: 스캔을 천장 아래에 머무는 여러 볼륨 파일로 분할하거나 문서를 디스크에 두고 FPDF_FILEACCESS가 아닌 64비트 오프셋을 기반으로 구축된 직접 액세스 설계로 서비스해야 합니다

실패가 경계를 넘어서는 안 됩니다

읽기 작업은 실패할 수 있습니다. 스트림은 시간 초과가 발생하는 네트워크 백업 객체이거나, 기반에서 닫혀버린 blob 핸들, 또는 문서가 열린 후 잘려나간 파일일 수 있습니다. 읽기 콜백에 대한 PDFium의 규약은 반환 값입니다: 성공하면 0이 아닌 값, 실패하면 0입니다. 이것은 C 프레임이며, 파스칼 예외를 잡거나 전파하는 메커니즘이 없습니다

이것이 트램펄린이 seek와 read 작업을 try/except로 감싸서 예외를 삼키고 0을 반환하는 이유입니다. 델파이 예외가 콜백 외부로 전파되도록 허용된다면, 이는 파스칼 예외 메커니즘에 의해 해제되도록 설계된 적이 없는 PDFium의 cdecl 스택 프레임을 거쳐 풀릴 것입니다. 그 결과 최선의 경우 정의되지 않은 동작, 최악의 경우 사용할 수 있는 스택 없이 PDF 파서 깊은 곳에서 심각한 충돌이 발생합니다. 0을 반환하면 실패가 규약 내에 유지됩니다. PDFium은 실패한 블록 읽기를 확인하고 작업을 깔끔하게 중단하며, FPDF_LoadCustomDocument는 문서를 로드할 수 없다고 보고하고, 컴포넌트는 이를 파스칼 측에 마땅히 있어야 할 EPdfError로 표면화합니다

이 방식으로 문서를 열기

스트리밍 경로를 구동하는 컴포넌트 메서드는 LoadCustomDocument입니다. 이것은 또 다른 LoadDocument 오버로드가 아닌 고유한 메서드로 선언되어 TMemoryStream을 전달하더라도 실수로 버퍼링된 경로로 떨어지지 않도록 합니다. 이 메서드는 어댑터를 만들고, FPDF_LoadCustomDocument를 호출하며, 로드된 문서의 전체 수명 동안 어댑터를 살려둡니다

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // 스트림 소유권을 Pdf에 넘깁니다: 문서가 닫힐 때 FileStream을 해제합니다.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium은 지금까지 트레일러와 카탈로그만 읽었습니다.
    // 페이지를 렌더링하면 콜백을 통해 딱 그 페이지의 바이트만 가져옵니다.
    // ... 여기서 페이지를 렌더링하거나 검사합니다 ...
  finally
    Pdf.Free;  // 문서를 닫으며, 이는 어댑터와 스트림을 해제합니다.
  end;
end;

동일한 호출이 TMemoryStream, 데이터베이스 데이터셋의 blob 스트림 또는 사용자 정의 TStream 파생 클래스에 대해서도 동작합니다. 온디맨드 로딩은 파일이 크고 그 중 일부만 읽을 때 그 진가를 발휘합니다: 아카이브 뷰어, 몇 페이지를 샘플링하는 썸네일 생성기, 한 번에 한 페이지만 가져오는 검색 인덱스 등입니다. 파일이 작거나 어차피 모두 읽을 예정이라면 버퍼링된 로드가 더 간단하고 스트리밍 장치가 주는 이점이 없습니다. 결정 요인은 파일이 포함한 바이트 대비 실제로 건드릴 바이트의 비율입니다

온디맨드 방식으로 페이지가 스트리밍되면, 다음 관심사는 사용자가 확대/축소 및 스크롤할 때 렌더링된 페이지를 반응성 있게 유지하는 것인데, 이는 렌더링 캐싱 및 줌 성능에 대한 글에서 다루고 있습니다. 스트리밍된 문서가 뷰어에 표시되긴 하지만 사용자가 내보내거나 변경해서는 안 되는 경우, 보안 PDF 미리보기 연습의 기법이 이 로딩 경로와 자연스럽게 어울립니다. 두 기능 모두 여기서 설명한 스트리밍 로드 기반 위에 구축되며, 다른 블로그 게시물에서 다루는 렌더링, 텍스트 추출 및 주석 API와 함께 Delphi 및 C++Builder용 PDFium Component의 일부로 제공됩니다