Artikel Teknis

Atribut Halaman Warisan dan Instance Aliasing di PDFlibPas

Di PDFlibPas, PDF library untuk Delphi, halaman yang dipindahkan dengan MovePage dulu menerima objek MediaBox, CropBox, dan Resources yang persis sama dengan yang dipegang node Pages lamanya, sehingga SetPageBox atau DrawText berikutnya di halaman pindahan diam-diam menulis ulang node itu dan setiap sibling yang masih mewarisinya. Sejak v3.539.36 halaman pindahan mendapat salinannya sendiri, dan indirect reference tetap menjadi reference. Release yang sama menutup dua jalur terkait: SetPageBox pada box indirect yang dibagi beberapa halaman, dan CopyPageRanges yang membuat halaman-halaman dokumen sumber tetap terikat ke node Pages-nya, dengan CropBox terikat ke MediaBox

Laporan-laporan yang berujung ke sini tak pernah menyebut object identity. Isinya seperti "aku crop halaman 7 dan halaman 8 sampai 12 ikut ter-crop", atau "aku sempitkan CropBox dan MediaBox ikut bergeser", atau yang paling membingungkan, "aku menyalin satu halaman ke dokumen baru dan file aslinya berubah". Tak ada yang crash, tak ada yang bocor, dan file tersimpannya adalah PDF yang benar-benar valid. Ia cuma memuat geometri yang tak diminta siapa pun

Kenapa SetPageBox di satu halaman ikut mengubah ukuran sibling-nya?

SetPageBox mengubah ukuran sibling karena dua entri page tree menunjuk ke satu array di memori, dan SetPageBox mengedit array targetnya di tempat. Halaman atau node Pages mana pun yang memegang instance yang sama melihat edit itu. Tiga jalur code di PDFlibPas menghasilkan pembagian itu sebelum v3.539.36:

  • MovePage mematerialisasi atribut yang bisa diwariskan ke halaman sebelum melepasnya dari parent-nya, dan ia menempelkan objek milik ancestor itu sendiri alih-alih salinan, sehingga halaman pindahan dan mantan sibling-nya berbagi satu array box dan satu dictionary Resources
  • SetPageBox mengikuti indirect reference dan mengedit array yang dirujuknya, sehingga file yang beberapa halamannya menunjuk ke satu objek /MediaBox 11 0 R mengalami semua halaman itu diubah ukurannya oleh satu panggilan, dengan atau tanpa keterlibatan MovePage
  • CopyPageRanges mematerialisasi nilai warisan di halaman sumber sebelum meng-clone-nya ke dokumen target, dan ia menempelkan instance node Pages ke halaman sumber, plus instance MediaBox itu sendiri sebagai CropBox default
Aliasing MovePage di PDFlibPas di mana halaman pindahan dan mantan sibling-nya sama-sama memegang instance array MediaBox milik ancestor, sehingga SetPageBox mengedit satu halaman dan mengubah ukuran yang lain; sejak v3.539.36 materialisasi menempelkan salinan hasil decode dan edit tetap lokal di halaman yang Anda sentuh
Dua entri page tree yang menunjuk ke satu array di memori membuat setiap edit mendarat di semua pemegangnya, dan PDF tersimpan tetap valid sepanjang waktu

Kasus MovePage punya sejarah pendek. Sebelum v3.539.27, MovePage hanya membawa /Resources-nya saja, sehingga halaman yang dipindah ke parent berbeda diam-diam mewarisi ukuran dan rotasi parent itu. v3.539.27 memperbaiki MediaBox, CropBox, dan Rotate yang hilang, yang juga jadi tumpuan CollateDocumentsEx saat ia mengurutkan ulang halaman, tapi ia menempelkan nilai ancestor sebagai instance bersama. Itulah celah yang ditutup v3.539.36. Jalur SetPageBox dan CopyPageRanges lebih tua; build apa pun sebelum v3.539.36 memilikinya

Nilai direct, indirect reference, dan pewarisan atribut halaman

Salinan yang benar dari atribut halaman warisan menduplikasi nilai direct dan menjaga indirect reference sebagai reference, karena itulah pembedaan yang digambar ISO 32000-1 sendiri. Objek direct seperti [0 0 400 300] yang ditulis di dalam dictionary hanya milik dictionary itu. Objek indirect, yang didefinisikan sekali sebagai 11 0 obj dan dirujuk sebagai 11 0 R, memang didesain untuk dibagi: ISO 32000-1 §7.3.10 membuatnya addressable dari mana pun di file, dan setiap 11 0 R berarti objek yang sama

Pewarisan atribut halaman, ISO 32000-1 §7.7.3.4, menambah kasus ketiga. Resources, MediaBox, CropBox, dan Rotate boleh berada di node Pages dan berlaku untuk setiap halaman keturunan yang tak mendefinisikan miliknya sendiri. Halaman tak memegang nilai itu; ia mencari nilainya lewat /Parent. Rantai lookup itu putus begitu halaman berganti parent, dan itulah kenapa MovePage dan BalancePageTree harus lebih dulu menulis nilai efektif ke halaman itu sendiri. Pertanyaannya cuma bagaimana menulisnya

Kenapa object pool menyembunyikan kesalahan ini

Di PDFlibPas setiap objek PDF hasil parse atau hasil create dimiliki oleh pool TPDFStructure milik dokumen, dan dictionary serta array menyimpan pointer polos ke entri-entrinya. TPDFDictionary.Add hanya mencatat pointer itu, tidak lebih. Menambahkan satu instance ke dua container parent karenanya legal di setiap level yang bisa dicek runtime: tak ada double free saat teardown, tak ada reference count yang bisa salah, tak ada exception. Serialisasi sama-samanya memaafkan, karena tiap container menulis nilai terkini instance bersama itu secara inline, dan sebelum ada edit, output-nya byte demi byte sama dengan yang dihasilkan salinan yang benar

Aliasing baru muncul ketika seseorang memutasi instance bersama itu di tempat. SetPageBox melakukan persis itu lewat wrapper rectangle di atas array yang ada, dan menggambar di halaman melakukannya ke dictionary Resources ketika font atau image didaftarkan. Edit-nya mendarat, dengan senyap, di setiap container lain yang memegang pointer itu

Bagaimana PDFlibPas v3.539.36 menyalin alih-alih berbagi

PDFlibPas v3.539.36 memperbaiki masalah ini di kedua ujung: materialisasi kini menempelkan salinan, dan penulisan box kini hanya mengedit array yang dimiliki halaman. Masing-masing perbaikan menutup kasus yang tak bisa ditutup oleh yang lain

Helper materialisasi, PLInheritPageAttributes, kini menempelkan Page.Owner.Decode(Value.Output) alih-alih Value. Round-trip lewat serializer adalah cara yang kasar tapi eksak untuk mendapat semantik PDF gratis. Array atau dictionary direct terserialisasi menjadi teks literalnya dan ter-decode menjadi instance baru yang independen. Indirect reference terserialisasi menjadi 11 0 R dan ter-decode menjadi objek reference baru yang menunjuk ke objek 11 yang sama, sehingga halaman masih merujuk objek bersama itu alih-alih menerima salinan inline, yang menjaga perilaku reference yang diperkenalkan di v3.539.27. Salinannya sedalam struktur direct-nya saja: apa pun yang dicapai lewat reference di dalam dictionary hasil salinan tetap dibagi, sesuai niat format filenya. BalancePageTree memanggil helper yang sama untuk setiap halaman yang re-parent-nya lakukan, sehingga halaman yang dimaterialisasi di sana juga mendapat instance terpisah

Round-trip materialisasi PDFlibPas di mana PLInheritPageAttributes menempelkan Page.Owner.Decode(Value.Output): array direct terserialisasi menjadi teks literal dan ter-decode menjadi instance baru, sedangkan 11 0 R indirect terserialisasi dan ter-decode menjadi reference baru yang tetap menunjuk ke objek bersama 11
Serialisasi dan parse ulang mendapat semantik objek PDF gratis: nilai direct tersalin, reference tetap reference, persis seperti yang dimaksudkan ISO 32000-1

Menyalin saja tak cukup, karena kasus reference masih menunjuk ke objek bersama. Andai SetPageBox mengikuti reference itu dan mengedit objek 11, halaman pindahan kembali mengubah ukuran parent lama dan anak-anak lainnya. Maka box writer kini menerapkan copy-on-write: ia mengedit di tempat hanya ketika entri milik halaman sendiri adalah array direct, dan mengganti box indirect atau yang hilang dengan array direct baru. Objek 11 dibiarkan tak tersentuh untuk setiap halaman lain yang merujuknya

Keputusan copy-on-write SetPageBox di PDFlibPas: ketika entri milik halaman sendiri adalah array direct ia diedit di tempat, dan ketika ia indirect reference atau tidak ada, writer menggantinya dengan array direct baru sehingga objek bersama 11 menjaga nilainya untuk setiap halaman lain yang merujuknya
Menyalin saat materialisasi tak cukup selama reference masih menunjuk ke objek bersama, jadi box writer hanya mengedit yang dimiliki halaman
Jalur codeSebelum v3.539.36Sejak v3.539.36
Materialisasi MovePageHalaman memegang instance direct milik ancestor itu sendiriHalaman memegang salinan hasil decode; reference tetap reference
SetPageBoxMengikuti reference dan mengedit array bersamaHanya mengedit array direct di halaman, selebihnya menulis yang baru
Halaman sumber CopyPageRangesBerbagi box node Pages; CropBox adalah instance MediaBoxSetiap nilai yang dimaterialisasi di halaman sumber adalah salinan
Box default saat cloning resource halamanCropBox, BleedBox, TrimBox, dan ArtBox berbagi satu arrayTiap box default mendapat array miliknya sendiri

Baris terakhir adalah yang laten. Ketika library meng-clone resource sebuah halaman untuk page capture atau merging, ia mengisi entri CropBox, BleedBox, TrimBox, dan ArtBox yang hilang, dan dulu semuanya adalah instance array yang sama. Tak ada caller saat ini yang membiarkan alias itu hidup cukup lama sampai teredit, tapi caller berikutnya akan saja. Bagaimana nilai box default itu dipilih adalah topik tersendiri, dibahas di panduan PDFlibPas tentang default TrimBox, BleedBox, dan CropBox

Merekonstruksi aliasing MovePage dengan PDF buatan tangan

Cara tercepat mengecek build PDFlibPas mana pun adalah PDF kecil tulisan tangan yang dimuat dengan LoadFromString, di mana setiap nomor objek diketahui lebih dulu. Helper di bawah menulis tabel cross-reference klasik dengan byte offset yang dihitung benar, sehingga test tak bergantung pada perilaku recovery parser untuk file rusak

uses
  System.SysUtils, PDFlibrary;

function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
  Offsets: array of Integer;
  I, XRefPos: Integer;
begin
  Result := '%PDF-1.4'#10;
  SetLength(Offsets, Length(Objects));
  for I := 0 to High(Objects) do
  begin
    Offsets[I] := Length(Result);   // byte offset 0-based dari "N 0 obj"
    Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
      Objects[I] + #10'endobj'#10;
  end;
  XRefPos := Length(Result);
  Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
    #10'0000000000 65535 f '#10;
  for I := 0 to High(Offsets) do      // tiap entri persis 20 byte
    Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
  Result := Result + 'trailer'#10'<< /Size ' +
    AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
    'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;

function StreamObj(const Content: AnsiString): AnsiString;
begin
  Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
    ' >>'#10'stream'#10 + Content + #10'endstream';
end;

Dokumen test punya dua node Pages antara. Node 3 membawa MediaBox indirect (objek 11, 400 kali 300 points), CropBox direct, dan dictionary Resources direct, serta memegang dua halaman. Node 4 punya MediaBox ukuran Letter dan memegang halaman ketiga. Memindahkan halaman 1 ke posisi 3 me-re-parent-nya ke bawah node 4, dan itulah persis pemindahan yang butuh materialisasi: tanpa itu, halaman akan berubah menjadi halaman Letter

procedure Check(Condition: Boolean; const Msg: string);
begin
  if not Condition then
    raise Exception.Create(Msg);
end;

procedure CheckMovedPageIsIsolated;
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
        '/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
      '<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
        '/MediaBox [0 0 612 792] >>',
      '<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
      '<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
      '<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
      StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
      '[0 0 400 300]']), '') = 1, 'load failed');

    Lib.SelectPage(1);
    Check(Lib.MovePage(3) = 1, 'MovePage failed');
    Lib.SelectPage(3);                       // halaman yang baru kita pindahkan
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');

    Lib.SetPageBox(1, 0, 200, 200, 200);     // MediaBox 200 x 200
    Lib.SetPageBox(2, 0, 100, 100, 100);     // CropBox 100 x 100
    FontID := Lib.AddStandardFont(4);        // Helvetica
    Lib.SelectFont(FontID);
    Lib.SetTextSize(12);
    Lib.DrawText(20, 20, 'MOVED');

    // Periksa parent lama SEBELUM memilih halaman lain (lihat di bawah)
    Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
      'font registered in the old Pages node');

    Lib.SelectPage(1);                       // mantan halaman 2, masih di bawah node 3
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
    Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
    Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
      'shared object 11 was rewritten');
  finally
    Lib.Free;
  end;
end;

GetPageBox(BoxType, Dimension) menerima tipe box 1 untuk MediaBox dan 2 untuk CropBox, serta dimensi 2 untuk width. Dengan origin default kiri-bawah, SetPageBox(1, 0, 200, 200, 200) berarti kiri 0, atas 200, lebar 200 dan tinggi 200. Pada build antara v3.539.27 dan v3.539.35, pengecekan sibling gagal: edit CropBox mendarat di array direct milik node 3, dan edit MediaBox menulis ulang objek 11 lewat reference-nya

Apakah CopyPageRanges mengubah dokumen sumber?

Sejak v3.539.36, CopyPageRanges tetap menulis ke halaman sumber, tapi setiap nilai yang ia tulis adalah salinan terpisah, sehingga edit berikutnya di sumber tetap lokal di halaman yang Anda edit. Penulisannya sendiri memang disengaja: halaman sumber butuh MediaBox, CropBox, Rotate, dan Resources eksplisit sebelum dictionary-nya di-clone ke target, kalau tidak, salinannya akan kehilangan semua yang diwarisinya. Penomoran ulang dan penyalinan halaman ke target dibahas di deep copy objek lintas dokumen di PDFlibPas; bug ini duduk di sisi sumber, yang oleh kebanyakan orang dianggap hanya dibaca oleh penyalinan

Output tak pernah menunjukkannya. Entah dibagi atau disalin, nilai hasil materialisasi terserialisasi identik, jadi kedua dokumen tersimpan byte demi byte sama sebelum dan sesudah perbaikan. Hanya edit ke dokumen sumber setelah penyalinan yang membuka aliasnya:

procedure CheckSourceSurvivesCopy;
var
  Lib: TPDFlib;
  SourceID, TargetID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Check(Lib.LoadFromString(BuildPdf([
      '<< /Type /Catalog /Pages 2 0 R >>',
      '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
        '/MediaBox [0 0 400 300] /Resources << >> >>',
      '<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
      '<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
      StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
    SourceID := Lib.SelectedDocument;

    TargetID := Lib.NewDocument;             // menjadi dokumen terpilih
    Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');

    Lib.SelectDocument(SourceID);
    Lib.SelectPage(1);
    Lib.SetPageBox(2, 50, 250, 100, 100);    // sempitkan hanya CropBox
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
    Lib.SetPageBox(1, 0, 200, 200, 200);

    Lib.SelectPage(2);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');

    Lib.SelectDocument(TargetID);            // salinannya menjaga ukuran aslinya
    Lib.SelectPage(Lib.PageCount);
    Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
  finally
    Lib.Free;
  end;
end;

Sebelum v3.539.36 kedua halaman di sini mewarisi MediaBox direct milik node root, salinannya menempelkan instance itu ke halaman sumber 1, dan menempelkannya lagi sebagai CropBox halaman 1. Menyempitkan CropBox karenanya menyempitkan MediaBox, dan mengubah ukuran MediaBox mengubah ukuran halaman 2 lewat node root. Workflow yang menyalin halaman keluar lalu terus mengedit sumber, seperti meng-collect scan duplex menjadi satu PDF sebelum memangkas aslinya, adalah tempat bug ini muncul

Kenapa instance aliasing begitu sulit di-test?

Instance aliasing sulit di-test karena efek yang bisa diamati butuh tiga langkah dalam urutan tertentu: ciptakan aliasnya, mutasi satu sisi, lalu periksa sisi lain sebelum apa pun menyentuhnya. Kebanyakan test hanya melakukan langkah pertama dan membandingkan output tersimpan, yang identik entah aliasnya ada atau tidak

Jebakan urutan di PDFlibPas adalah SelectPage. Memilih sebuah halaman menerapkan ulang font saat ini lewat SelectFont, yang mendaftarkan font itu ke resource halaman. Halaman tanpa /Resources milik sendiri resolve ke dictionary parent-nya, jadi sekadar memilih halaman semacam itu secara sah menambahkan /Font ke node Pages. Di test MovePage di atas, memilih mantan halaman 2 menambahkan entri Helvetica ke node 3, yang merupakan perilaku yang benar dan bukan kebocoran. Itulah kenapa pengecekan GetObjectToString(3) jalan sebelum SelectPage(1); tukar keduanya dan test gagal di build yang sudah diperbaiki

Aturan itu juga menandai apa yang sengaja dibiarkan v3.539.36. Menulis resource ke halaman yang mewarisi dictionary Resources-nya menulis ke dictionary ancestor, dan setiap sibling melihat entri barunya. Itu pewarisan yang bekerja sesuai spesifikasi, bukan pembagian instance, dan tak berbahaya karena menambahkan nama font atau image ke dictionary bersama tak mengubah cara halaman lain merender. Kalau Anda butuh sebuah halaman berhenti mewarisi, berikan dictionary Resources miliknya sendiri lebih dulu

Checklist untuk code object model PDF

Pelajarannya berlaku umum untuk object model PDF apa pun yang dibangun di atas pool dan container pointer, di Delphi atau di tempat lain:

  • Saat mematerialisasi atribut warisan menurut ISO 32000-1 §7.7.3.4, deep-copy nilai direct dan jaga indirect reference sebagai reference baru ke objek yang sama
  • Jangan pernah Add instance yang sudah ada ke container kedua kecuali pembagian itu memang disengaja dan terdokumentasi; kepemilikan oleh pool berarti runtime tak akan pernah protes
  • Edit di tempat hanya yang dimiliki node saat ini sebagai objek direct; ganti nilai indirect atau warisan dengan objek direct baru (copy-on-write)
  • Nilai default yang diturunkan dari entri lain, seperti CropBox dari MediaBox, butuh instance miliknya sendiri
  • Test aliasing dengan sekuens mutasi-lalu-periksa pada pemegang lainnya, dan cek urutan panggilan yang mungkin menulis secara sah di antaranya
  • Membandingkan output tersimpan tak membuktikan apa pun di sini: nilai bersama dan tersalin terserialisasi identik sampai edit pertama
  • Di PDFlibPas, upgrade ke v3.539.36 atau lebih baru jika Anda memanggil MovePage, CollateDocumentsEx, BalancePageTree, atau CopyPageRanges lalu mengedit page box atau menggambar di halaman

PDFlibPas mengekspos pengeditan page tree, penyalinan lintas dokumen, dan kontrol page box lewat satu class TPDFlib untuk Delphi, C++Builder, dan Free Pascal. Lihat halaman produk PDF library Delphi PDFlibPas untuk edisi, platform, dan referensi API lengkap