Artikel Teknis

Garbage Collection PDF di Delphi: Mark and Sweep

Menghapus halaman dari PDF tidak menghapus font, gambar, atau content stream miliknya. losLab PDF Library mengembalikan objek-objek tersebut lewat collector mark-sweep yang menelusuri object graph maju dari trailer root dan membuang setiap indirect object yang tidak lagi dijangkau siapa pun. Proses ini berjalan saat full save, defaultnya nonaktif, dan mengembalikan jumlah objek yang dibuang

Kenapa menghapus halaman PDF tidak memperkecil ukuran berkas?

Sebab penghapusan halaman adalah pengeditan referensi, bukan operasi penyimpanan. DeletePages(StartPage, PageCount) melepas tautan objek halaman dari page tree dan memperbaiki entri outline yang menunjuk ke sana. Yang tidak bisa dilakukannya adalah memutuskan bahwa font program, content stream, dan image XObject yang dipakai halaman-halaman itu kini sudah mati, karena pada saat penghapusan tidak ada apa pun dalam berkas yang mencatat siapa lagi yang mungkin masih menunjuk ke objek tersebut. Objek-objek itu tetap berada di document object list, dan full save menuliskan semuanya kembali ke berkas. Hasilnya adalah keluhan yang memulai sebagian besar thread dukungan semacam ini: pelanggan menghapus sembilan puluh persen halaman, menyimpan, dan ukuran berkas hanya menyusut dua persen. Lebih parah lagi, kebocorannya bertumpuk. Muat, hapus, simpan, muat lagi, hapus lagi, simpan lagi, dan berkas terus membesar secara monoton sementara jumlah halaman terus berkurang. Ini adalah masalah yang berbeda dari yang diselesaikan oleh font subsetting dan image downsampling, yang memperkecil objek yang masih hidup. Di sini objeknya bukan terlalu besar. Objek-objek itu sekadar sudah bukan lagi bagian dari dokumen

Root set adalah trailer, bukan page tree

Object graph PDF tidak memiliki field reverse-reference. Format ini tidak mendefinisikan reference count maupun back-pointer list, dan key /Parent yang memang ada hanya berlaku untuk struktur tertentu seperti page tree, bukan untuk object graph secara keseluruhan. Tidak ada apa pun dalam indirect object yang memberi tahu siapa yang menunjuk ke sana, sehingga pertanyaan "apakah objek 47 masih dipakai" hanya punya satu jawaban: telusuri maju dari root yang diketahui dan lihat apakah sampai ke sana. Itulah sebabnya collector di losLab PDF Library adalah collector mark-sweep, bukan skema refcount

Root berasal dari file trailer (ISO 32000-1 §7.5.5). Tiga key yang membawanya: /Root, document catalog dari §7.7.2 tempat page tree, names, outlines, AcroForm, dan metadata semuanya bergantung; /Info, document information dictionary; dan /Encrypt, encryption dictionary. Dua key trailer yang tersisa hanyalah pengecoh. /ID adalah array berisi dua byte string, dan /Prev adalah integer byte offset ke cross-reference section sebelumnya. Keduanya bukan indirect reference, jadi keduanya tidak menyumbang root. losLab PDF Library memasukkan seluruh trailer dictionary ke antrean, bukan hanya tiga key bernama tersebut, yang tidak memakan biaya apa pun dan menjaga agar ekstensi trailer privat apa pun tetap hidup

Penelusurannya sendiri bersifat iteratif, bukan rekursif. Saat traversal menemui indirect reference, ia hanya mencatat object number dan generation, menandai slot yang bersesuaian, dan memasukkannya ke FIFO queue alih-alih langsung mendereferensi, sehingga page tree yang dalam dan rantai outline yang panjang tidak membebani call stack dan objek yang sama tidak didekode dua kali. Dictionary, array, dan stream dictionary langsung (direct) masuk ke antrean kedua yang dijaga oleh visited set, karena dokumen nyata memang mengandung siklus sungguhan: /Parent sebuah halaman menunjuk balik ke node page tree-nya, dan item outline berantai lewat /Prev dan /Next ke dua arah. Nomor generation adalah bagian dari pencocokan, bukan sekadar hiasan. Sebuah reference hanya resolve bila object number dan generation sama-sama cocok; reference ke nomor yang ada pada generation berbeda diperlakukan sebagai null object sebagaimana disyaratkan spesifikasi, tidak pernah sebagai edge yang hidup

Bagaimana cara mengaktifkan garbage collection saat save?

Garbage collection bersifat opt-in dan menjadi bagian dari save options record. Defaultnya False karena collector ini adalah pass yang destruktif atas object graph, dan tidak ada pustaka yang seharusnya diam-diam menghapus objek yang tidak pernah diminta caller untuk diperiksa

var
  Pdf: TPDFlib;
  Opt: TPDFlibSaveOptions;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
      Exit;
    Pdf.DeletePages(11, 490);          // keep the first ten pages

    FillChar(Opt, SizeOf(Opt), 0);
    Opt.CompressContent := True;
    Opt.CompressFonts := True;
    Opt.OptimizeContentStreams := True;
    Opt.PackObjectStreams := True;
    Opt.GarbageCollect := True;        // drop everything the pages left behind
    Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
  finally
    Pdf.Free;
  end;
end;

Dua entry point lain menuju collector yang sama. SetGarbageCollect(1) mengatur flag pada dokumen yang dipilih sehingga SaveToFile biasa akan menghormatinya, dan GarbageCollectObjects menjalankan pass secara langsung serta mengembalikan jumlah indirect object yatim yang dibuang. Bentuk langsung inilah yang dipakai bila Anda menginginkan angka untuk di-log atau di-assert, dan itu layak diperiksa, karena nilai kembalian negatif bukanlah sebuah hitungan

var
  Removed: Integer;
begin
  Pdf.DeletePages(11, 490);
  Removed := Pdf.GarbageCollectObjects;
  if Removed < 0 then
    // The graph could not be fully decoded. Nothing was swept and the
    // document is unchanged; save it without GC or reject the input.
    LogWarning('object graph incomplete, GC skipped')
  else
    LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;

Jalur kegagalan itu lebih penting daripada kelihatannya. Objek didekode secara lazy, dan objek yang belum pernah didekode sama sekali tidak memperlihatkan reference apa pun. Jika collector memperlakukan objek yang tidak bisa didekode sebagai node kosong, ia akan menyapu bersih segala sesuatu yang hanya bisa dijangkau lewat objek itu. Karena itu traversal memaksa dekode saat menyentuh setiap objek, dan satu saja error decode akan membatalkan seluruh pass dengan hasil negatif dan membiarkan dokumen tetap byte-identical. Menyapu graph yang hanya dipahami sebagian adalah cara sebuah collector mengubah berkas yang rusak menjadi berkas yang hancur

Apa yang membuat PDF collector naif jadi gagal?

Ada dua detail, dan keduanya gagal secara diam-diam, bukan dengan ribut. Yang pertama adalah object stream. Sejak PDF 1.5, objek non-stream boleh hidup terkompresi di dalam container /ObjStm (§7.5.7), dan entri cross-reference-nya berupa type 2 entry yang menamai container plus indeks di dalamnya. Objek terkompresi karena itu hanya bisa dijangkau lewat containernya. Tandai member-nya, sapu container-nya karena tidak ada yang mereferensikannya sebagai objek dokumen, dan Anda sudah menulis berkas yang xref-nya menunjuk ke objek yang sudah tidak ada lagi. Container adalah penyimpanan struktural, bukan data dokumen, sehingga ia tidak pernah muncul sebagai edge dalam object graph yang sedang ditelusuri. losLab PDF Library menangani ini dengan melepaskan setiap member terkompresi yang selamat dari container sumbernya sebelum container tersebut lenyap, setelah itu proses save mengemas ulang para penyintas ke dalam object stream yang baru. Detail kedua adalah apa yang sebenarnya direferensikan oleh sebuah stream object. Byte-nya bukan bagian dari graph. Content stream yang menggambar teks dengan /F1 12 Tf menyebut font lewat resource name, dan nama itu diresolusi lewat dictionary /Resources pada halaman, sehingga edge keterjangkauannya berjalan page → /Resources/Font → font object, tidak pernah lewat payload stream. Satu-satunya reference yang disumbang sebuah stream berasal dari dictionary-nya, tempat /Length, /Filter, dan /DecodeParms semuanya boleh berupa indirect. Collector yang mengurai byte stream demi mencari reference sedang melakukan kerja mahal untuk sesuatu yang sia-sia; collector yang melewatkan stream dictionary akan kehilangan length object dan merusak berkas

Apa yang terjadi pada nomor objek yang dibebaskan

Nomor-nomor itu menjadi free entries, dan tidak dipakai ulang dalam save yang sama. Proses sweep menelusuri object list dalam urutan menurun agar penghapusan tetap index-stable, membangun ulang lookup index sekali saja di akhir alih-alih setiap kali ada penghapusan, dan untuk setiap objek yang dihapus mencatat nomornya di free list dengan generation-nya dinaikkan satu, persis seperti yang disyaratkan §7.5.4 untuk entri yang mungkin dipakai ulang kelak. Generation yang sudah mencapai 65535 tetap di situ, menandai nomor tersebut sebagai pensiun permanen. Nomor objek sengaja tidak dipadatkan (compacted). Setelah sebuah collection, berkas tetap menyisakan lubang: objek 12 mungkin bebas sementara 13 dan 14 masih dipakai, dan /Size pada trailer tetap melaporkan angka tertinggi ditambah satu, bukan jumlah yang bertahan. Itu sah dan wajar. Menomori ulang hanya akan menghemat segelintir byte di cross-reference table dan akan memaksa penulisan ulang setiap reference dalam dokumen, jenis perubahan yang diam-diam membatalkan apa pun yang menyimpan nomor objek dari luar. Ukuran yang Anda dapat kembali berasal dari isi objek, bukan dari xref table

Kapan Anda tidak boleh menjalankan collector

Tidak pernah pada incremental update. Collector ini dibatasi hanya untuk full save, dan flag-nya memang tidak dibaca ketika dokumen sedang di-append, dan pembatasan itu bukan keterbatasan yang perlu diakali. Incremental update (§7.5.6) membiarkan byte asli tidak tersentuh dan menambahkan cross-reference section baru yang dirantai ke yang sebelumnya lewat /Prev. Setiap revisi sebelumnya tetap menunjuk ke objek yang selalu ditunjuknya, sehingga objek yang tidak terjangkau di revisi saat ini sangat mungkin masih terjangkau di revisi yang lebih lama. Menghapusnya akan merusak setiap revisi kecuali yang terakhir, dan mekanisme di baliknya dibahas di artikel tentang incremental update dan save mode-append. Alasan yang sama menyingkirkan garbage collection pada dokumen yang sudah ditandatangani, karena penulisan ulang penuh yang membuat collection mungkin dilakukan justru itulah yang membatalkan tanda tangannya

Penting juga untuk jelas soal apa yang bukan collection ini. Ini bukan sanitizer. Collector membuang objek yang tidak lagi direferensikan siapa pun; ia tidak punya pendapat soal apakah isinya sensitif, dan objek yang masih direferensikan tetap seperti apa adanya. Bila tujuannya membuat informasi tidak bisa dipulihkan, bukan sekadar memperkecil berkas, object graph adalah layer yang salah, dan redaction level-instruksi dan sanitasi dokumen adalah yang tepat. Keduanya justru berpadu baik dalam urutan ini: redact dan sanitasi lebih dulu, baru collect, sehingga objek yang dilepas oleh redaction benar-benar meninggalkan berkas. Perpaduan yang sama ada di resource purge API, tempat mengirim opsi garbage-collect membuat purge menjalankan collection setelahnya dan melaporkan objek yatim yang dibuang lewat OrphanObjectsRemoved

Satu kebiasaan terakhir yang layak diadopsi. Log nilai kembalian GarbageCollectObjects di batch job apa pun yang melakukan penghapusan halaman Anda, dan amati selama beberapa minggu pada dokumen nyata. Angka nol pada berkas yang baru saja Anda potong jadi separuh berarti ada sesuatu di hulu yang masih memegang reference yang tidak Anda duga, biasanya entri name tree, tujuan outline, atau field AcroForm yang bertahan meski halaman tempatnya melekat sudah hilang. Collector ini adalah reachability debugger termurah yang pernah Anda miliki, karena ia menjawab pertanyaan yang format PDF sendiri menolak untuk dijawab

Garbage collector, save-options record, dan resource purge API yang dibahas di sini adalah bagian dari losLab PDF Library untuk Delphi dan C++Builder, yang halaman produknya memuat referensi lengkap save-pipeline termasuk interaksi antara collection, object-stream packing, dan linearization