Saat HotPDF Delphi Component memuat file PDF 1.5 dengan LoadFromFile, ia tidak memparse objek yang dipak di dalam container /Type /ObjStm. Ia mencatat di mana setiap member terkompresi berada dan memparsenya hanya ketika ada yang memintanya. Invarian malas itulah yang menjaga waktu pemuatan sebanding dengan apa yang benar-benar Anda sentuh, dan itu juga alasan sebuah rewrite penuh harus melakukan satu pekerjaan tambahan sebelum byte apa pun keluar: mengekspansi setiap member yang masih belum diparse, karena rewrite-nya akan membuang container tempat member itu tinggal
Gejalanya yang memicu catatan ini mudah dijelaskan dan tidak nyaman di-debug. Muat file yang font, color space, dan structure tree-nya berada di object stream, jalankan lewat pasangan generasi BeginDoc dan EndDoc, dan outputnya terbuka tanpa keluhan. Jumlah halamannya benar, teksnya terlihat di halaman yang Anda cek. Lalu seorang kolega membuka halaman 40 dan badan teksnya terender dengan font pengganti, atau perintah Extract Text mengembalikan sampah di tempat yang tadinya berisi pengganti ActualText. Tidak ada yang crash. Writer-nya sekadar menyerialkan objek yang tidak pernah dimuat, dan objek yang tidak dimuat akan terserialisasi sebagai ketiadaan
Apa yang sebenarnya disimpan LoadFromFile untuk objek terkompresi?
Untuk setiap entry cross-reference tipe 2, LoadFromFile menyimpan record kecil di FCompactObjects: nomor objek, indeks stream pemuatnya di tabel container, posisi member di dalam stream itu, dan pointer ParsedObject yang awalnya nil. Container-nya sendiri ditemukan, didekripsi kalau dokumennya terenkripsi, dan di-inflate, tapi body member-nya dibiarkan sebagai byte. ISO 32000-1 §7.5.7 mendefinisikan tata letak container yang memungkinkan ini: header berisi pasangan nomor objek dan offset, lalu body member disambung-sambung setelah /First, jadi member mana pun bisa diiris keluar tanpa menyentuh tetangganya
EnsureCompressedObjectLoaded adalah satu-satunya jalur yang mengubah record jadi objek. Ia mencari record-nya berdasarkan nomor objek, dan kalau ParsedObject-nya sudah terisi ia mengembalikan objek yang di-cache itu dan menghitung satu cache hit. Kalau tidak, ia memuat ulang container-nya bila sudah dievict, menghitung rentang byte member-nya dari tabel offset, menyerahkan ke parser sebuah view zero-copy atas irisan itu, dan menyimpan hasilnya kembali ke record-nya. Sejak itu objeknya bersifat tak langsung, membawa nomor objek yang sebenarnya, dan terdaftar di indeks objek dokumen seperti objek yang diparse dari badan file. Catalog, dictionary info, root page tree, dan objek halaman melewati jalur ini saat pemuatan karena navigasi membutuhkannya. Font, color space, dictionary ExtGState, dan elemen struktur tidak, dan mereka tetap berupa record sampai render halaman atau rewrite menyentuhnya
Anda bisa mengamati ini dari luar. GetLoadedObjectStreamCacheInfo melaporkan berapa banyak container yang ada, berapa banyak member yang terindeks, dan berapa di antaranya yang sudah diparse sejauh ini:
var
Pdf: THotPDF;
Info: THPDFObjectStreamCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('tagged-report.pdf');
if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
Writeln(Format('%d containers, %d members indexed, %d parsed so far',
[Info.ContainerCount, Info.IndexedObjectCount,
Info.MaterializedObjectCount]));
finally
Pdf.Free;
end;
end;
Pada file yang berat struktur, angka ketiga hanya sebagian kecil dari angka kedua tepat setelah pemuatan. Selisih itulah inti dari lazy loading, dan itu juga persis himpunan objek yang harus diambil kembali oleh rewrite penuh
Kenapa rewrite penuh membuang font yang tetap disimpan incremental save?
Rewrite penuh membuang container /ObjStm dan /XRef milik file sumber lalu menyerialkan ulang object graph-nya dari nol, jadi member mana pun yang ParsedObject-nya masih nil tidak punya representasi tersisa di output. Incremental update tidak pernah punya masalah ini, karena ia menambahkan objek baru setelah byte aslinya dan membiarkan container lama di tempatnya agar bisa dialamati section cross-reference sebelumnya. Perbedaannya bukan pada cara kedua mode memperlakukan font. Perbedaannya pada apakah container aslinya selamat untuk dibaca viewer berikutnya
Perbaikannya berada di SaveToStream, serializer yang digerakkan EndDoc entah Anda menyetel FileName atau OutputStream. Sebelum ia mengirim ke cabang writer mana pun, ia menelusuri FCompactObjects dan memanggil EnsureCompressedObjectLoaded pada setiap entri. Kalau ada member yang tidak bisa dimuat, save-nya melempar error alih-alih melanjutkan, karena rewrite yang diam-diam membuang dictionary font lebih buruk daripada rewrite yang berhenti. Ekspansinya harus berada di level itu, di atas cabang classic, packed, dan linearized, dan di atas pemangkasan structural stream hasil muat ulang milik jalur linearized. Versi sebelumnya hanya mengekspansi member di dalam SaveLoadedDocument, yang mencakup kosakata loaded-document dan sama sekali melewatkan kosakata generasi. LoadFromFile yang diikuti BeginDoc, penyuntingan halaman, dan EndDoc langsung menuju writer dengan setiap member yang tak tersentuh masih belum diparse
// Kedua kosakata rewrite sekarang mengekspansi member kompak sebelum writer mana pun berjalan.
// Jalur loaded-document:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');
// Jalur generasi di atas file yang sudah dimuat:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc; // SaveToStream mematerialisasi setiap entri FCompactObjects lebih dulu
Member yang di-cache mempertahankan apa pun yang Anda lakukan padanya. Objek yang sudah diparse, disunting, dan ditandai kotor sebelum save dikembalikan dari cache beserta suntingannya, dan member yang Anda hapus mempertahankan status penghapusannya melewati save berulang. Pass ekspansinya idempoten secara konstruksi: ia hanya pernah mengisi slot nil
Kenapa pemeriksaan piksel di tiga halaman melewatkan kasus ActualText
Elemen struktur adalah tempat bug ini bersembunyi paling lama. Entri ActualText pada sebuah marked-content sequence, yang didefinisikan di ISO 32000-1 §14.9.4, menggantikan glyph untuk ekstraksi dan aksesibilitas tapi tidak memengaruhi rendering. Kalau elemen strukturnya berada di object stream dan rewrite-nya kehilangan elemen itu, halamannya tetap tergambar dengan benar, halaman pertama, tengah, dan terakhir bandingannya sama piksel per piksel dengan sumbernya, dan regresinya baru muncul saat ada yang menjalankan ekstraksi teks atau screen reader. Test rewrite yang hanya merender halaman bukan test rewrite untuk PDF bertag. Diff juga teks yang terekstrak dan structure tree-nya
Bagaimana password pengguna yang kosong mengubah pemuatannya?
Password pengguna yang kosong tetap berarti file-nya terenkripsi, dan object stream di file semacam itu berupa ciphertext sampai file key-nya dipulihkan. ISO 32000-1 §7.6.3.4 Algoritma 2 menurunkan key itu dari password, entri /O, /P, dan identifier dokumen pertama, dan HotPDF harus menjalankannya terhadap string kosong sebelum pass tipe 2 bisa meng-inflate satu container pun. Itulah kenapa BeginDoc pada dokumen terenkripsi yang dimuat memanggil DecryptLoadedDocument dengan password kosong sebelum apa pun yang lain: object graph-nya harus terautentikasi dan terdekripsi sebelum rewrite bisa dimulai, terlepas dari apakah pemanggilnya berniat melindungi outputnya. Enkripsi output adalah keputusan terpisah, digerakkan oleh setelan proteksi milik pemanggil, dan BeginDoc memulihkan setelan itu setelah pass dekripsi supaya input terenkripsi tidak diam-diam berubah jadi output terenkripsi
Kebijakan container-nya dibaca dari dictionary /Encrypt sebelum password apa pun dicoba. Untuk /V 1 dan 2 setiap stream dienkripsi dengan file key. Untuk crypt filter, HotPDF meresolusi /StmF lewat /CF: filter Identity atau /CFM bernilai None berarti container plaintext, sedangkan V2 dan AESV2 berarti container terenkripsi. Jawabannya mendarat di FReloadObjectStreamsEncrypted, dan itu penting untuk satu kasus spesifik. Ketika container-nya plaintext tapi string-nya tidak, member-nya membawa string terenkripsi yang harus didekripsi satu per satu, jadi MaterializeMembersOfPlaintextObjectStreams mengekspansi setiap member kompak sebelum pass dekripsi per objek. Ia tidak melakukan apa pun ketika kebijakannya belum diketahui dan tidak melakukan apa pun ketika container-nya sendiri yang terenkripsi, karena member dari container terenkripsi sudah ikut terdekripsi bersamanya dan tidak boleh didekripsi dua kali
Apa yang terjadi ketika container tidak bisa didekripsi?
Container yang gagal didekripsi dikarantina, bukan dimatikan. Pass tipe 2 mencatat entri THPDFObjStmQuarantineInfo di FObjStmQuarantine beserta nomor objek container-nya, sebuah THPDFObjStmQuarantineReason, string diagnostik, dan daftar nomor objek member yang diarahkan cross-reference ke sana. osqrDecryptFailed muncul untuk empat situasi berbeda: tidak ada crypt filter yang bisa diresolusi, dekripsi AES-256 atau AES-GCM melempar error, dekripsi RC4 atau AES-128 lama melempar error, atau tidak ada file key yang bisa dipakai sama sekali. Container yang independen tetap dimuat, jadi dokumen dengan satu container rusak tetap terbuka dan tetap merender setiap halaman yang tidak bergantung padanya
Daftar karantinanya bertahan melewati parser fallback. Kalau pemuatan cross-reference utama gagal dan HotPDF merekonstruksi tabel objeknya dengan memindai file, flag encrypted dari percobaan pertama mungkin tidak selamat dari rekonstruksi itu, tapi record karantinanya selamat. Itulah kenapa BeginDoc memeriksa daftar karantina alih-alih flag encrypted: pada dokumen yang dimuat ia menelusuri FObjStmQuarantine dan melempar error pada entri osqrDecryptFailed pertama, menyebut container-nya dan meminta pemuatan ulang dengan password yang valid. Rewrite yang melanjutkan melewati titik itu akan menulis member yang seharusnya dipegang container itu sebagai objek kosong lalu melaporkan sukses. Anda bisa menjalankan pemeriksaan yang sama sendiri, lebih awal dan dengan kebijakan Anda sendiri, lewat accessor publiknya:
var
Info: THPDFObjStmQuarantineInfo;
I: Integer;
begin
Pdf.LoadFromFile('vendor-form.pdf'); // password pengguna kosong
for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
(Info.Reason = osqrDecryptFailed) then
raise Exception.CreateFmt(
'Object stream %d is unreadable (%s); %d members unresolved',
[Info.ContainerObjNum, String(Info.Diagnostic),
Length(Info.MemberObjNums)]);
// aman untuk rewrite dari sini
end;
Alasan karantina lain mencakup kegagalan non-kriptografis: container yang bukan stream, dictionary yang hilang, /N atau /First yang tidak valid, ukuran stream di luar rentang yang diterima, kegagalan dekompresi, /First yang menunjuk melewati datanya, atau body member yang terdekode tapi tidak terparse. Semuanya layak dicatat di log saat ingest, karena masing-masing menyebut persis member mana yang akan hilang di hilir
Kenapa rewrite butuh token numerik aslinya?
HotPDF menyimpan setiap objek numerik sebagai Single, dan Single tidak bisa mereproduksi teks sumber sebuah bilangan real. ISO 32000-1 §7.3.3 mengizinkan writer mengeluarkan 0.750000, .75, atau 0.75 untuk nilai yang sama, dan tidak satu pun dari itu selamat melewati perjalanan bolak-balik lewat biner 24-bit dan formatter generik tanpa berubah. Lebih buruk lagi, nilai seperti 0.7 sama sekali tidak terwakili di Single; ia terparse ke float terdekat, dan memformat ulang float itu bisa menghasilkan 0.69999999 atau tetangga yang dibulatkan tergantung loop digitnya. Pada fill color atau konstanta transparansi /CA, itu selisih satu hitungan di kanal 8-bit, cukup untuk menggagalkan perbandingan piksel terhadap sumbernya dan, di batas gradient, cukup untuk terlihat
THPDFNumericObject.RememberSourceToken menyelesaikan ini untuk kasus yang tidak dimodifikasi. Parser memanggilnya dengan token mentahnya tepat setelah menugaskan Value; method-nya hanya menerima token yang terdiri dari digit, paling banyak satu titik desimal, dan tanda di depan yang opsional, lalu menyimpan token itu beserta nilai yang bersesuaian dengannya di FSourceValue. Properti SourceToken mengembalikan teks tersimpannya hanya selama Value masih sama dengan FSourceValue. Ubah angkanya dan token-nya menguap, jadi nilai yang dimodifikasi selalu lewat jalur pemformatan yang ada dan tidak pernah mengeluarkan teks basi. SaveNumericObject memeriksa SourceToken lebih dulu dan menuliskannya apa adanya bila ada, lalu jatuh ke cabang integer, referensi color space, dan fraksional hanya untuk angka yang dibuat atau disunting di memori
Invariannya kecil dan layak dinyatakan terang-terangan: angka yang tidak Anda sentuh ditulis dengan byte yang dipakai saat membacanya, dan angka yang Anda sentuh ditulis oleh formatter HotPDF sendiri. Member kompak mendapat manfaat yang sama seperti objek di badan file, karena EnsureCompressedObjectLoaded menjalankan parser yang sama atas irisan member-nya. Pemformatan angkanya sendiri, dan kemandiriannya dari locale proses, dibahas di artikel tentang pemformatan angka PDF yang invarian terhadap locale di HotPDF
Menguji jalur rewrite terhadap object stream
Tiga pemeriksaan menangkap setiap kegagalan yang dijelaskan di atas, dan tak satu pun butuh Acrobat. Pertama, bandingkan IndexedObjectCount dengan MaterializedObjectCount setelah save; pada rewrite penuh keduanya harus sama, dan selisih apa pun berarti ada member yang dibuang. Kedua, ekstrak teks dan enumerate structure tree-nya di kedua file, bukan hanya merendernya, supaya ActualText yang hilang atau elemen struktur yang hilang muncul sebagai diff. Ketiga, muat outputnya dengan instance baru lalu pastikan GetLoadedQuarantinedObjStmCount nol, yang sekaligus membuktikan writer-nya tidak menghasilkan container yang tidak bisa dibuka reader-nya. Kombinasi crypt filter yang menentukan FReloadObjectStreamsEncrypted diuraikan di artikel kebijakan StmF, StrF, dan EFF. Sisi writer dari cerita ini, yaitu cara mengeluarkan object stream dan kapan lebih memilih incremental update daripada rewrite, ada di panduan object stream dan incremental update
Pemuatan member secara malas, pass ekspansi sebelum writer, karantina dekripsi, dan pelestarian token sumber semuanya dikirim dalam HotPDF Delphi Component untuk Delphi dan C++Builder. Halaman produknya menautkan referensi API kalau Anda ingin menelusuri GetLoadedObjectStreamCacheInfo dan accessor karantinanya terhadap pipeline ingest Anda sendiri