Artikel Teknis

Pemirsa PDFium Delphi: Cache Render dan Taktik Zoom Mulus

Tahan tombol zoom pada sebuah viewer PDF yang naif dan perhatikan grafik CPU-nya. Satu kali penekanan pada sebuah kontrol zoom auto-repeat memicu selusin langkah zoom atau lebih per detik, dan jika setiap langkah memicu sebuah re-render kualitas penuh dari halaman yang terlihat, render-render itu menumpuk lebih cepat daripada penyelesaiannya. Halaman itu me-raster dengan baik jika diisolasi, mungkin 180 md untuk sebuah hasil pindai A4, tetapi Anda sekarang menjalankan selusin render 180 md terhadap pekerjaan yang sudah dilewati pengguna. Viewer terkunci, satu core menempel di 100%, dan pada saat layar berhasil mengejar, pengguna sudah berhenti pada sebuah level zoom empat render yang lalu. Obatnya bukan sebuah rasterizer yang lebih cepat. Obatnya adalah sebuah cache yang mengembalikan halaman yang sudah jadi secara instan dan sebuah render loop yang mau meninggalkan pekerjaan begitu pekerjaan itu menjadi basi

PDFium Component menyerahkan kepada Anda bagian-bagian untuk keduanya dan tidak ikut campur dalam kebijakannya. Anda mendapatkan bitmap yang dimiliki oleh si pemanggil, sebuah renderer progresif yang menerima sebuah cancellation token, fit mode yang menghitung ulang zoom saat resize, dan sebuah panggilan tiling untuk halaman yang terlalu besar untuk di-raster utuh. Yang dengan sengaja tidak disediakannya adalah cache itu sendiri, karena kebijakan eviction yang tepat bergantung pada viewport Anda, batas memori platform Anda, dan bagaimana pengguna Anda melakukan scroll. Keputusan itu adalah tanggung jawab Anda untuk dibuat dengan benar, dan konsekuensi dari membuatnya salah persis adalah freeze dan kebocoran memori

Ke mana milidetik dan megabyte itu pergi

Berikan angka pada biayanya sebelum Anda mendesain apa pun. Sebuah halaman A4 pada 96 DPI kurang lebih berukuran 794 kali 1123 piksel, sekitar 3,5 MB sebagai sebuah bitmap 32-bit. Zoom ke 200% dan itu berlipat empat. Pada 400% di sebuah layar high-DPI, Anda mengalokasikan dan mengisi satu bitmap halaman sebesar 50 hingga 60 MB, dan sebuah viewer continuous-scroll mempertahankan beberapa halaman tetap hidup sekaligus. Biaya rasterisasi mengikuti piksel output, sehingga setiap penggandaan zoom kurang lebih melipatempatkan waktu render dan memori sekaligus

Dua konsekuensi muncul langsung dari aritmetika itu. Sebuah cache yang key-nya mengabaikan level zoom tidak ada gunanya, karena justru gestur yang perlu dipercepatnya, yaitu zooming, menghasilkan sebuah bitmap baru setiap kali. Dan sebuah cache tak terbatas akan menghabiskan address space dari sebuah proses 32-bit persis pada dokumen-dokumen tempat orang paling banyak melakukan zoom: hasil pindai sertifikat tanah yang padat, gambar teknik, peta berformat besar. Cache harus diberi key dengan benar dan dibatasi dengan tegas, dan keduanya bukan opsional

Apa yang seharusnya ada dalam cache key

Sebuah bitmap yang di-cache aman untuk digunakan kembali hanya ketika setiap input yang membentuk pikselnya masih cocok. Itu berarti nomor halaman, zoom efektif (atau setara dengan dimensi piksel output), rotasi, DPI monitor, dan opsi render yang berlaku saat bitmap itu diproduksi. Sebuah halaman yang dirender dengan reAnnotations adalah gambar yang berbeda dari halaman yang sama tanpa itu, dan sebuah pass grayscale melalui reGrayscale berbeda lagi. Hilangkan salah satu dari ini dari key-nya dan bug-nya dapat diprediksi: sebuah overlay anotasi yang masih bertahan setelah seorang peninjau menghapus komentarnya, atau sebuah halaman yang menjadi buram seketika saat pengguna menyeret jendela dari panel laptop ke sebuah monitor 4K eksternal dan DPI-nya berubah di bawah sebuah bitmap yang basi

Pencarian cache render PDFium dalam penampil Delphi di mana kunci cache menggabungkan halaman, zoom, rotasi, DPI monitor, dan opsi render, hit mengembalikan bitmap dalam mikrodetik, dan eviksi membebaskan setiap bitmap yang ia buang
Cache key mencakup setiap input yang membentuk piksel, dan eviction membebaskan bitmap yang ia buang
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // cache kini memiliki bitmap ini
end;

Pada sebuah hit, ini kembali dalam hitungan mikrodetik, yang memang menjadi keseluruhan tujuannya. Pertanyaan yang lebih sulit adalah apa yang terjadi pada bitmap yang keluar dari cache, dan itu ternyata adalah sebuah pertanyaan tentang siapa yang memilikinya

Siapa yang membebaskan bitmap-nya

Bentuk fungsi dari RenderPage mengembalikan sebuah TBitmap yang dimiliki oleh si pemanggil. Dalam sebuah ekspor sekali pakai, kepemilikan itu jelas dan mudah dihormati. Di dalam sebuah cache, itu menjadi kebocoran tunggal yang paling umum pada viewer PDF Delphi, karena dictionary itu kini memegang satu-satunya referensi ke setiap bitmap, dan sebuah TDictionary biasa hanya membebaskan key dan value untuk Anda jika keduanya adalah managed type. TBitmap bukan. Keluarkan (evict) sebuah entri tanpa memanggil Free dan pikselnya tetap teralokasi tanpa apa pun yang menunjuk padanya

Alasan mengapa ini lolos begitu saja adalah soal timing. Sebuah smoke test sepuluh menit tidak pernah melakukan zoom pada cukup banyak halaman berbeda untuk menyadarinya; kebocoran itu baru menampakkan diri setelah seseorang melakukan scroll dan zoom pada sebuah dokumen panjang selama beberapa jam, pada titik mana proses itu sedang memegang ratusan bitmap halaman yatim piatu dan mesin mulai melakukan paging. Itulah sebabnya eviction seharusnya ada di versi pertama cache, bukan versi belakangan. Batasi cache berdasarkan estimasi byte, dihitung sebagai lebar dikali tinggi dikali empat, keluarkan halaman yang paling lama tidak digunakan (least-recently-used) yang berada di luar viewport dan jendela prefetch, dan bebaskan setiap bitmap saat Anda mengeluarkannya. Untuk penggambaran yang benar-benar sementara, overload yang me-render ke dalam sebuah TBitmap yang disediakan pemanggil atau langsung ke sebuah HDC membiarkan Anda melewati seluruh tarian kepemilikan itu. Print preview adalah kasus yang jelas, karena Anda me-render setiap lembar satu kali dan caching-nya tidak memberi keuntungan apa pun

Rendering progresif dan pembatalan yang jujur

Overload RenderPage biasa memblokir sampai halamannya selesai, yang persis merupakan perilaku yang tidak Anda inginkan selagi pengguna masih menggerakkan kontrol zoom. Untuk itu Anda meraih RenderPageProgressive. Fungsi ini menerima sebuah IPdfCancellationToken dan mengembalikan salah satu dari prsDone, prsCancelled, atau prsFailed. Detail perilaku yang sering menjebak orang adalah bahwa pembatalan tidak bersifat instan. Token itu di-poll pada batas-batas chunk di dalam render, sehingga sebuah token yang Anda sinyalkan di tengah sebuah chunk baru berlaku ketika chunk itu selesai. Pada sebuah halaman yang kompleks, latensi antara meminta dan berhenti bisa mencapai puluhan milidetik. Rancanglah di sekitar celah itu, bukan berharap celah itu hilang: batalkan token sebelumnya seketika sebuah nilai zoom baru tiba, tetapi jangan berasumsi render lama berhenti seketika Anda memintanya

Timeline rendering progresif PDFium di Delphi di mana setiap permintaan zoom baru membatalkan token sebelumnya, pembatalan mendarat di batas chunk, render yang tersubstitusi mengembalikan prsCancelled, dan percobaan terakhir mengembalikan prsDone
Setiap permintaan zoom baru membatalkan token render sebelumnya, dan pembatalan mendarat di batas chunk
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // tinggalkan render sebelumnya yang masih berjalan
  FTokenSource := TPdfCancellationTokenSource.New;  // unit FPdfAsync

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // digantikan oleh permintaan yang lebih baru: abaikan secara senyap
    prsFailed:    ShowRenderFailure;
  end;
end;

Selama interaksi, prsCancelled adalah hasil yang normal, bukan yang eksepsional. Sebagian besar render yang dimulai oleh sebuah gestur zoom akan digantikan sebelum selesai, jadi perlakukan pembatalan sebagai hal rutin dan abaikan hasilnya secara senyap. Sebuah render queue yang mencatat setiap pembatalan sebagai sebuah warning akan mengubur satu kegagalan yang sungguh-sungguh penting di bawah ribuan baris noise. Agar layar tidak terlihat mati selagi render sesungguhnya berjalan, pasangkan jalur progresif dengan sebuah pengganti sementara yang murah: skalakan bitmap yang di-cache sebelumnya ke zoom yang baru dan tampilkan itu segera. Itu terlihat lembut (blur) selama seratus milidetik atau dua ratus, tetapi terbaca sebagai instan, dan itu membeli waktu yang dibutuhkan oleh render kualitas penuh untuk selesai atau dibatalkan oleh gestur berikutnya

Fit mode yang diam-diam dimatikan oleh zoom

Properti FitMode milik sebuah viewer, yang diset ke pfmFitPage atau pfmFitWidth, menghitung ulang zoom pada setiap resize sehingga halaman tetap pas seiring jendela berubah. Ganjalannya adalah menetapkan Zoom secara langsung mengatur ulang FitMode kembali ke pfmNone. Sebagai default, itu benar: seorang pengguna yang sengaja mengetik 150% tidak ingin resize jendela berikutnya membuangnya begitu saja. Tetapi itu mengejutkan siapa pun yang merangkai sebuah tombol zoom-in sebagai Zoom := Zoom * 1.25 dan kemudian tidak dapat memahami mengapa fit-to-width berhenti merespons setelah klik pertama. Jika toolbar Anda menawarkan baik zoom eksplisit maupun fit mode, Anda harus mengingat sendiri pilihan fit terakhir pengguna dan menetapkannya kembali saat mereka menekan tombol fit lagi. Komponen ini tidak akan memulihkan sebuah mode yang baru saja dihapus oleh sebuah penetapan zoom, dan memang seharusnya tidak

Sebuah anggaran memori yang dapat Anda pertahankan

Sebuah anggaran yang dapat Anda tuliskan adalah sebuah anggaran yang dapat Anda perdebatkan dalam sebuah code review, jadi mulailah dari sebuah skenario konkret. Katakanlah continuous scroll mempertahankan halaman yang terlihat ditambah satu halaman prefetch di atas dan di bawah, berdampingan dengan sebuah strip thumbnail. Pada 100% di sebuah layar 96-DPI, ketiga bitmap ukuran penuh itu berjumlah sekitar 3,5 MB masing-masing, yang bukan apa-apa. Pada 300% di sebuah layar 4K, ketiga bitmap yang sama itu kurang lebih 30 MB masing-masing, dan itu sebelum cache mempertahankan satu pun halaman historis. Pertumbuhannya ada pada gesturnya, bukan pada dokumennya

Aritmetika memori bitmap PDFium untuk penampil Delphi di mana setiap penggandaan zoom melipatempatkan memori halaman, gulir berkelanjutan menjaga tiga halaman tetap hidup, anggaran LRU yang dijepit membela cache, dan RenderTile menangani gambaran berukuran besar
Setiap penggandaan zoom melipatempatkan memori bitmap, sehingga cache butuh cap keras dan tile untuk halaman berukuran besar

Default yang masuk akal untuk sebuah proses Delphi 32-bit adalah sebuah anggaran bitmap 256 MB di bawah eviction LRU. Pada 64-bit Anda dapat menyesuaikan dengan RAM fisik, tetapi tetap pertahankan sebuah batas keras (hard ceiling) apa pun yang terjadi, karena kegagalan yang Anda jaga bukanlah proses Anda yang crash. Melainkan seluruh mesin yang thrashing pada page file-nya sementara viewer Anda secara teknis tetap berjalan dan pengguna bertanya-tanya mengapa semua yang lain melambat. Sebuah batas keras gagal secara dapat diprediksi; sebuah cache tak terbatas gagal dengan menyeret seluruh desktop bersamanya. Thumbnail layak mendapat perlakuan tersendiri: render masing-masing satu kali pada ukuran target kecilnya dan simpan itu dalam sebuah pool terpisah yang tidak pernah disentuh oleh logika LRU. Membuat ulang sebuah thumbnail 120 piksel dengan men-downscale sebuah bitmap halaman penuh 60 MB adalah cara paling boros yang mungkin untuk memproduksi sebuah perangko

Sejumlah halaman tunggal mengalahkan anggaran apa pun. Sebuah gambar teknik ukuran E atau sebuah peta besar yang dirender utuh pada 400% adalah sebuah alokasi berukuran ratusan megabyte, dan tidak ada kebijakan eviction yang membuat itu dapat diterima. Jawabannya di sana adalah berhenti me-render halaman utuh. RenderTile me-raster hanya region pada offset piksel (Left, Top) dalam sebuah halaman yang secara nosional diskalakan ke PageWidth kali PageHeight, sehingga Anda hanya me-render rectangle yang terlihat ditambah sebuah margin satu-tile di sekelilingnya untuk panning yang mulus, dan Anda melipat offset tile ke dalam cache key berdampingan dengan zoom. Jaga dimensi tile tetap sama di seluruh dokumen. Tile yang tetap berarti sebuah perubahan DPI membatalkan seluruh grid secara bersih, sementara tile yang bervariasi membuat Anda mengejar sambungan yang terlihat di antara region yang dirender pada skala yang sedikit berbeda

Dua fitur bertetangga diam-diam menambah semua ini. Pass color-filter seperti grayscale atau inversi berjalan setelah rendering dan menghasilkan sebuah bitmap ukuran penuh kedua setiap kalinya, melipatgandakan jejak per-halaman dari tampilan mana pun yang menggunakannya; biaya itu menjadi topik dari penyaringan warna low-vision untuk viewer PDF Delphi. Dan sebuah viewer yang menyorot kata selama text-to-speech membatalkan tampilan yang dirender pada setiap kata yang diucapkan, sehingga interaksi antara penggambaran ulang highlight dan kecepatan bicara menjadi lebih penting daripada yang tampak pada awalnya, sebagaimana dibahas dalam highlighting TTS kata demi kata

Overload rendering, kode status progresif, dan komponen viewer itu sendiri didokumentasikan pada halaman produk untuk PDFium Component