Artikel Teknis

Membaca PDF dengan Memory-Mapped Window di Delphi

PDFlibPas dapat membuka PDF lokal melalui view memory-mapped read-only yang dibatasi: LoadFromMappedFile dan DAOpenMappedFile hanya mempertahankan satu sliding window di atas file, memetakan ulang sesuai kebutuhan, dan melayani setiap potongan object melalui pembacaan absolute-offset. Delphi PDF library ini tidak pernah menampung seluruh source di memory, sehingga penggunaan address space tetap datar ketika file membesar. Desain ini ditujukan untuk satu workload: PDF berukuran gigabyte ketika parser sudah selesai memuat dokumen tetapi masih terus kembali ke disk, object demi object dan fragmen stream demi fragmen stream

Mengapa sparse read tetap mahal setelah PDF dimuat?

Memuat PDF tidak berarti selesai membacanya, dan pada file multi-gigabyte selisih itulah waktu habis. Cross-reference table atau cross-reference stream (ISO 32000-1 §7.5.4 dan §7.5.8) hanya mencatat lokasi awal setiap indirect object. Byte baru dibaca kemudian, ketika sebuah halaman dirender, font program didekode, atau embedded file stream (ISO 32000-1 §7.11.4) diekstrak. Arsip 2 GB dengan puluhan ribu object berubah menjadi puluhan ribu pembacaan kecil yang tidak berurutan, dan tidak satu pun diketahui ketika load awal

Jalur yang dulu digunakan untuk pembacaan itu adalah Seek bersama yang diikuti Read pada satu positional stream, dan jalur tersebut gagal dari dua arah sekaligus. Setiap fragmen tetap membayar biaya file read meskipun halaman sudah berada di cache sistem operasi, dan cursor merupakan mutable state bersama. Akibatnya local file dan byte-range source di balik progressive PDF range loading dengan prefetch tidak dapat menjalankan kode parser yang sama tanpa saling berebut posisi. PDFlibPas memperbaiki keduanya dengan menjadikan absolute-offset reading sebagai contract, bukan sekadar optimasi

Apa yang dijamin TPDFReadAtStream?

TPDFReadAtStream menjamin pembacaan pada offset absolut yang tidak bergantung pada dan tidak mengubah logical stream cursor. Ini adalah turunan abstrak TStream dengan tepat satu virtual method, dan kedua source yang tidak bergantung pada cursor di library berasal darinya: TReadOnlyMappedFileStream untuk file lokal dan TByteRangeStream untuk remote yang dilayani melalui range. Object-slice reader sekali saja memeriksa apakah source-nya merupakan TPDFReadAtStream, lalu kembali ke urutan seek-then-read lama bila bukan, sehingga file stream atau memory stream biasa tetap bekerja tanpa perubahan

type
  // Stream read-only yang absolute read-nya menghindari Seek lalu Read bersama
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Akses read-only berjendela ke satu file lokal
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

Perbedaan ini lebih penting daripada yang terlihat dari signature-nya. ReadAt menggunakan offset yang diberikan dan membiarkan Position tetap persis seperti sebelumnya, sehingga level parser yang bersarang dapat melakukan read tanpa ritual save-and-restore di setiap pemanggilan. TReadOnlyMappedFileStream tetap mengimplementasikan Read, Seek, dan Size seperti TStream lain, Seek membatasi posisi logis ke dalam file, dan Write selalu mengembalikan 0 karena source dibuka read-only

Membuka PDF melalui mapped view di Delphi

Dua entry point eksplisit membuka mapped source, dan tidak satu pun mengubah perilaku entry point yang sudah Anda gunakan. LoadFromMappedFile memuat dan memilih sebuah dokumen; DAOpenMappedFile mengembalikan Direct Access handle di atas file yang sama, yaitu mode yang tepat ketika menggabungkan dan memisahkan PDF gigabyte melalui Direct Access. LoadFromFile dan DAOpenFile mempertahankan semantics file sharing, error, dan compatibility masing-masing, sehingga caller yang tidak memilih opt-in tidak mengalami perubahan. Kedua mapped entry point menerima WindowSize yang diminta dalam byte dan bitmask Options, dan keduanya menerima 0 untuk salah satunya

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 memilih default 64 MiB; mapping wajib di sini
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Extraction yang ditunda kini menelusuri mapped window, bukan melakukan seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Apa yang sebenarnya diwajibkan PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING mengubah fallback diam-diam menjadi kegagalan langsung yang dapat didiagnosis pada waktu open. Jika Options dibiarkan 0, kedua entry point menerima fallback file-stream read-only: jika platform tidak memiliki kode mapping, atau pemanggilan mapping gagal, dokumen tetap terbuka dan setiap read berjalan melalui file stream biasa. Jika flag diset, PDFlibPas hanya menerima input ketika view pertama berhasil dibuat, dan melaporkan penolakan melalui LastErrorCode 401, bukan memuat dokumen yang diam-diam berperilaku persis seperti jalur lama

Di Windows, mapped stream membuka handle read-only kedua dengan FILE_SHARE_READ, FILE_SHARE_WRITE, dan FILE_SHARE_DELETE ditambah FILE_FLAG_RANDOM_ACCESS, membuat mapping PAGE_READONLY di atasnya, lalu memetakan window pertama di dalam constructor. Mapping dilakukan secara eager karena itulah inti desainnya: kegagalan "mapping required" muncul di LoadFromMappedFile, bukan pada object read lazy pertama di tengah pekerjaan rendering. Namun perlu jelas di mana jaminan ini berhenti. Kode mapping hanya dikompilasi untuk target Windows, dan file berukuran nol tidak pernah mencoba mapping, sehingga PDF_MAPPED_FILE_REQUIRE_MAPPING adalah permintaan yang memang dapat gagal, bukan janji portabel. WindowSize negatif, atau bit apa pun dalam Options selain satu nilai yang didokumentasikan, langsung ditolak dengan error 401 yang sama

Satu window yang dipetakan ulang mengikuti allocation granularity

Hanya satu view yang dipertahankan setiap saat, dan itulah yang membuat penggunaan address space tidak bergantung pada ukuran file. WindowSize 0 memilih 64 MiB; nilai di bawah system allocation granularity dinaikkan ke nilai tersebut; nilai di atas 1 GiB dibatasi; dan hasilnya dibulatkan ke sejumlah unit granularity penuh, yaitu 65536 byte di Windows kecuali GetSystemInfo melaporkan dwAllocationGranularity yang berbeda. Ketika read jatuh di luar view saat ini, PDFlibPas meng-unmap-nya, menurunkan offset yang diminta ke batas granularity, lalu memetakan window baru di sana. Window terakhir dibatasi ke ukuran fisik file, sehingga view tidak pernah melewati akhir file

Satu read dapat melintasi berapa pun jumlah window: loop menyalin sebanyak yang dapat disediakan view saat ini, memetakan ulang, lalu melanjutkan, dan request yang berjalan melewati akhir mengembalikan short count, bukan error. Hal yang sengaja tidak dilakukan PDFlibPas adalah memberikan pointer ke dalam view, karena read lintas-window berikutnya akan membuat pointer itu invalid dan caller tidak memiliki cara yang masuk akal untuk melindunginya. Byte hasil mapping langsung disalin ke destination buffer milik parser, sehingga file input buffer tambahan dan perpindahan posisi hilang, tetapi library tidak membuat klaim zero-copy untuk storage akhir parser. Windowing pada sisi read juga dapat dipadukan dengan sisi write, karena byte-level reference shifting selama fast PDF merge mengalirkan byte object keluar ketika mapped source mengalirkannya masuk. Trade-off ukuran window sudah jelas: window lebih kecil menggunakan lebih sedikit address space tetapi lebih sering dipetakan ulang, yang biasanya merupakan pilihan tepat di dalam proses 32-bit

Apa yang dilindungi lock, dan apa yang dilaporkan GetMappedFileInfo?

Satu critical section mencakup mapped view, fallback file cursor, posisi logis, dan statistik, dan pemisahan antara kedua read method langsung mengikuti aturan itu. ReadAt mengambil lock lalu memanggil internal reader tanpa lock; Read mengambil lock yang sama, memanggil internal reader pada posisi logis saat ini, kemudian memajukannya. Penggunaan ulang fungsi internal, bukan public ReadAt, menghindari recursive locking, sedangkan mempertahankan lock sepanjang seluruh copy loop menjaga remap satu window tetap benar saat ada pemanggilan bersamaan. Ada satu detail Free Pascal yang perlu diketahui sebelum porting: unit Windows milik FPC mendeklarasikan record bernama TCriticalSection sendiri, sehingga field dan construction-nya harus ditulis sebagai SyncObjs.TCriticalSection. Delphi dengan senang hati mengompilasi bentuk tanpa qualifier; FPC menyelesaikannya menjadi record yang tidak memiliki Create, Enter, atau Leave

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped bernilai false setiap kali fallback file-stream portabel aktif, dan ini adalah satu-satunya field yang membuktikan bahwa mapping tidak pernah berhasil dibuat
  • windowSize adalah window efektif yang sudah di-align, bukan nilai yang diminta, sedangkan mappedBytes lebih kecil darinya pada tail window
  • mappedOffset adalah awal view yang sudah di-align dengan allocation, atau -1 ketika tidak ada view yang sedang aktif
  • readCalls menghitung request read in-range yang berhasil, bytesRead menghitung byte yang disalin ke caller, dan remapCount mencakup view awal

Regresi terarah mencakup absolute read lintas-window, pelestarian logical cursor, short read di tail, offset tidak valid, write yang ditolak, remapping di antara window yang berjauhan, extraction tertunda pada attachment inkompresibel berukuran 220 KB, serta statistik yang menjadi invalid setelah DACloseFile; suite headless Win32 dan Win64 masing-masing menemukan 1467 test dan semuanya lulus tanpa hasil ignored, failed, errored, atau leaked. Jika Anda bekerja dengan PDF gigabyte di Delphi atau C++Builder dan profiler terus menunjuk ke file read, bukan parsing, entry point mapped-file layak diukur selama satu sore, dan GetMappedFileInfo akan memberi tahu apakah mapping benar-benar diperoleh. Referensi API lengkap dan build trial tersedia di halaman PDFlibPas Delphi PDF library