Artikel Teknis

Streaming PDF Raksasa Sesuai Permintaan Dengan PDFium di Delphi

Sebuah arsip hasil pindai (scan) dapat mencapai beberapa gigabyte dalam satu PDF. Sebuah penampil yang membuka file semacam itu biasanya hanya ingin menampilkan satu halaman, mungkin daftar isi, mungkin sebuah halaman yang dituju pengguna dari sebuah bookmark. Membaca seluruh file ke dalam memori untuk me-render dua halaman adalah pemborosan di setiap sisi: ia menghabiskan ruang alamat, ia menahan pengguna di belakang pembacaan awal yang panjang, dan pada proses Delphi 32-bit ia bisa gagal total sebelum satu halaman pun muncul. PDFium dibangun dengan mempertimbangkan hal ini. Ia dapat memuat sebuah dokumen melalui sebuah callback yang meminta rentang byte tertentu yang dibutuhkannya, saat dibutuhkan, dan ia tidak pernah menuntut seluruh file sekaligus. Satu batasan perlu disebutkan sejak awal: jalur streaming ini mendeskripsikan file dengan panjang 32-bit, sehingga ia melayani satu file hingga 4 GiB, yang dalam praktiknya mencakup hampir semua arsip hasil pindai. Sebuah file yang melampaui batas tersebut bukan wilayah artikel ini; file itu perlu dipecah menjadi beberapa volume saat pemindaian atau dibuka melalui strategi akses langsung sebagai gantinya, dan penjaga yang menegakkan batas atas tersebut secara jujur mendapatkan bagiannya sendiri di bawah ini

Komponen tersebut mengekspos jalur itu melalui sebuah stream adapter. Anda menyerahkan kepadanya sebuah TStream apa pun, dan PDFium menarik blok-blok dari stream tersebut sesuai permintaan. File tersebut dapat berada di disk, di sebuah field blob database, atau di balik descendant TStream lain apa pun, dan tidak satu pun darinya disalin ke memori sejak awal

Bagaimana PDFium meminta byte

API C milik PDFium memuat sebuah dokumen dari sebuah objek yang disediakan pemanggil, dideskripsikan oleh struktur FPDF_FILEACCESS. Struktur tersebut memiliki tiga bagian yang penting di sini: sebuah field panjang, sebuah callback baca, dan sebuah parameter pengguna yang opak. Titik masuk yang mengonsumsinya adalah FPDF_LoadCustomDocument. Begitu PDFium memegang struktur tersebut, ia mengurai trailer, menemukan tabel referensi silang (cross-reference), dan sejak itu hanya membaca apa yang dibutuhkan oleh operasi tertentu. Membuka dokumen tersebut menyentuh bagian akhir file dan segelintir objek katalog. Me-render halaman 400 membaca content stream dan sumber daya untuk halaman tersebut dan tidak ada yang lain

Inilah perbedaan antara pemuatan buffered dan pemuatan streaming. Sebuah pemuatan buffered membaca file dari awal hingga akhir sebelum PDFium melihat byte nol. Sebuah pemuatan streaming membalik hubungan tersebut: PDFium yang mengendalikan pembacaan, dan byte-byte yang tidak pernah tersentuh tidak pernah dibaca. Untuk sebuah file multi-gigabyte yang dilihat satu halaman pada satu waktu, itulah jurang antara pemuatan yang tidak dapat digunakan dan pemuatan yang instan

Diagram arsitektur yang mengontraskan pemuatan berbuffer, yang menyalin PDF multi-gigabyte ke memori sebelum parsing, dengan streaming, di mana PDFium meminta rentang byte dari TStream Delphi melalui FPDF_FILEACCESS
Pembukaan hanya berbiaya trailer dan catalog; merender halaman 400 menarik byte halaman 400 dan tidak lainnya melalui callback

Stream adapter tersebut

Adapter yang menjembatani sebuah TStream Delphi ke FPDF_FILEACCESS adalah TPdfStreamAdapter. Constructor-nya menerima stream tersebut dan sebuah flag kepemilikan, menangkap panjang stream sekali, mengisi record FPDF_FILEACCESS, dan menghubungkan callback baca. Ketika PDFium nantinya memanggil kembali dengan sebuah offset dan sebuah ukuran, adapter tersebut men-seek stream ke offset tersebut dan menyalin persis rentang tersebut ke buffer yang disediakan PDFium

// Verbatim dari komponen: jembatan stream-ke-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 adalah unsigned long 32-bit. Tolak sebuah stream
  // yang akan terpotong secara diam-diam melewati 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;

Flag kepemilikan tersebut memutuskan siapa yang membebaskan stream tersebut. Oper False dan pemanggil tetap memegang stream tersebut dan harus menjaganya tetap hidup selama masa hidup dokumen. Oper True dan adapter tersebut mengambil alih, membebaskan stream saat dokumen ditutup. Bagaimanapun juga, stream tersebut harus tetap hidup lebih lama daripada setiap pembacaan yang akan dilakukan PDFium, karena PDFium memegang pointer FPDF_FILEACCESS dan akan memanggil kembali pada titik mana pun selama dokumen terbuka, tidak hanya selama pemuatan awal

Mengapa callback tersebut adalah sebuah fungsi statis

Callback baca yang disimpan PDFium di m_GetBlock adalah sebuah pointer fungsi C biasa dengan calling convention cdecl. Sebuah metode Delphi tidak dapat digunakan secara langsung, karena sebuah metode membawa argumen Self tersembunyi yang tidak diketahui sama sekali oleh pemanggil C dan tidak akan pernah disediakan. Adapter tersebut karena itu mendeklarasikan callback tersebut sebagai sebuah class function yang ditandai cdecl; static, yang dikompilasi menjadi sebuah fungsi berdiri sendiri dengan tata letak frame C yang diharapkan PDFium dan tanpa Self implisit

Itu menyelesaikan masalah calling convention tetapi memunculkan pertanyaan kedua: tanpa Self, bagaimana callback tersebut mencapai stream tertentu yang seharusnya dibacanya? Jawabannya adalah parameter pengguna yang opak tersebut. Ketika adapter membangun record tersebut, ia menyimpan pointer instance-nya sendiri di m_Param. PDFium mengembalikan pointer yang sama itu sebagai argumen pertama dari setiap callback. Fungsi statis tersebut mengonversinya kembali menjadi sebuah TPdfStreamAdapter dan mengirimkan pembacaan tersebut terhadap stream instance itu. Ini adalah trampoline standar untuk menyerahkan konteks objek melintasi batas C yang tidak memiliki konsep objek

Diagram trampolin cdecl yang membawa permintaan blok PDFium dari batas C ke instance TPdfStreamAdapter Delphi dan memadatkan exception menjadi nilai kembali nol
Callback cdecl statis menyembunyikan Self implisit, sehingga m_Param menyerahkan instance adapter kembali ke setiap pemanggilan dan exception Pascal apa pun melipat menjadi nilai balik nol
// Verbatim dari komponen: trampoline cdecl kembali ke instance
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);   // pulihkan instance dari 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;  // laporkan kegagalan lewat nilai kembalian, jangan pernah dengan memunculkan exception
  end;
end;

Batas atas 4 GiB dan mengapa itu membutuhkan penjaga

Di sinilah asal batasan yang disebutkan di pembukaan tadi. Field panjang m_FileLen di FPDF_FILEACCESS adalah sebuah nilai unsigned 32-bit. Panjang maksimum yang dapat direpresentasikannya adalah satu byte kurang dari 4 GiB. Sebuah TStream melaporkan ukurannya sebagai Int64, sehingga sebuah stream dapat mendeskripsikan jauh lebih banyak byte daripada yang dapat ditampung field tersebut. Begitu ukuran sebuah stream melampaui batas atas tersebut, tidak ada cara jujur untuk memberi tahu PDFium seberapa panjang file tersebut

Respons yang salah adalah menetapkan ukuran tersebut dan membiarkannya wrap. Memotong panjang 5 GiB ke sebuah field 32-bit menghasilkan sebuah angka kecil yang terlihat masuk akal, dan PDFium kemudian akan mengurai file tersebut dengan meyakini bahwa file itu berakhir kira-kira satu gigabyte ke dalam. Trailer dan tabel referensi silang sesungguhnya berada di akhir file yang sebenarnya, jauh melampaui panjang yang terpotong tersebut, sehingga penguraian gagal dengan cara yang tidak ada hubungannya dengan penyebab sebenarnya. Anda akan sibuk men-debug sebuah kesalahan referensi silang pada file yang sebenarnya sempurna valid, tanpa petunjuk bahwa sebuah integer telah wrap dua lapis di atasnya

Adapter tersebut malah menolak input tersebut. Constructor-nya membandingkan ukuran stream terhadap High(FPDF_DWORD) dan memunculkan EPdfError pada saat stream tersebut terlalu besar untuk dideskripsikan. Sebuah error yang eksplisit dan langsung menyebutkan masalah sebenarnya pada titik konstruksi. Sebuah pemotongan diam-diam menyembunyikannya di balik gejala yang menyesatkan yang akan Anda kejar jauh kemudian. Batas 4 GiB adalah batasan sungguhan dari jalur pemuatan ini, dan hal yang jujur adalah memunculkannya dengan lantang alih-alih menutupinya dengan aritmetika yang kebetulan berhasil dikompilasi. Ketika sebuah arsip benar-benar melampaui batas tersebut, solusi yang dijanjikan di atas berada di luar API ini: pecah pemindaian tersebut menjadi file per-volume yang masing-masing tetap di bawah batas atas, atau biarkan dokumen tersebut di disk dan layani melalui sebuah desain akses langsung yang dibangun di atas offset 64-bit alih-alih melalui FPDF_FILEACCESS

Diagram keputusan yang menjaga batas 4 GiB FPDF_FILEACCESS, di mana TStream Delphi yang terlalu besar memunculkan EPdfError segera alih-alih secara senyap melipat bidang panjang yang dideklarasikan
EPdfError seketika mengalahkan aritmetika yang sekadar terkompilasi: m_FileLen yang melipat mengirim debugging menyusuri jejak cross-reference fiktif

Kegagalan tidak boleh melintasi batas tersebut

Sebuah pembacaan dapat gagal. Stream tersebut mungkin sebuah objek berbasis jaringan yang timeout, sebuah handle blob yang ditutup di bawah Anda, atau sebuah file yang terpotong setelah dokumen dibuka. Kontrak PDFium untuk callback baca adalah sebuah nilai kembalian: bukan-nol untuk sukses, nol untuk gagal. Ini adalah sebuah frame C, dan tidak memiliki mekanisme untuk menangkap atau menyebarkan sebuah exception Pascal

Inilah mengapa trampoline tersebut membungkus seek dan pembacaan dalam sebuah try/except yang menelan exception tersebut dan mengembalikan nol. Jika sebuah exception Delphi diizinkan menyebar keluar dari callback, ia akan unwind melalui stack frame cdecl milik PDFium, yang tidak pernah dibangun untuk di-unwind oleh mekanisme exception Pascal. Hasilnya adalah undefined behavior paling baiknya dan crash keras paling buruknya, jauh di dalam parser PDF tanpa stack yang dapat digunakan. Mengembalikan nol menjaga kegagalan tersebut tetap di dalam kontrak. PDFium melihat sebuah pembacaan blok yang gagal, membatalkan operasi tersebut dengan bersih, dan FPDF_LoadCustomDocument melaporkan bahwa dokumen tersebut tidak dapat dimuat, yang oleh komponen dimunculkan sebagai sebuah EPdfError di sisi Pascal tempat seharusnya itu berada

Membuka sebuah dokumen dengan cara ini

Metode komponen yang mengendalikan jalur streaming tersebut adalah LoadCustomDocument, dideklarasikan sebagai sebuah metode terpisah alih-alih overload LoadDocument lainnya sehingga mengoper sebuah TMemoryStream tidak akan pernah secara tidak sengaja mendarat di jalur buffered. Metode ini membangun adapter tersebut, memanggil FPDF_LoadCustomDocument, dan menjaga adapter tersebut tetap hidup selama masa hidup dokumen yang dimuat

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Serahkan kepemilikan stream ke Pdf: ia membebaskan FileStream saat dokumen ditutup.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium sejauh ini baru membaca trailer dan katalog.
    // Me-render sebuah halaman hanya menarik byte halaman itu melalui callback.
    // ... render atau periksa halaman di sini ...
  finally
    Pdf.Free;  // menutup dokumen, yang membebaskan adapter dan stream tersebut
  end;
end;

Panggilan yang sama berfungsi untuk sebuah TMemoryStream, sebuah blob stream dari dataset database, atau sebuah descendant TStream kustom. Pemuatan sesuai permintaan (on-demand) membuktikan nilainya ketika file tersebut besar dan hanya sebagian darinya yang akan dibaca: sebuah penampil arsip, sebuah generator thumbnail yang mengambil sampel beberapa halaman, sebuah indeks pencarian yang menarik satu halaman pada satu waktu. Ketika file tersebut kecil atau Anda akan membaca semuanya bagaimanapun juga, sebuah pemuatan buffered lebih sederhana dan mekanisme streaming tidak memberi Anda keuntungan apa pun. Faktor penentunya adalah rasio byte yang akan benar-benar Anda sentuh terhadap byte yang dikandung file tersebut

Begitu halaman-halaman mengalir masuk sesuai permintaan, perhatian berikutnya adalah menjaga halaman yang telah di-render tetap responsif saat pengguna melakukan zoom dan scroll, yang dibahas di catatan kami tentang render caching dan performa zoom. Ketika dokumen yang di-streaming tersebut adalah dokumen yang seharusnya ditampilkan oleh penampil tetapi tidak boleh diekspor atau diubah oleh pengguna, teknik-teknik dalam panduan pratinjau PDF yang aman berpasangan secara alami dengan jalur pemuatan ini. Keduanya dibangun di atas pemuatan streaming yang dijelaskan di sini, yang dikirimkan sebagai bagian dari PDFium Component untuk Delphi dan C++Builder bersama API rendering, ekstraksi teks, dan anotasi yang dijelaskan di tempat lain di blog ini