Artikel Teknis

Pemuatan Rentang PDF Progresif di Delphi dengan PDFlibPas

Arsip hasil pindai 2 GB tersimpan di bucket S3 dan pengguna menginginkan halaman 900. PDFlibPas dapat menyajikan halaman itu tanpa mengunduh file: LoadFromRangeSource membangun stream seekable read-only di atas callback byte-range milik Anda sendiri lalu menyerahkannya ke TPDFDocument, sehingga parser menarik tabel referensi silang, satu cabang page tree, dan satu content stream

Sisi transportnya sudah tua dan membosankan. Server HTTP mengiklankan byte range selama beberapa dekade, kini dispesifikasikan di RFC 9110 §14, dan setiap object store berbicara dalam dialek yang sama. Sisi PDF-nya sama tenangnya: ISO 32000-1 §7.5.8 mendefinisikan linearisasi persis agar reader bisa merender halaman pertama dari bagian depan file. Yang selama ini hilang di Delphi adalah potongan di tengah, bagian yang memutuskan rentang mana yang diminta, berapa yang disimpan, dan bagaimana menghindari meminta dua kali

Apa yang dibutuhkan LoadFromRangeSource dari transport Anda?

Dua hal, dan tidak satu pun adalah stream. PDFlibPas meminta SourceSize yang otoritatif dan callback baca sinkron bertipe TPDFlibRangeReadEvent, dideklarasikan sebagai function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Secara internal pasangan itu menjadi TCallbackByteRangeSource yang mengekspos SourceSize dan ReadRange, dibungkus dalam stream yang kepemilikannya berpindah ke dokumen. Target callback dan backend-nya tetap milik Anda: dokumen membebaskan wrapper saat close, clear, atau reload, tetapi tidak pernah menyentuh objek transport di balik method pointer itu

Kontraknya sengaja bersikap pemaaf di satu arah dan ketat di arah lain. Baca pendek itu legal dan hanya berarti parser meminta lagi. Callback yang melempar exception dikonversi menjadi baca pendek dan berkonvergensi lewat jalur kegagalan load yang normal. Callback yang mengklaim menulis lebih dari Count byte dijepit, karena provider yang bermasalah tidak boleh bisa meluberkan buffer cache. Percobaan ulang kata sandi membangun ulang range stream baru dan parse state baru di atas sumber callback yang sama, sehingga percobaan yang gagal tak bisa meninggalkan posisi, window, atau state dekripsi yang basi

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
  { satu GET blocking dengan 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;  { membebaskan stream wrapper }
  Src.Free;  { transport Anda, masa hidup Anda }
end;

Seberapa banyak yang benar-benar dipegang cache rentang?

Secara default 4 MiB, tersebar di atas window yang selaras chunk dan diusir dengan LRU. Desain single-window sebelumnya tumbuh sepanjang apa pun yang diminta pemanggil, sehingga satu baca sekuensial besar bisa melewati ukuran chunk nominal sementara lompatan acak langsung membuang window sebelumnya. Cache saat ini menyelaraskan setiap offset sumber ke ChunkSize, mengambil tepat satu chunk per miss, dan menegakkan anggaran byte yang keras di beberapa window. Anggaran eksplisit yang Anda berikan dinaikkan minimal ke satu chunk penuh, sehingga satu baca selalu maju chunk demi chunk dan beban puncak cache tetap terprediksi. ChunkSize di bawah 4096 mundur ke default 64 KiB

Cara PDFlibPas melayani baca parser di Delphi tanpa mengunduh PDF: offset absolut diselaraskan ke bawah ke ukuran chunk, disajikan dari salah satu window LRU saat hit, atau diubah menjadi satu panggilan callback yang dijepit saat miss
Setiap offset sumber diselaraskan ke ukuran chunk, sehingga satu miss mengambil tepat satu chunk dan beban puncak cache tetap terprediksi

Pembukuan baca-ulang adalah bagian yang layak disambungkan ke telemetri Anda. PDFlibPas mengenali pengulangan lewat awal chunk yang selaras dan menyimpan interval kontinu yang terurut, yang memisahkan pengambilan pertama yang sesungguhnya dari refetch setelah eviction sambil menjaga pembukuan tetap tidak tumbuh linear terhadap ukuran file. GetRangeSourceCacheInfo mengembalikan gambaran utuh sebagai JSON, SetRangeSourceCacheLimit mengubah ukuran anggaran saat runtime, dan ClearRangeSourceCache membuang window sekaligus mereset statistik bersamaan. Mengecilkan anggaran saat runtime mempertahankan riwayat dan menghitung pelepasan akibat anggaran sebagai eviction, sehingga repeatedReads yang naik dengan hits yang datar adalah sinyal bahwa working set tidak lagi muat

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;

Apa yang terjadi saat beberapa thread menginginkan chunk yang sama?

Mereka menunggu satu permintaan, bukan beberapa. TStream klasik punya satu kursor posisi, dan dua thread yang masing-masing mengunci dengan benar tetap bisa memiliki posisi itu ditulis ulang di antara Seek dan Read, sehingga objek lazy dan baca tersegmentasi di PDFlibPas memakai ReadAt absolut yang tak pernah menggerakkan kursor. Setiap chunk selaras mendapat satu permintaan in-flight yang dibagi semua pemanggil chunk itu, chunk yang mengantre dan berdekatan digabung sebelum baca sumber dimulai, dan satu baca fisik dibatasi 16 MiB, sehingga ledakan kerja halaman paralel tidak melipatgandakan menjadi permintaan kecil duplikat maupun satu permintaan besar yang absurd. Window penggabungan default 2 ms dan hanya berlaku untuk chunk pertama yang hilang dari setiap ReadAt; Read posisional tidak pernah menunggunya, dan memberi nol menghapus delay pengumpulan awal sepenuhnya, yang penting untuk pemindaian sekuensial panjang yang sebaliknya menumpuk waktu tunggu chunk demi chunk. Posisi, metadata cache, dan baca sumber berada di belakang tiga lock terpisah, dan callback sumber sendiri diserialisasi, dan itulah yang membuat adapter database atau object store tanpa proteksi thread internal bisa dipakai tanpa diubah. Penunggu menerima salinan datanya sendiri, sehingga eviction LRU belakangan tidak bisa membatalkan buffer yang sudah diserahkan

Penggabungan permintaan dalam pemuatan rentang PDFlibPas untuk Delphi: dua thread yang meminta chunk yang sama berbagi satu permintaan in-flight, chunk mengantre yang berdekatan digabung dalam window dua milidetik, dan satu baca sumber terserialisasi melayani semuanya
Ledakan kerja halaman paralel runtuh menjadi satu permintaan bersama per chunk, dan setiap penunggu tetap menerima salinan byte-nya sendiri

Bisakah Anda menanyakan kesiapan halaman 900 tanpa mengambilnya?

Ya, dan persis itulah gunanya callback ketersediaan opsional ini. Callback baca biasa tidak bisa membedakan byte yang sudah mendarat dari byte yang menuntut bolak-balik blocking, dan memeriksa dengan baca percobaan justru memicu unduhan yang ingin Anda hindari. TPDFlibRangeAvailabilityEvent hanya menjawab satu pertanyaan, yaitu apakah sebuah rentang lengkap bisa dibaca seketika, dan dilarang mengambil apa pun; byte yang sudah tercakup cache selalu dihitung tersedia. GetRangeSourceDataAvailability memetakan objek tidak langsung ke rentang penyimpanan fisik yang tercatat di entri referensi silang, menyelesaikan objek terkompresi ke wadah object stream-nya, mengoreksi header PDF yang bergeser, dan meng-parse objek hanya setelah rentang penuhnya lolos probe tanpa pengambilan, sehingga jalur yang hilang tidak pernah memanggil callback baca Anda

Penelusurannya dibatasi ruang lingkupnya, bukan menyeluruh. Kueri halaman hanya menelusuri cabang page tree yang memuat halaman target lalu menambahkan content halaman, resource, anotasi, dan atribut halaman yang diwariskan, melewati tepi balik Parent dan P agar satu halaman atau widget tidak bisa meluas mundur ke seluruh dokumen. Graf objek dibatasi 100000 objek yang diminta dan kedalaman 256, objek stream di-parse dengan kamus lebih dulu, dan fallback full-parse hanya diizinkan untuk objek tersimpan sampai 4 MiB. Laporan JSON menggabungkan interval yang tumpang tindih dan berdekatan sebelum menghitung, sehingga requiredBytes dan missingBytes dihitung dari array requiredRanges dan missingRanges yang sudah digabung, dengan end sebagai endpoint inklusif. Menanyakan objek yang sudah tersedia bisa mengisi cache rentang; menanyakan yang hilang membiarkan statistik baca tak tersentuh

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 membawa "missingBytes" plus "missingRanges" yang sudah digabung }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { mis. file tidak punya AcroForm sama sekali }
end;

Mengapa prefetch harus beriterasi

Karena membaca missingRanges saat ini sekali saja tidak membuat halaman tersedia. Node page tree atau object stream yang hilang baru membuka lapisan dependensi berikutnya setelah ia tiba, sehingga job prefetch PDFlibPas menjalankan loop kueri, ambil, kueri-ulang sampai halaman, form, atau graf objek sepenuhnya tersedia atau batas byte dan batas pass menghentikannya. Job memakai reader miliknya sendiri dan cache sekunder kecil yang sumber datanya meneruskan baca absolut ke range stream asli, sehingga parse state tetap terisolasi dari TSmartPDFReader latar depan sementara byte yang benar-benar diunduhnya tetap mendarat di cache utama bersama. Satu worker thread ada per range stream, selaras dengan serialisasi yang sudah disyaratkan callback sumber, dan antrean memilih berdasarkan empat tingkat prioritas lalu urutan pengiriman di dalam satu tingkat. MaxBytes diperhitungkan dalam byte chunk fisik, sehingga parser yang meminta satu byte di dalam chunk yang belum di-cache tetap membayar seluruh chunk, sementara chunk yang sudah ada di cache bersama tidak membebani job sama sekali. Membatalkan job yang masih mengantre mencapai status terminal dengan nol baca sumber; job yang sedang berjalan diperiksa sebelum setiap pass dependensi dan setiap chunk sumber, dan membebaskan range stream menunggu callback in-flight kembali alih-alih mencoba menginterupsinya

Loop prefetch PDFlibPas di Delphi: job menanyakan ketersediaan, mengambil rentang yang hilang lalu menanyakan lagi, karena setiap node page tree atau object stream yang tiba membuka lapisan dependensi berikutnya, sampai graf selesai atau sebuah batas menghentikannya
Job prefetch beriterasi karena node yang hilang baru menyebut anak-anaknya sendiri setelah tiba, dan ia memperhitungkan setiap pass dalam chunk fisik utuh
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" dan laporan
    ketersediaan terakhir, sehingga LIMIT_REACHED tetap bisa dibedakan
    dari FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Kapan ini merosot menjadi unduhan seluruh file

Pemuatan rentang adalah taruhan pada tata letak file, dan sebagian file tidak menghormatinya. File linearized sesuai ISO 32000-1 §7.5.8 adalah kasus baik: bagian halaman pertama dihangatkan saat dibuka, dibatasi oleh ambang aman 4 MiB yang sudah ada dan anggaran cache saat ini sehingga pemanasan tidak bisa langsung mengusir sebagian besar dirinya sendiri. File non-linearized tetap terselesaikan lewat trailer dan rantai referensi silang di dekat akhir, yang memakan beberapa bolak-balik ekstra alih-alih bencana. Jurang yang sesungguhnya adalah file rusak yang memaksa jalur perbaikan, karena merekonstruksi tabel referensi silang berarti memindai header objek di seluruh dokumen, dan itu adalah unduhan penuh yang datang satu chunk demi satu chunk. Latensi adalah batas jujur yang lain: pada 60 ms per permintaan, parse akses-acak yang membutuhkan empat puluh chunk yang belum di-cache menghabiskan lebih dari dua detik di perjalanan betapapun baiknya cache, dan persis itulah yang mau disembunyikan oleh argumen read-ahead dan antrean prioritas. Disiplin yang sama muncul di pendekatan akses-langsung untuk menggabung dan memisah PDF besar, dan cache ini berada di bawah rendering halaman paralel sekaligus cache halaman disk milik viewer

API range source, kueri ketersediaan, dan penjadwal prefetch adalah bagian dari PDFlibPas Delphi PDF Library standar untuk Delphi, C++Builder, dan Free Pascal; halaman produk memuat referensi parameter lengkap untuk LoadFromRangeSource bersama konstanta prioritas dan status prefetch