Technical Article

Progressive PDF Range Loading in Delphi with PDFlibPas

A 2 GB scanned archive lives in an S3 bucket and the user wants page 900. PDFlibPas can serve that page without downloading the file: LoadFromRangeSource builds a read-only seekable stream over your own byte-range callback and hands it to TPDFDocument, so the parser pulls the cross-reference tables, one page tree branch, and one content stream

The transport side of this is old and boring. HTTP servers have advertised byte ranges for decades, now specified in RFC 9110 §14, and every object store speaks the same dialect. The PDF side is equally settled: ISO 32000-1 §7.5.8 defines linearization precisely so a reader can render the first page from the front of the file. What has been missing in Delphi is the piece in the middle, the part that decides which ranges to ask for, how many to keep, and how to avoid asking twice

What does LoadFromRangeSource need from your transport?

Two things, and neither of them is a stream. PDFlibPas asks for an authoritative SourceSize and a synchronous read callback of type TPDFlibRangeReadEvent, declared as function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internally the pair becomes a TCallbackByteRangeSource exposing SourceSize and ReadRange, wrapped in a stream whose ownership passes to the document. Your callback target and its backend stay yours: the document frees the wrapper on close, clear, or reload, but it never touches the transport object behind the method pointer

The contract is deliberately forgiving in one direction and strict in the other. A short read is legal and simply means the parser asks again. A callback that raises is converted into a short read and converges through the normal load-failure path. A callback that claims to have written more than Count bytes is clamped, because a buggy provider must not be able to overrun the cache buffer. Password retries rebuild a fresh range stream and a fresh parse state over the same callback source, so a failed attempt cannot leave stale position, window, or decryption state behind

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
  { one blocking GET with Range: bytes=Offset-(Offset+Count-1) }
  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;  { frees the wrapper stream }
  Src.Free;  { your transport, your lifetime }
end;

How much does the range cache actually hold?

By default 4 MiB, spread over chunk-aligned windows and evicted LRU. The earlier single-window design grew to whatever length the caller asked for, so one large sequential read could blow past the nominal chunk size while a random jump threw the previous window away immediately. The current cache aligns every source offset to ChunkSize, fetches exactly one chunk per miss, and enforces a hard byte budget across several windows. Any explicit budget you pass is raised to at least one full chunk, so a single read always advances chunk by chunk and peak cache load stays predictable. A ChunkSize below 4096 falls back to the 64 KiB default

How PDFlibPas serves a parser read in Delphi without downloading the PDF: the absolute offset is aligned down to the chunk size, served from one of several LRU windows on a hit, or turned into a single clamped callback call on a miss
Every source offset is aligned to the chunk size, so one miss fetches exactly one chunk and the peak cache load stays predictable

Repeat-read accounting is the part worth wiring into your telemetry. PDFlibPas identifies a repeat by aligned chunk start and keeps ordered contiguous intervals, which separates a genuine first fetch from a refetch after eviction while keeping the bookkeeping from growing linearly with file size. GetRangeSourceCacheInfo returns the whole picture as JSON, SetRangeSourceCacheLimit resizes the budget at runtime, and ClearRangeSourceCache drops the windows and resets the statistics together. Shrinking the budget at runtime keeps the history and counts the budget-driven releases as evictions, so a rising repeatedReads against a flat hits is your signal that the working set no longer fits

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;

What happens when several threads want the same chunk?

They wait on one request, not several. A classic TStream has a single position cursor, and two threads that each lock correctly can still have that position rewritten between a Seek and a Read, so lazy objects and segmented reads in PDFlibPas use an absolute ReadAt that never moves the cursor. Each aligned chunk gets a single in-flight request that every caller for that chunk shares, adjacent queued chunks are merged before the source read starts, and one physical read is capped at 16 MiB, so a burst of parallel page work amplifies into neither duplicate small requests nor one absurd large one. The merge window defaults to 2 ms and applies only to the first missing chunk of each ReadAt; positional Read never waits for it, and passing zero removes the initial collection delay entirely, which matters for long sequential scans that would otherwise accumulate the wait chunk by chunk. Position, cache metadata, and source reads sit behind three separate locks, and the source callback itself is serialised, which is what lets a database or object-store adapter with no internal thread protection be used unchanged. Waiters receive their own copy of the data, so a later LRU eviction cannot invalidate a buffer that has already been handed out

Request coalescing in PDFlibPas range loading for Delphi: two threads asking for the same chunk share one in-flight request, adjacent queued chunks are merged inside a two millisecond window, and one serialised source read serves all of them
A burst of parallel page work collapses into one shared request per chunk, and every waiter still receives its own copy of the bytes

Can you ask whether page 900 is ready without fetching it?

Yes, and that is exactly what the optional availability callback is for. A plain read callback cannot distinguish bytes that already landed from bytes that require a blocking round trip, and probing with a trial read would trigger the very download you are trying to avoid. TPDFlibRangeAvailabilityEvent answers one question only, whether a complete range can be read immediately, and is forbidden from fetching anything; bytes the cache already covers always count as available. GetRangeSourceDataAvailability maps indirect objects to the physical storage ranges recorded in the cross-reference entries, resolves compressed objects to their object stream container, corrects for a shifted PDF header, and parses an object only after the full range passes the non-fetching probe, so the missing path never calls your read callback

The traversal is scoped rather than exhaustive. A page query walks only the branch of the page tree containing the target page and then adds page content, resources, annotations, and inherited page attributes, skipping Parent and P back-edges so a single page or widget cannot expand backwards into the whole document. The object graph is bounded at 100000 requested objects and a depth of 256, stream objects are parsed dictionary-first, and a full-parse fallback is allowed only for stored objects up to 4 MiB. The JSON report merges overlapping and adjacent intervals before counting, so requiredBytes and missingBytes are computed from the merged requiredRanges and missingRanges arrays, whose end is an inclusive endpoint. Querying an object that is already available may populate the range cache; querying a missing one leaves the read statistics untouched

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 carries "missingBytes" plus the merged "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { e.g. the file has no AcroForm at all }
end;

Why prefetch has to iterate

Because reading the current missingRanges once does not make the page available. A missing page tree node or object stream only reveals the next layer of dependencies after it arrives, so a PDFlibPas prefetch job runs a query, fetch, requery loop until the page, form, or object graph is completely available or a byte or pass limit stops it. The job uses its own reader and a small secondary cache whose data source forwards absolute reads to the original range stream, which keeps parse state isolated from the foreground TSmartPDFReader while the bytes it genuinely downloads still land in the shared main cache. One worker thread exists per range stream, matching the serialisation the source callback already requires, and the queue picks by four priority levels and then by submission order within a level. MaxBytes is charged in physical chunk bytes, so a parser that asks for a single byte inside an uncached chunk still pays for the whole chunk, while chunks already in the shared cache cost the job nothing. Cancelling a queued job reaches a terminal state with zero source reads; a running job is checked before each dependency pass and each source chunk, and freeing the range stream waits for an in-flight callback to return rather than trying to interrupt it

The PDFlibPas prefetch loop in Delphi: a job queries availability, fetches the missing ranges and queries again, because each arriving page tree node or object stream reveals the next layer of dependencies, until the graph completes or a limit stops it
A prefetch job iterates because a missing node only names its own children once it arrives, and it charges every pass in whole physical chunks
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" and the last
    full availability report, so LIMIT_REACHED stays distinguishable
    from FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Where this degrades into a whole-file download

Range loading is a bet on file layout, and some files do not honour it. A linearized file per ISO 32000-1 §7.5.8 is the good case: the first-page section is warmed on open, bounded by both the existing 4 MiB safety threshold and the current cache budget so the warm-up cannot immediately evict most of itself. A non-linearized file still resolves through the trailer and the cross-reference chain near the end, which costs a couple of extra round trips rather than a disaster. The real cliff is a damaged file that forces the repair path, because reconstructing a cross-reference table means scanning for object headers across the whole document, and that is a full download arriving one chunk at a time. Latency is the other honest limit: at 60 ms per request, a random-access parse needing forty uncached chunks spends over two seconds in transit no matter how good the cache is, which is precisely what the read-ahead argument and the priority queue exist to hide. The same discipline shows up in the direct-access approach to merging and splitting large PDFs, and this cache sits underneath parallel page rendering and the viewer disk page cache alike

The range source API, the availability query, and the prefetch scheduler are part of the standard PDFlibPas Delphi PDF Library for Delphi, C++Builder, and Free Pascal; the product page carries the full parameter reference for LoadFromRangeSource along with the prefetch priority and state constants