기술 문서

PDFlibPas로 하는 델파이 점진적 PDF 범위 로딩

2 GB 스캔 아카이브가 S3 버킷에 살고 사용자는 900페이지를 원합니다. PDFlibPas는 파일을 다운로드하지 않고 그 페이지를 서비스할 수 있습니다. LoadFromRangeSource가 여러분의 바이트 범위 콜백 위에 읽기 전용 시크 가능 스트림을 세워 TPDFDocument로 넘기므로, 파서는 교차 참조 테이블과 페이지 트리 한 가지, 콘텐츠 스트림 하나만 끌어당깁니다

전송 쪽은 오래되고 지루합니다. HTTP 서버는 수십 년간 바이트 범위를 광고해 왔고 지금은 RFC 9110 §14로 규격화되며, 모든 오브젝트 스토어가 같은 방언을 씁니다. PDF 쪽도 똑같이 정착됐습니다. ISO 32000-1 §7.5.8은 선형화를 정확히 정의해서 리더가 파일 앞부분에서 첫 페이지를 렌더링할 수 있게 합니다. 델파이에서 빠져 있던 것은 가운데 조각입니다. 어떤 범위를 요청할지, 몇 개를 유지할지, 두 번 묻는 것을 어떻게 피할지 결정하는 부분입니다

LoadFromRangeSource가 전송 계층에 요구하는 것은 무엇인가

두 가지이고, 어느 쪽도 스트림이 아닙니다. PDFlibPas는 권위 있는 SourceSizefunction(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object로 선언되는 동기 읽기 콜백 TPDFlibRangeReadEvent를 요구합니다. 내부적으로 이 쌍은 SourceSizeReadRange를 노출하는 TCallbackByteRangeSource가 되고, 소유권이 문서로 넘어가는 스트림에 싸입니다. 콜백 대상과 그 백엔드는 여러분 것으로 남습니다. 문서는 닫기, 지우기, 재로드에서 래퍼를 해제하지만, 메서드 포인터 뒤의 전송 객체는 절대 만지지 않습니다

계약은 한 방향으로는 일부러 관대하고 다른 방향으로는 엄격합니다. 짧은 읽기는 합법이며 그저 파서가 다시 묻는다는 뜻입니다. 예외를 던지는 콜백은 짧은 읽기로 변환되어 정상적인 로드 실패 경로로 수렴합니다. Count보다 많이 썼다고 주장하는 콜백은 클램프됩니다. 버그 있는 제공자가 캐시 버퍼를 넘칠 수 없어야 하기 때문입니다. 암호 재시도는 같은 콜백 소스 위에 새 범위 스트림과 새 파스 상태를 다시 세우므로, 실패한 시도가 낡은 위치, 창, 복호화 상태를 남길 수 없습니다

type
  TObjectStoreSource = class
  private
    FClient: TRangeHttpClient;
    FSize: Int64;
  public
    function ReadRange(Sender: TObject; Offset: Int64;
      Buffer: Pointer; Count: LongInt): LongInt;
    function IsResident(Sender: TObject; Offset: Int64;
      Count: LongInt): Integer;
    property Size: Int64 read FSize;
  end;

function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
  Buffer: Pointer; Count: LongInt): LongInt;
begin
  { Range: bytes=Offset-(Offset+Count-1)로 블로킹 GET 한 번 }
  Result := FClient.FetchInto(Offset, Count, Buffer);
end;

{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
  if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
       65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
    Lib.SelectPage(900);
finally
  Lib.Free;  { 래퍼 스트림을 해제한다 }
  Src.Free;  { 전송 계층은 여러분 것, 수명도 여러분 것 }
end;

범위 캐시는 실제로 얼마나 담는가

기본값은 4 MiB이고 청크 정렬 창에 흩어져 LRU로 추방됩니다. 이전의 단일 창 설계는 호출자가 요구한 길이만큼 자라서, 한 번의 큰 순차 읽기가 명목 청크 크기를 넘을 수 있었고 무작위 점프는 이전 창을 즉시 버렸습니다. 현재 캐시는 모든 소스 오프셋을 ChunkSize에 정렬하고, 미스마다 정확히 한 청크를 가져오며, 여러 창에 걸쳐 하드 바이트 예산을 집행합니다. 여러분이 넘기는 명시적 예산은 최소 한 청크 분량으로 올려지므로, 단일 읽기는 항상 청크 단위로 전진하고 피크 캐시 적재는 예측 가능하게 유지됩니다. 4096 미만의 ChunkSize는 64 KiB 기본값으로 폴백합니다

PDFlibPas가 PDF를 다운로드하지 않고 델파이에서 파서 읽기를 서비스하는 방법: 절대 오프셋은 청크 크기로 내림 정렬되고, 히트면 여러 LRU 창 중 하나에서 서비스되거나, 미스면 클램프된 콜백 호출 한 번으로 바뀐다
모든 소스 오프셋이 청크 크기로 정렬되므로, 미스 한 번이 정확히 한 청크를 가져오고 피크 캐시 적재는 예측 가능하게 유지됩니다

반복 읽기 회계는 텔레메트리에 연결할 가치가 있는 부분입니다. PDFlibPas는 정렬된 청크 시작으로 반복을 식별하고 정렬된 연속 구간을 유지하므로, 진짜 첫 가져오기와 추방 뒤의 재가져오기를 갈라내면서도 장부가 파일 크기에 비례해 자라지 않습니다. GetRangeSourceCacheInfo가 JSON으로 전체 그림을 반환하고, SetRangeSourceCacheLimit가 실행 중 예산을 조정하며, ClearRangeSourceCache가 창과 통계를 함께 떨어뜨립니다. 실행 중 예산을 줄이면 이력은 유지되고 예산 주도 방출은 추방으로 헤아리므로, 평평한 hits에 맞서 오르는 repeatedReads가 작업 집합이 더 이상 안 맞는다는 신호입니다

var
  Info: WideString;
begin
  Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
  Lib.SelectPage(900);
  if Lib.GetRangeSourceCacheInfo(Info) = 1 then
    { "windowCount", "cacheLimitBytes", "cachedBytes", "hits", "misses",
      "evictions", "sourceReads", "sourceBytes", "repeatedReads",
      "coalescedRequests", "coalescedSourceReads" }
    LogRangeStats(Info);
end;

여러 스레드가 같은 청크를 원하면 무슨 일이 벌어지는가

여러 요청이 아니라 하나의 요청에 기다립니다. 고전 TStream은 위치 커서가 하나라서, 각자 올바르게 잠그는 두 스레드도 SeekRead 사이에 그 위치가 다시 쓰일 수 있습니다. 그래서 PDFlibPas의 지연 객체와 분할 읽기는 커서를 움직이지 않는 절대 ReadAt을 씁니다. 정렬된 청크마다 그 청크의 모든 호출자가 공유하는 단일 진행 중 요청이 붙고, 인접 대기 청크는 소스 읽기가 시작되기 전에 병합되며, 한 물리 읽기는 16 MiB로 갇힙니다. 병렬 페이지 작업의 폭주가 중복 소형 요청도, 터무니없는 대형 요청 하나도 낳지 않습니다. 병합 창은 기본 2 ms이고 각 ReadAt의 첫 미스 청크에만 적용됩니다. 위치 기반 Read는 결코 기다리지 않고, 0을 넘기면 초기 수집 지연이 완전히 사라지는데, 청크마다 대기를 쌓아가게 될 긴 순차 스캔에서 중요합니다. 위치, 캐시 메타데이터, 소스 읽기는 세 개의 별도 잠금 뒤에 있고, 소스 콜백 자체는 직렬화되는데, 내부 스레드 보호가 없는 데이터베이스나 오브젝트 스토어 어댑터를 그대로 쓸 수 있는 이유가 그것입니다. 대기자는 데이터 자기 사본을 받으므로, 이후 LRU 추방이 이미 건네진 버퍼를 무효화할 수 없습니다

델파이 PDFlibPas 범위 로딩의 요청 병합: 같은 청크를 요구하는 두 스레드가 하나의 진행 중 요청을 공유하고, 인접 대기 청크는 2밀리초 창 안에서 병합되며, 하나의 직렬화된 소스 읽기가 모두를 서비스한다
병렬 페이지 작업의 폭주는 청크당 하나의 공유 요청으로 붕괴되고, 모든 대기자는 여전히 바이트의 자기 사본을 받습니다

가져오지 않고 900페이지가 준비됐는지 물을 수 있는가

됐고, 선택적 가용성 콜백이 정확히 그 용도입니다. 평범한 읽기 콜백은 이미 도착한 바이트와 블로킹 왕복이 필요한 바이트를 구분하지 못하고, 시험 읽기로 염탐하면 피하려던 바로 그 다운로드를 촉발합니다. TPDFlibRangeAvailabilityEvent는 한 가지 질문에만 답합니다. 완전한 범위를 즉시 읽을 수 있는가. 그리고 무엇이든 가져오는 것이 금지됩니다. 캐시가 이미 덮은 바이트는 언제나 가용으로 칩니다. GetRangeSourceDataAvailability는 간접 객체를 교차 참조 항목에 기록된 물리 저장 범위로 매핑하고, 압축 객체를 그 객체 스트림 컨테이너로 해석하며, 밀린 PDF 헤더를 보정하고, 전체 범위가 무가져오기 탐침을 통과한 뒤에야 객체를 파싱합니다. 그래서 누락 경로는 절대 읽기 콜백을 부르지 않습니다

순회는 전수가 아니라 범위가 정해집니다. 페이지 질의는 대상 페이지를 담은 페이지 트리 가지만 걷고, 그다음 페이지 콘텐츠, 리소스, 어노테이션, 상속된 페이지 속성을 더하되, ParentP 백엣지를 건너뛰어 단일 페이지나 위젯이 문서 전체로 뒤로 퍼지지 않게 합니다. 객체 그래프는 요청 객체 100000개와 깊이 256으로 갇히고, 스트림 객체는 사전 우선으로 파싱되며, 전체 파스 폴백은 4 MiB 이하의 저장 객체에만 허용됩니다. JSON 보고는 세기 전에 겹치는 구간과 인접 구간을 병합하므로, requiredBytesmissingBytes는 병합된 requiredRangesmissingRanges 배열에서 계산되고, 그 end는 포함 끝점입니다. 이미 가용한 객체를 질의하면 범위 캐시가 채워질 수 있습니다. 누락된 객체를 질의하면 읽기 통계가 손대지 않습니다

var
  Report: WideString;
  Status: Integer;
begin
  Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
    Report);
  if Status = PDF_RANGE_DATA_AVAILABLE then
    RenderPageNow
  else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
    { Report는 "missingBytes"와 병합된 "missingRanges"를 실어 나른다 }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { 예: 파일에 AcroForm이 아예 없는 경우 }
end;

프리페치가 반복해야 하는 이유

현재 missingRanges를 한 번 읽는 것으로 페이지가 가용해지지 않기 때문입니다. 누락된 페이지 트리 노드나 객체 스트림은 도착한 뒤에야 다음 의존성 층을 드러냅니다. 그래서 PDFlibPas 프리페치 작업은 페이지, 폼, 객체 그래프가 완전히 가용해지거나 바이트 또는 패스 한계가 멈출 때까지 질의-가져오기-재질의 루프를 돕니다. 작업은 자기 리더와 작은 보조 캐시를 쓰는데, 그 데이터 소스는 절대 읽기를 원본 범위 스트림으로 전달하므로, 파스 상태는 포그라운드 TSmartPDFReader와 격리되면서 정말로 다운로드한 바이트는 공유 메인 캐시에 안착합니다. 범위 스트림마다 작업자 스레드 하나가 존재하며, 이는 소스 콜백이 이미 요구하는 직렬화와 맞고, 큐는 4개 우선순위 수준으로 골라 수준 내에서는 제출 순서로 고릅니다. MaxBytes는 물리 청크 바이트로 청구되므로, 캐시되지 않은 청크 안의 바이트 하나를 요구한 파서도 청크 전체를 지불하고, 공유 캐시에 이미 있는 청크는 작업에 아무 비용도 없습니다. 대기 중 작업의 취소는 소스 읽기 0회로 종단 상태에 닿습니다. 실행 중 작업은 의존성 패스마다와 소스 청크마다 검사되고, 범위 스트림 해제는 실행 중 콜백을 끊으려 하는 대신 돌아오기를 기다립니다

델파이 PDFlibPas 프리페치 루프: 작업은 가용성을 질의하고, 누락 범위를 가져오고, 다시 질의한다. 도착하는 페이지 트리 노드나 객체 스트림마다 다음 의존성 층이 드러나기 때문이다. 그래프가 완성되거나 한계가 멈출 때까지
프리페치 작업이 반복하는 이유는 누락된 노드가 자기 자식의 이름을 도착해야 밝히기 때문이고, 모든 패스를 물리 청크 단위로 청구합니다
var
  Job: Integer;
  Info: WideString;
begin
  Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
    PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
  if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
       PDF_RANGE_PREFETCH_STATE_COMPLETED then
    PrepareNextPage
  else
    Lib.CancelRangeSourcePrefetch(Job);
  { "passes", "plannedRanges", "sourceReads", "fetchedBytes"와 마지막
    전체 가용성 보고. 그래서 LIMIT_REACHED가 FAILED와 구분된다 }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

이것이 파일 전체 다운로드로 퇴화하는 지점

범위 로딩은 파일 배치에 대한 내기이고, 어떤 파일은 그것을 지키지 않습니다. ISO 32000-1 §7.5.8의 선형화 파일이 좋은 사례입니다. 첫 페이지 섹션이 열 때 데워지는데, 기존 4 MiB 안전 임계와 현재 캐시 예산 둘 다로 갇혀서 웜업이 스스로 대부분을 즉시 추방할 수 없습니다. 비선형화 파일도 끝부의 트레일러와 교차 참조 체인으로 해석되는데, 재앙이 아니라 왕복 몇 번의 비용입니다. 진짜 절벽은 수리 경로를 강제하는 손상된 파일입니다. 교차 참조 테이블 재구성은 문서 전체에서 객체 헤더를 훑는 일이고, 그것은 청크 단위로 도착하는 전체 다운로드입니다. 지연이 다른 정직한 한계입니다. 요청당 60 ms에서 캐시되지 않은 청크 마흔 개를 필요로 하는 무작위 접근 파스는 캐시가 아무리 좋아도 전송에서 2초를 넘게 씁니다. 읽기 먼저 하기 논거와 우선순위 큐가 정확히 그것을 가리려고 존재합니다. 같은 규율이 대형 PDF 병합과 분할의 직접 접근 방식에서도 나타나며, 이 캐시는 병렬 페이지 렌더링뷰어 디스크 페이지 캐시 아래 똑같이 자리합니다

범위 소스 API, 가용성 질의, 프리페치 스케줄러는 Delphi, C++Builder, Free Pascal용 표준 PDFlibPas Delphi PDF Library의 일부입니다. 제품 페이지가 프리페치 우선순위와 상태 상수와 함께 LoadFromRangeSource의 전체 매개변수 참조를 실어 나릅니다