Gabungkan dua PDF secara manual, pindahkan satu objek halaman ke dokumen target, dan penyalinan itu langsung menabrak access violation. PDFlibPas memperbaikinya di CopyForeignObject: ia melakukan deep copy satu objek tak langsung ditambah seluruh closure referensinya, dan menyelesaikan back-reference bersiklus seperti /Parent menjadi null alih-alih melakukan rekursi
Mengapa menyalin satu halaman antar dokumen menyebabkan crash?
Karena pohon halaman PDF hanya sebuah pohon jika Anda membacanya dari atas ke bawah. Susuri pohon itu seperti yang dilakukan copier rekursif, mengikuti setiap nilai di setiap dictionary, dan dictionary halaman menyerahkan /Parent kepada Anda, yang menunjuk kembali ke node /Pages tempat Anda datang, dan node itu menyerahkan /Kids, yang menunjuk kembali ke halaman. ISO 32000-1 §7.7.3 mensyaratkan /Parent pada setiap node pohon halaman kecuali akar, sehingga ini bukan berkas rusak yang bisa Anda tolak — ini adalah bentuk normal dari setiap dokumen yang akan pernah Anda terima
Paruh kedua masalahnya adalah penomoran. Objek tak langsung diidentifikasi oleh nomor objek yang lokal terhadap satu berkas (ISO 32000-1 §7.3.10), sehingga objek yang ditarik dari dokumen A ke dokumen B harus diberi nomor ulang, dan setiap referensi ke objek itu di dalam closure yang disalin harus diberi nomor ulang dengan cara yang sama, kalau tidak dua referensi yang dulu menunjuk ke satu font bersama kini menunjuk ke dua hal yang tidak berhubungan. Penomoran ulang itu adalah pekerjaan yang sama dengan yang dilakukan merge cepat di tingkat byte, dan ada baiknya membaca keduanya berdampingan: penggeseran referensi tingkat byte untuk merge PDF cepat menyelesaikannya dengan mentranslasikan seluruh berkas, sementara penyalinan tingkat objek harus menyelesaikannya satu tepi dalam satu waktu
Apa yang sebenarnya disalin oleh CopyForeignObject milik PDFlibPas
TPDFlib.CopyForeignObject(SourceDocumentID, ObjectNumber) menggandakan satu objek tak langsung dan semua yang terjangkau darinya — dictionary bersarang, array, string, name, angka, dan stream beserta dictionary-nya yang utuh — ke dalam dokumen yang sedang dipilih saat ini, dan mengembalikan handle non-nol ke referensi tak langsung baru. Nomor objek sumber dipetakan ulang melalui map yang hidup selama durasi pemanggilan, sehingga objek yang terjangkau dua kali dalam closure digandakan sekali dan dipakai bersama dua kali. Ia mengembalikan nol, tanpa melempar exception, ketika ID dokumen sumber tidak dikenal, ketika sumber adalah dokumen terpilih itu sendiri, atau ketika ObjectNumber di bawah 1
var
Lib: TPDFlib;
SourceDoc, TargetDoc, Handle: Integer;
begin
Lib := TPDFlib.Create;
try
TargetDoc := Lib.NewDocument;
if Lib.LoadFromFile('source.pdf', '') <> 1 then
Exit; // LoadFromFile mengembalikan 1 saat sukses
SourceDoc := Lib.SelectedDocument; // proses load memilih apa yang ia muat
Lib.SelectDocument(TargetDoc); // penyalinan menyasar dokumen terpilih
Handle := Lib.CopyForeignObject(SourceDoc, 12);
if Handle = 0 then
raise Exception.Create('cross-document copy rejected');
finally
Lib.Free;
end;
end;
Dua detail menggigit orang pada run pertama. LoadFromFile menjawab 1 atau 0, bukan ID dokumen, sehingga handle yang Anda butuhkan datang dari SelectedDocument tepat setelah load; dan penyalinan selalu menulis ke dokumen yang terakhir dijadikan current oleh SelectDocument, tidak pernah ke dokumen yang Anda muat. Secara internal, rekursi juga membawa batas kedalaman keras 64, yang merupakan backstop terhadap nesting patologis, bukan mekanisme yang menangani siklus — penanganan siklusnya terpisah dan disengaja
Mengapa mencadangkan mapping Nil tidak memutus siklusnya?
Karena Nil di tabel mapping berarti dua hal berbeda sekaligus, dan kode tidak bisa membedakannya. Pertahanan yang tampak jelas terhadap siklus adalah menambahkan entri map sebelum merekursi ke objek, sehingga apa pun yang berputar kembali menemukan entri itu dan berhenti. Tetapi entri itu belum bisa menampung target sebenarnya — target tidak ada sampai closure di bawahnya selesai ditulis — jadi ia menampung Nil, dan pencarian yang seharusnya menangkap back-edge membaca Nil lalu menyimpulkan objek itu belum pernah dipetakan
// Rusak: target Nil cadangan tak terbedakan dari "belum dipetakan"
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // dicadangkan, masih Nil
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // di-backfill hanya saat keluar
end;
Ikuti itu melalui loop halaman. Klon halaman mencapai /Parent, merekursi ke node /Pages, yang mencapai /Kids, yang merekursi kembali ke halaman — yang entri cadangannya masih terbaca Nil, sehingga ia digandakan kedua kalinya, dan ketiganya, setiap level mendorong satu frame baru dan satu objek setengah jadi yang baru. Yang Anda amati pun bukan stack overflow yang bersih: frame-frame luar sedang menumpang pada referensi yang targetnya tidak pernah ditugaskan, sehingga penulisan pertama lewat salah satu slot itu adalah access violation di tempat yang tidak tampak seperti penyalinan halaman yang menyebabkannya
Perbaikannya: status in-progress yang eksplisit
Perbaikannya adalah berhenti menumpangkan makna pada Nil dan mengajukan pertanyaannya secara langsung. Entri map yang targetnya masih belum ditugaskan berarti objek ini sedang digandakan, dan predikat InProgress menguji tepat hal itu sebelum pencarian biasa berjalan. Ketika benar, tepi itu adalah siklus kembali ke leluhur klon saat ini, dan PDFlibPas mengeluarkan objek null untuknya alih-alih mengikutinya
// Entri map dengan target Nil menandai klon in-progress
function InProgress(Num: Integer): Boolean;
var
I: Integer;
begin
Result := False;
for I := 0 to High(Map) do
if (Map[I].SourceObjNum = Num) and (not Assigned(Map[I].Target)) then
Exit(True);
end;
// ... di dalam CloneObject, untuk satu referensi tak langsung:
if InProgress(SrcRef.ObjNum) then
Exit(FStructure.NewNull); // back-edge siklik, jangan rekursi
NewRef := FindMapped(SrcRef.ObjNum);
if not Assigned(NewRef) then
begin
SrcInd := SourceDoc.FindObj(SrcRef.ObjNum, SrcRef.GenNum);
if (not Assigned(SrcInd)) or (not Assigned(SrcInd.Obj)) then
Exit(FStructure.NewNull); // referensi sumber menggantung
SetLength(Map, Length(Map) + 1);
Map[High(Map)].SourceObjNum := SrcRef.ObjNum;
Map[High(Map)].Target := nil; // cadangkan, lalu rekursi
NewRef := NewObjRef(CloneObject(SrcInd.Obj, Depth + 1));
Map[High(Map)].Target := NewRef; // backfill
end;
Exit(NewRef);
Ini aman digeneralisasi hanya karena satu fakta struktural tentang PDF: siklus di graf objek muncul pada back-link, bukan pada tepi konten. /Parent di pohon halaman dan /Prev di rantai outline menunjuk ke atas atau ke belakang ke sesuatu yang sudah dikunjungi; closure sebuah font, XObject gambar, atau XObject formulir berjalan ke bawah dan berhenti. Jadi penyalinan font descriptor, color space, atau dictionary shading tidak terpengaruh oleh substitusi null — tidak ada dalam closure itu yang pernah menyentuh InProgress. Biayanya, dinyatakan gamblang, adalah bahwa tepi siklik tidak selamat dari penyalinan. Dictionary halaman yang digandakan dengan cara ini tiba dengan /Parent sebagai objek null, yang oleh ISO 32000-1 §7.3.9 disetarakan dengan entri yang absen, sehingga halaman hasil salinan adalah objek valid yang tidak terikat pada pohon halaman mana pun sampai Anda menautkannya ke node /Pages target dan memperbaiki /Count sendiri. Item outline hasil salinan kehilangan /Prev-nya dengan cara yang sama dan membutuhkan rantai sibling dibangun ulang. Itulah pertukaran yang jujur: CopyForeignObject memberi Anda closure yang benar dan menyisakan re-parenting struktural kepada pemanggil, yang merupakan batas kerja yang sama dengan mengganti halaman sambil mempertahankan nomor objek
Mengapa entri map harus dicadangkan sebelum NewObjRef
Alternatif yang tampak jelas akan melewati seluruh tarian in-progress: alokasikan objek cangkang kosong lebih dulu, daftarkan nomor aslinya di map, lalu isi cangkang itu setelah anak-anak digandakan. Itu tidak bisa dilakukan di sini, karena TPDFIndObj.Obj bersifat read-only dan isinya tidak bisa diganti setelah konstruksi — tidak ada cangkang untuk diisi. Nomor dan isi diputuskan bersama oleh NewObjRef, yang berarti entri map harus dibuat sebelum panggilan rekursif dan dilengkapi setelahnya, dan selang di antara dua momen itulah yang harus dicakup oleh InProgress. Satu konsekuensi yang layak diketahui sebelum Anda membedakan output: karena NewObjRef berjalan setelah closure anak selesai ditulis, penomoran di target keluar dari bawah ke atas, dan nomor objek tidak akan mencerminkan urutan sumber. Tidak ada di format berkas yang peduli, tetapi perbandingan byte terhadap ekspektasi buatan tangan akan peduli. Jika ada run yang menyisakan objek yang Anda putuskan tidak ditautkan ke apa pun, objek itu unreferenced alih-alih rusak, dan pengumpulan mark-and-sweep objek PDF yang tak terjangkau adalah alat yang membersihkannya sebelum menyimpan
Regresi yang mencakup ini butuh satu detail yang mengejutkan orang yang menulis tes terhadap TPDFlib: konstruktor sudah memegang satu dokumen default, sehingga DocumentCount dimulai dari 1 dan fixture dua dokumen harus meng-assert >= 2, bukan = 2. Di samping penyalinan yang sukses, tes menambatkan tiga penolakan — ID sumber yang tidak dikenal, dokumen terpilih sebagai sumbernya sendiri, dan nomor objek nol — semuanya mengembalikan 0 alih-alih melempar exception, karena loop merge adalah tempat yang buruk untuk mengetahui bahwa guard clause melempar
Di mana posisi ini dalam pipeline merge
Penyalinan tingkat objek adalah primitif yang Anda ambil ketika penggabungan seluruh berkas terlalu kasar: mengangkat satu program font keluar dari template, menarik satu XObject formulir ke dokumen stempel, atau memindahkan satu anotasi beserta appearance stream-nya lintas berkas tanpa menyeret sisa halamannya. PDFlibPas mengeksposnya sebagai satu panggilan terhadap dokumen yang dimuat, dan Anda bisa melihat posisinya di sisa API objek tingkat rendah pada referensi PDFlibPas Delphi PDF Library