Artikel Teknis

Kenapa Save PDF Tanpa Perubahan Bisa Merusak Info dan XMP

PDF Library for Delphi v3.539.18 dan v3.539.20 memperbaiki dua cara sebuah save PDF yang tidak mengubah apa pun masih bisa merusak metadata dokumen: ketika /CreationDate dan /ModDate merujuk objek string yang sama, pembaruan ModDate otomatis menulis ulang keduanya, dan ketika objek XMP sudah dibuat sebelum stream /Metadata asli dibaca, paket default menggantikan yang asli. Perbaikannya mengganti referensi dictionary alih-alih memutasi objek bersama, dan menangkap paket yang sudah ada sebelum inisialisasi malas XMP berjalan

Operasi ini adalah hal paling tidak menarik yang dilakukan sebuah library PDF: muat file, simpan dengan nama baru, tidak menyentuh apa pun di antaranya. Halaman terender identik sebelum dan sesudah. Hash content stream cocok. File itu lolos setiap pemeriksaan yang kami punya, dan tetap salah di dua tempat yang tak akan pernah ditunjukkan renderer mana pun. Kedua cacat duduk di jalur read-modify-write yang dilalui setiap penyuntingan sungguhan, jadi save apa pun sudah cukup untuk memicunya, dan keduanya baru ketahuan ketika parser kedua yang independen membandingkan semantik non-visual dari kedua file itu

Kenapa menyimpan PDF bisa mengubah CreationDate-nya?

Karena document information dictionary boleh merujuk satu objek string tak langsung dari dua key, dan library-nya memperbarui objeknya, bukan key-nya. ISO 32000-1 §7.3.10 mengizinkan nilai dictionary mana pun berupa referensi tak langsung, dan tidak ada satu pun bagian di §14.3.3 Tabel 317 yang menyatakan nilai di bawah /CreationDate harus objek yang berbeda dari nilai di bawah /ModDate. Producer yang menulis timestamp yang sama dua kali saat pembuatan bisa, dengan sepenuhnya legal, menunjuk kedua key itu ke satu 2728 0 R, dan persis itulah yang dilakukan sebuah dokumen desain CJK di korpus lokal kami

Pemicunya adalah tanggal modifikasi otomatis. Kalau UserModDate tidak diset, SaveToFile memanggil SetInfo('ModDate', ...) dengan waktu saat itu sebelum menulis, dan itu sampai ke SetRawInfo. SetRawInfo yang lama mencari objek di bawah key tersebut dan, kalau menemukan TPDFString, memanggil SetTo padanya. Itu penulisan in-place ke objek apa pun yang saat itu jadi hasil resolusi key, dan ketika objek itu dipakai bersama, /CreationDate kini juga melaporkan waktu save. Dokumennya tetap terbuka, tercetak, dan terender piksel per piksel seperti sebelumnya, jadi suite regresi visual lolos tanpa kedip

Mutasi string Info bersama di PDFlibPas: /CreationDate dan /ModDate secara legal merujuk satu objek string 2728 0 R, SetRawInfo lama memanggil SetTo pada apa pun hasil resolusi key itu dan menulis ulang kedua tanggal dengan waktu save, sedangkan SetRawInfo baru menambahkan string baru di bawah key tersebut sambil mempertahankan mode string hex
Memperbarui sebuah entry dictionary sekarang mengganti referensi entry itu alih-alih memutasi objek bersama, jadi satu penulisan ModDate otomatis tidak lagi bisa mengubah CreationDate, dan objek yang digantikan tetap disimpan untuk referensi lain
var
  Lib: TPDFlib;
  Before, After: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('design.pdf', '');
    Before := Lib.GetInformation(7);          // 7 = CreationDate, 8 = ModDate
    Lib.SaveToFile('design-resaved.pdf');
    Lib.LoadFromFile('design-resaved.pdf', '');
    After := Lib.GetInformation(7);
    if Before <> After then
      Log('a save that changed nothing rewrote CreationDate');
  finally
    Lib.Free;
  end;
end;

Perbaikan di TPDFDocument.SetRawInfo kecil, dan prinsip di belakangnya umum: memperbarui sebuah entry dictionary mengganti referensi entry itu, bukan objek yang kebetulan jadi hasil resolusinya. Kode barunya membaca TPDFStringMode yang ada supaya string hex tetap hex dan string literal tetap literal, lalu menambahkan string baru dari FStructure.NewString(Value, StringMode) di bawah key tersebut. Dua detail lain sama pentingnya dengan perubahan utamanya. Cabang lama untuk entry bernilai stream mengosongkan stream itu dengan SetTo('') sebelum menggantinya, dan itu akan mengosongkan nilainya bagi setiap key lain yang masih menunjuk stream tersebut, jadi pengosongan itu dihapus. Dan objek yang digantikan tidak dihapus, karena structure-lah pemiliknya dan referensi lain mungkin masih membutuhkannya

// Sebelum: mutasi objek apa pun yang saat ini jadi hasil resolusi key
if Obj is TPDFString then
  TPDFString(Obj).SetTo(Value);

// Sesudah: pertahankan representasinya, ganti hanya referensi key ini
StringMode := smLiteral;
if Obj is TPDFString then
  StringMode := TPDFString(Obj).StringMode;
ID.Add(Key, FStructure.NewString(Value, StringMode));

Regresi di Tests\SharedInfoSemantics.inc membangun aliasing itu dengan sengaja alih-alih mengandalkan file korpus: satu string hex yang dirujuk kedua key tanggal, satu string langsung yang dipakai bersama /Title dan /Subject, satu stream yang dipakai bersama /Author dan /Keywords. Setelah salah satu key dari tiap pasangan diperbarui, pasangannya harus tetap membaca nilai aslinya dan string yang diperbarui harus tetap hex. Referensi publik untuk SetInformation sekarang menyatakan jaminannya dalam satu kalimat: memperbarui sebuah field Info hanya mengganti field itu, bahkan ketika field lain merujuk objek yang sama

Kenapa paket XMP yang sudah ada digantikan oleh default?

Karena urutan dua baris. TPDFDocument.GetMetadata punya jalur cepat: ketika field XMP sudah terisi, ia mengembalikan XMP.SaveToString alih-alih mendekode stream /Metadata dari catalog. Beberapa call site menginisialisasi dengan malas lewat XMP := TPDFlibXMP.Create; XMP.LoadFromString(GetMetadata);, yang enak dibaca dan salah: saat GetMetadata berjalan, XMP sudah terisi, jadi "sumber" yang dimuat adalah paket default hasil serialisasi dari objek yang dibuat satu baris sebelumnya. Paket aslinya, lengkap dengan dc:creator, namespace kustom, dan identifikasi standar apa pun, tidak pernah sampai ke objek itu dan tertimpa saat save. Tanggal modifikasi otomatis yang sama sudah cukup untuk memicunya, karena SetInfo menginisialisasi XMP sebelum menyentuh dictionary Info supaya xmp:ModifyDate tetap selaras dengan /ModDate. Perhatikan apa yang disembunyikan cacat ini: perbandingan dictionary Info dari bug pertama lolos, karena /Author dan /Title di /Info tidak tersentuh. Hanya pohon XMP yang berubah, dan hanya pemeriksaan yang memparse lalu membandingkan pohon itu yang menyadarinya

Urutan inisialisasi XMP malas di PDFlibPas: membuat objek XMP sebelum memanggil GetMetadata membuat jalur cepat menyerialkan paket default dan membuang dc:creator, namespace kustom, serta identifikasi standar, sedangkan menangkap Source sebelum TPDFlibXMP.Create memuat stream /Metadata asli dari catalog
Save apa pun memicu penukaran itu karena SetInfo menginisialisasi XMP untuk menjaga xmp:ModifyDate selaras dengan /ModDate, jadi setiap inisialisasi malas di dokumen sekarang mengalir lewat satu EnsureXMP yang menangkap paket yang ada sebelum membuat objeknya
// Salah: GetMetadata sekarang menyerialkan objek yang dibuat di baris sebelumnya
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(GetMetadata);

// Benar: tangkap dulu stream /Metadata, baru buat dan muat
Source := GetMetadata;
XMP := TPDFlibXMP.Create;
XMP.LoadFromString(Source);

Perbaikannya melakukan dua hal. TPDFDocument.EnsureXMP sekarang menangkap Source := GetMetadata sebelum TPDFlibXMP.Create, dan setiap inisialisasi malas di dokumen diganti dengan panggilan ke sana: SetInfo, SetXMPInformation, GetXMPInformation, setter mode PDF/A, PDF/X, PDF/E, PDF/VT, PDF/VCR, dan PDF/UA, serta jalur perbaikan metadata. Entry point publik seperti SetXMPProperty memang sudah lewat EnsureXMP, dan GetXMPProperty membaca lewat GetDocumentMetadata, jadi seluruh permukaan API berbagi satu urutan inisialisasi. Satu salinan urutan tiga baris yang benar lebih berharga daripada sepuluh salinan yang kebetulan sepakat hari ini

Dua jebakan lebih kecil yang ditemukan di jalur yang sama

Serializer XMP di Windows memakai XML writer platform, yang mengeluarkan deklarasi XML yang tidak boleh dibawa paket itu. Kode lamanya menghapusnya dengan membuang karakter sampai mencapai <?xpacket. ISO 16684-1 §7.3.2 membuat wrapper xpacket bersifat opsional, dan producer yang menulis elemen <x:xmpmeta> polos tetap berada di dalam standar, jadi pada paket semacam itu loop-nya menghapus seluruh dokumen yang valid. Serializer-nya sekarang mencari ?> penutup dari deklarasi itu dan menghapus hanya itu. Tests\XMPRetentionSemantics.inc menjalankan pemeriksaan retensinya dua kali, sekali dengan wrapper dan sekali dengan wrapper itu dipotong, lalu memastikan penanda namespace kustom dan penulis aslinya bertahan melewati SetInfo, GetMetadata, SaveToString, dan satu kali muat ulang. Jebakan kedua adalah simbol preprocessor: sinkronisasi Info-ke-XMP di SetInfo dijaga oleh NOVCL, yang aktif untuk build Free Pascal, padahal backend XMP-nya dibatasi oleh sistem operasi, bukan oleh framework-nya, karena PDFlibXMP.pas mendefinisikan NO_XMP hanya ketika OS_WINDOWS tidak ada. Jadi build Lazarus di Windows punya objek XMP yang bekerja sementara SetInfo-nya diam-diam melewati pembaruan objek itu. Penjaganya sekarang NO_XMP, jadi aplikasi Free Pascal di Windows mendapat sinkronisasi yang sama dengan Delphi

Bagaimana mempertahankan ModDate asli pada save yang hanya lewat?

Setel KeepModDate di TPDFlibSaveOptions lalu simpan lewat SaveToFileOptions. Opsi itu menyetel UserModDate selama panggilan berlangsung, dan SaveToFile lalu melewati timestamp otomatis, yang juga langkah yang menginisialisasi objek XMP secara malas. Dokumen yang metadatanya tidak pernah Anda sentuh, dan yang tidak ada mode kepatuhan diaktifkan untuknya, mempertahankan dictionary Info beserta stream /Metadata-nya seperti saat dimuat. Memanggil SetInformation(8, ...) memberi efek permanen yang sama, karena menyetel tanggal modifikasi sendiri menandainya sebagai dikendalikan pengguna

var
  Options: TPDFlibSaveOptions;
begin
  FillChar(Options, SizeOf(Options), 0);
  Options.OptimizeContentStreams := True;
  Options.PackObjectStreams := True;
  Options.KeepModDate := True;      // tanpa /ModDate otomatis, tanpa init XMP malas
  if Lib.SaveToFileOptions('design-resaved.pdf', Options) <> 1 then
    Log(Format('save failed, LastErrorCode=%d', [Lib.LastErrorCode]));
end;

Jujurlah soal apa yang didapat dari ini. KeepModDate adalah pilihan yang tepat untuk langkah pass-through yang outputnya seharusnya menggambarkan revisi yang sama dengan inputnya, dan pilihan yang salah untuk apa pun yang sungguh menyunting konten, karena §14.3.3 mengharapkan /ModDate mencerminkan modifikasi terakhir. Ia juga tidak memperbaiki library yang memutasi objek bersama secara retroaktif; ia hanya menghindari satu penulisan yang membongkar cacat itu. Kedua perbaikan di atas itulah yang membuat save biasa jadi aman, dan opsi ini yang membuat no-op yang disengaja jadi jujur

Bagaimana memverifikasi bahwa sebuah save tidak mengubah apa pun selain ModDate?

Bukan dengan piksel dan bukan dengan hash stream, karena kedua cacat itu meninggalkan setiap halaman dan setiap content stream tetap identik byte per byte. Pemeriksaan yang menangkapnya adalah snapshot semantik non-visual yang diambil parser independen, yang tidak berbagi kode sebaris pun dengan library yang diuji, dari file sumber dan dari file hasil save, lalu diikuti perbandingan struktural. Snapshot-nya mencakup dictionary Info tanpa /ModDate, pohon outline dengan setiap bookmark diresolusi ke nomor halaman alih-alih nomor objek, named destination dan target link yang diresolusi dengan cara yang sama, nilai field form, byte lampiran sebagai hash, serta paket XMP yang diparse sebagai pohon alih-alih dibandingkan sebagai teks. Nomor objek sengaja tidak termasuk di dalamnya, karena penulisan ulang penuh menomori ulang semuanya dan perbandingan yang berpatokan padanya hanya akan melaporkan noise

Verifikasi semantik non-visual untuk save PDFlibPas: parser independen tanpa kode bersama mengambil snapshot dictionary Info minus /ModDate, halaman outline dan destination, nilai form, hash lampiran, serta pohon XMP, lalu membandingkan file sumber dengan file hasil save sambil membuang /ModDate, xmp:ModifyDate, dan xmp:MetadataDate sebagai perubahan yang memang diharapkan
Piksel dan hash stream tetap identik byte per byte melewati kedua cacat itu, jadi perbandingannya bekerja pada semantik hasil resolusi dan bukan pada nomor objek, dan metadata yang bertahan dilaporkan dengan jujur sebagai dipertahankan, bukan sebagai schema-valid atau patuh PDF/UA dan PDF/A

Pengecualiannya sama pentingnya dengan inklusinya. /ModDate, xmp:ModifyDate, dan xmp:MetadataDate memang diharapkan berubah dan dibuang sebelum perbandingan; file yang sumbernya tidak membawa XMP sama sekali tidak dihukum karena mendapat paket. Apa yang tidak diklaim pemeriksaan ini juga sama eksplisitnya: mempertahankan paket yang sudah ada tidak mengatakan apa pun soal apakah paket itu schema-valid atau apakah dokumennya memenuhi PDF/UA atau bagian PDF/A mana pun. Itu pertanyaan terpisah dengan alat terpisah, dan mencampuradukkan "metadatanya bertahan" dengan "metadatanya patuh" adalah cara bug pertama bersembunyi selama itu. Di sisi library, kedua regresi itu sekarang berjalan di setiap pass bertarget pada Delphi Win32 dan Win64 serta Free Pascal Win32 dan Win64, dan perbandingan semantiknya jadi syarat lolos untuk benchmark korpus dokumen nyata

Kalau Anda bekerja di level di bawah perbaikan ini, mekanika bagaimana sebuah save menulis ulang objek dibahas di incremental update dan penyimpanan append-only, yang merupakan satu-satunya mode save di mana objek bersama dibiarkan di tempatnya, dan di modification level dan revision diffing, yang merupakan tempat lain di mana tanggal yang basi atau ditulis ulang menyesatkan pembaca. Pandangan dari sisi perbaikan atas pasangan Info dan XMP yang sama, di mana kedua belahannya dibuat sepakat alih-alih sekadar dipertahankan, ada di mengonversi ke PDF/A dan memperbaiki metadata

PDF Library for Delphi adalah library PDF Pascal native untuk Delphi, C++Builder, dan Lazarus, dan jalur read-modify-write yang dijelaskan di sini adalah jalur yang sama yang dilalui setiap penyuntingan di proses Anda sendiri, jadi jaminan di atas berlaku entah Anda menyimpan sekali atau seribu kali sehari: lihat halaman produk PDF Library for Delphi untuk compiler dan platform yang didukung