HotPDF Delphi Component menghasilkan output PDF yang identik byte per byte lintas save ketika properti ReproducibleOutput bernilai True: ia memaku /CreationDate dan /ModDate di Info ke tanggal tetap, mengganti identifier dokumen berbasis jam dinding dengan hash berbasis seed atau turunan konten, mengganti dengan konstanta setiap byte acak yang tadinya akan ditarik jalur enkripsi AES, dan mengurutkan setiap dictionary yang diserialkannya. Flag-nya ada untuk suite regresi dan perbandingan artefak build, bukan untuk dokumen produksi, dan alasan di balik batas itu justru bagian yang menarik. Skenario yang mendorong fitur ini adalah golden-file test. Anda merender invoice, meng-commit PDF-nya, lalu memastikan build besok menghasilkan byte yang sama. Tidak pernah sama. File-nya terbuka mulus di semua viewer, teksnya identik, page tree-nya identik, dan diff-nya tetap menyala di empat atau lima tempat. Siapa pun yang pernah mencoba menaruh generator PDF di bawah test regresi tingkat byte sudah menabrak dinding ini, dan solusinya bukan "buang timestamp-nya" melainkan pendataan yang tepat atas setiap tempat writer-nya menyimak sesuatu selain dokumennya sendiri
Kenapa dua save atas PDF yang sama berbeda?
Dua save atas dokumen yang sama berbeda karena writer PDF, termasuk HotPDF, menyimak empat sumber entropi yang tidak berhubungan dengan konten halaman: jam dinding, identifier dokumen, generator angka acak kriptografis, dan urutan memori entri dictionary. Masing-masing sah dengan sendirinya. ISO 32000-1 memang menginginkannya di sana. Mereka sekadar membuat file-nya jadi fungsi dari kapan dan di mana ia ditulis alih-alih dari apa yang dikandungnya
- Jam. Dictionary Info membawa
/CreationDatedan/ModDate(ISO 32000-1 §14.3.3, Tabel 317) sebagai stringD:YYYYMMDDHHmmSSdengan akhiran zona waktu (§7.9.4), dan paket XMP mengulang instan yang sama sebagaixmp:CreateDatedanxmp:ModifyDate. HotPDF menstempel keduanya dariFCreationDate, yang diinisialisasi konstruktornya keNow, jadi kedua save berbeda pada detik penulisannya - Identifier. Array
/IDdi trailer (ISO 32000-1 §14.4) memegang identifier permanen dan identifier modifikasi. Resep default HotPDF menghash nama file bersama waktu saat itu sampai ke milidetik untuk elemen pertamanya, dan menghash itu plusGetTickCountuntuk yang kedua. Dua identifier, dua nilai segar di setiap run - Byte acak. Keamanan standar bergantung pada identifier dan pada keacakan yang sungguh acak. Untuk AES-256, file encryption key, salt validasi dan salt key, serta setiap initialization vector CBC ditarik dari sumber acak sistem (ISO 32000-2 §7.6.4.4.7 mensyaratkan salt acak). Karena
/U,/UE,/O, dan/OEsemuanya dihitung dari byte itu, dokumen terenkripsi berubah seluruhnya bahkan ketika plaintext-nya tidak. Algoritma yang lebih lama melipat elemen/IDpertama ke dalam key-nya (ISO 32000-1 §7.6.3.3, §7.6.3.4), jadi identifier yang segar saja sudah cukup untuk me-rekey file-nya - Urutan. Dictionary PDF adalah pemetaan tak berurut, dan writer yang menelusuri list di memorinya mengeluarkan key sesuai urutan penyisipannya. Jalur kode mana pun yang membangun dictionary resource dengan urutan berbeda, atau dokumen termuat yang diparse dari tata letak berbeda, menghasilkan file yang legal tapi berbeda secara tekstual
Apa saja yang dipaku ReproducibleOutput?
Menyetel ReproducibleOutput := True sebelum BeginDoc atau sebelum SaveLoadedDocument mengganti keempat sumber itu dengan nilai tetap, dan ia melakukannya di jalur kode yang sama yang tadinya akan meraih jam atau generator acaknya, jadi tidak perlu pass pembersihan terpisah. Perhatikan apa yang tidak ada di daftar di atas: kontennya. Font, page stream, data gambar, dan tabel cross-reference sudah deterministik untuk input yang sama; noise-nya sepenuhnya ada di metadata dan lapisan keamanan, itulah kenapa satu properti bertarget bisa menghapusnya. Properti ini default-nya False dan tidak ada apa pun di library yang menyalakannya untuk Anda
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // sebelum BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Di dalam BeginDoc cabang reproducible-nya menugaskan FCreationDate := EncodeDate(2026, 1, 1) dan memberi seed identifier dokumennya dengan MD5CalcString('HotPDF-reproducible-seed') alih-alih digest nama-file-plus-jam. Satu penugasan itu mencakup kedua tanggal Info dan kedua tanggal XMP, karena keempatnya dirender dari field yang sama. Ketika file-nya akhirnya ditulis, BuildDocumentIdentifiers meminta ComputeCanonicalDocumentIdentifier untuk identifier trailer-nya: ia mengekspor seluruh object graph dalam urutan kanonik, menolkan digit setiap string tanggal D: yang ditemuinya supaya timestamp-nya tidak bocor kembali lewat hash, lalu mengambil MD5 dari hasilnya. Kedua elemen /ID menerima nilai itu. Identifier turunan konten yang sama dipakai ketika dokumen yang dimuat dienkripsi tanpa pernah melewati BeginDoc, dan itulah kasus ActivateProtection pada file yang Anda buka dengan LoadFromFile
Byte acaknya adalah substitusi yang paling tidak kentara. Rutin key AES-256 membungkus sumber acaknya dalam helper lokal yang, di bawah flag itu, memanggil FillChar(P^, Count, $5A) untuk file encryption key 32 byte dan untuk setiap salt 8 byte, dan encryptor string serta stream AES-128 dan AES-256 beralih dari AESGenerateRandomIV ke AESGenerateStaticIV, yang mengisi initialization vector-nya dengan 14 * (1 + I) untuk slot I. Dengan key, salt, dan vector-nya semuanya tetap, /U, /UE, /O, /OE, dan setiap stream terenkripsi keluar identik di run kedua. Terakhir, SaveToStream menyalakan DeterministicDictionaryOrder setiap kali flag reproducible-nya aktif, dan serializer-nya lalu meng-insertion-sort setiap dictionary berdasarkan byte mentah nama key-nya, prefix yang lebih pendek lebih dulu, dengan indeks aslinya sebagai pemutus seri. Itu urutan yang sama dengan yang dipakai writer diagnostik, yang dijelaskan di artikel tentang menyunting PDF dengan tangan lalu memperbaikinya; flag reproducible-nya hanya meminjam urutannya, bukan sisa tata letak teks polos writer itu
Kenapa tanggal tetapnya masih membocorkan jam dinding?
Perbaikan v2.752.2 ada karena tanggal pembuatan tetapnya awalnya diputuskan di konstruktor, dan konstruktor tidak bisa tahu properti yang belum disetel pemanggilnya. Urutan pemanggilan yang normal adalah Create, lalu ReproducibleOutput := True, lalu BeginDoc. Pada saat konstruksi FReproducibleOutput masih False, jadi FCreationDate menerima Now dan menyimpannya. Identifier dan byte acaknya dipaku dengan benar, jadi kedua file sepakat hampir di semua tempat dan berselisih tepat di dua string tanggal dan dua field XMP. Memindahkan penugasannya ke cabang reproducible di BeginDoc, bersebelahan dengan identifier ber-seed, menaruh keputusannya di titik di mana propertinya sudah bernilai final
Test regresi yang melewatkan ini lebih bernilai daripada perbaikannya. Dua save yang keduanya berjalan di dalam detik jam dinding yang sama menulis string D: yang sama secara kebetulan, dan perbandingan byte-nya lolos untuk bug yang gagal di mesin mana pun yang lebih lambat. Test yang diperbaiki itu sleep 1100 ms di antara kedua save-nya supaya timestamp PDF-nya dijamin melewati batas detik, menjalankan kasusnya untuk output plain, AES-128, dan AES-256 dengan password sungguhan pada dua varian terenkripsinya, lalu membandingkan kedua buffer dengan CompareMem, melaporkan offset pertama yang berbeda saat gagal supaya diff-nya menunjuk objek tertentu alih-alih seluruh file. Perbandingan byte membuktikan determinisme dan tidak membuktikan apa pun selain itu, jadi simpan assertion terpisah yang memuat ulang output terenkripsinya dengan password pengguna lalu membaca jumlah halamannya; perubahan yang membuat file-nya stabil sekaligus tidak bisa dibaca tidak boleh lolos hanya karena diff-nya hijau
function SaveOnce(const Target: string): TBytes;
var
Pdf: THotPDF;
Stream: TFileStream;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := aes256;
Pdf.ActivateProtection := True;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
try
SetLength(Result, Stream.Size);
if Stream.Size > 0 then
Stream.ReadBuffer(Result[0], Stream.Size);
finally
Stream.Free;
end;
end;
// di badan test-nya
A := SaveOnce(PathA);
TThread.Sleep(1100); // paksa detik timestamp PDF yang berbeda
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
'two saves under ReproducibleOutput must be byte-identical');
Apakah PDF terenkripsi yang reproducible tetap aman?
Tidak. Dokumen yang dienkripsi di bawah ReproducibleOutput tidak terlindungi dalam arti yang berarti, dan flag-nya harus mati untuk apa pun yang keluar dari direktori test. File encryption key AES-256-nya adalah tiga puluh dua byte $5A, salt-nya delapan byte $5A, dan initialization vector-nya mengikuti pola aritmetika yang sudah dipublikasikan. Password-nya masih menjadi gerbang bagi wrapper /UE dan /OE, tapi key yang dibungkusnya adalah konstanta, jadi siapa pun yang tahu konstantanya bisa mendekripsi setiap content stream tanpa password sama sekali. Salt yang tetap juga menghapus keunikan per dokumen yang diandalkan ISO 32000-2 §7.6.4.4.7 untuk mencegah password identik menghasilkan string /U yang identik lintas file. Baca artikel penyiapan AES-256 untuk tahu apa yang dijanjikan properti enkripsinya ketika sumber acaknya utuh; di bawah flag reproducible, janji-janji itu ditangguhkan
Kompromi identifier-nya lebih halus. ISO 32000-1 §14.4 bermaksud agar elemen /ID kedua berubah pada setiap modifikasi supaya alat bisa membedakan file yang diperbarui dari leluhurnya, dan save reproducible menulis nilai yang sama ke kedua slotnya. Karena nilai itu adalah hash dari object graph kanonik, dua dokumen dengan konten berbeda tetap mendapat identifier yang berbeda, dan itu lebih baik daripada konstanta. Tapi seed yang dipakai BeginDoc untuk derivasi key-nya adalah string yang sama untuk setiap dokumen di setiap mesin, dan reader yang berpatokan pada /ID untuk membedakan file, misalnya cache anotasi atau sidecar data form, akan menyamakan setiap file reproducible yang kebetulan menghasilkan hash yang sama
Apa yang tidak dicakup flag ini?
ReproducibleOutput menghapus entropi yang dimasukkan writer-nya sendiri; ia tidak bisa menghapus entropi yang masuk lewat lingkungan atau lewat jalur kode yang tidak dikendalikannya, dan tiga di antaranya mudah tersandung
- Akhiran zona waktu.
_DateTimeToPdfDatemenambahkan offset UTC lokal, jadiD:20260101000000+08'00'di satu build agent danD:20260101000000-05'00'di agent lain adalah byte yang berbeda untuk tanggal tetap yang sama. Reproduksibilitasnya berlaku lintas run di satu mesin, atau lintas mesin yang berbagi zona waktu; paku zona agent-nya kalau golden file Anda berpindah tempat - Incremental update.
SaveIncrementalUpdatemenghitung identifier modifikasinya dari path target,GetTickCount, dan waktu saat itu tanpa cabang reproducible, karena section inkremental menurut definisinya adalah modifikasi baru. Bandingkan rewrite penuh, bukan delta yang ditambahkan - Shortcut passthrough.
SaveLoadedDocumentbiasanya menyalin file sumber yang tidak dimodifikasi dan tidak terenkripsi byte per byte alih-alih menyerialkannya ulang. Flag reproducible-nya menonaktifkan shortcut itu dan memaksa rewrite penuh supaya aturan urutan dan identifier-nya berlaku, yang berarti save reproducible atas file yang dimuat lebih lambat daripada default-nya dan tidak pernah berupa salinan inputnya. Diff-kan terhadap save reproducible sebelumnya, jangan pernah terhadap file aslinya
Satu pelajaran lagi dari rilis yang sama, soal apa yang dibuktikan dan tidak dibuktikan oleh pemeriksaan yang lolos. Sebuah fixture test PDF/X-6 memanggil CharProcs.DeleteValue('A'), yang meng-free glyph stream yang dipegang langsung, lalu menyisipkan kembali pointer yang sama, dan secara terpisah menyerahkan satu objek ExtGState langsung ke dictionary resource sekaligus ke sebuah pattern. Validator konformansinya lolos secara intermiten pada use-after-free dan kepemilikan ganda itu karena ia membaca apa pun yang kebetulan tersimpan di memori yang sudah dibebaskan. Ketika pemeriksaan struktural berkedip-kedip, lihat kepemilikan input test-nya sebelum melihat validator-nya. Output yang reproducible membuat disiplin itu lebih murah: begitu dua save identik byte per byte, satu-satunya sumber kedipan yang tersisa adalah object graph-nya sendiri, dan diff struktural dari catalog ke bawah akan menemukannya
Properti ReproducibleOutput, DeterministicDictionaryOrder, dan enkripsi yang dijelaskan di sini dikirim dalam HotPDF Delphi Component standar untuk Delphi dan C++Builder, dan flag yang sama menggerakkan korpus regresi library-nya sendiri, jadi perilaku yang Anda dapat di sebuah test suite adalah perilaku yang dipakai untuk menguji komponennya