Artikel Teknis

Membaca Aksi Bookmark dan Anotasi PDF di Delphi

Anda mewarisi kumpulan PDF dari hulu, dan tugasnya terdengar sederhana: beri tahu bookmark mana yang menuju URL eksternal, mana yang menjalankan JavaScript, dan ke mana sebenarnya tautan internal berakhir. Lalu Anda membuka referensi API dan menemukan bahwa library ini bisa membuat semua aksi itu, tetapi tidak menyediakan cara untuk membacanya kembali. Ketimpangan seperti ini ada di banyak alat PDF. Membuat bookmark yang membuka https://example.com cukup satu baris; sedangkan menanyakan bookmark yang sudah ada, "apa yang kamu lakukan, dan mengarah ke mana?" biasanya berarti menelusuri pohon objek mentah lewat /A, /S, /Dest, dan deretan varian fit type yang jarang benar pada percobaan pertama

PDFlibPas adalah library PDF Object Pascal native untuk Delphi dan C++Builder, dan untuk waktu yang lama ia punya celah yang sama: setter di sisi tulis yang kaya, getter yang hanya mengembalikan TPDFObject mentah dan membiarkan Anda membongkarnya sendiri. Rilis v3.77.0 menutup sebagian celah itu dengan seperangkat kecil panggilan introspeksi bertipe yang melaporkan jenis aksi, payload aksi, dan geometri destination sebagai record biasa. Artikel ini membahas bagaimana panggilan itu dipetakan ke model aksi dan destination ISO 32000-1, serta tiga jebakan nyata yang membuat versi buatan tangan dari kode ini diam-diam salah

Mengapa membaca aksi lebih sulit daripada menuliskannya

Sebuah aksi dalam PDF adalah dictionary dengan key /S yang menamai subtipenya: GoTo, GoToR, URI, Launch, Named, JavaScript, dan beberapa tipe lain yang jarang ditemui (ISO 32000-1 §12.6.4). Masalahnya, payload berada di key yang berbeda untuk setiap subtype, dan tidak ada slot seragam untuk "beri saya targetnya". Aksi URI menyimpan alamatnya di /URI. Aksi GoToR atau Launch menyimpan file specification di /F. Aksi JavaScript menyimpan skripnya di /JS, yang bisa berupa string atau stream. Aksi GoTo sama sekali tidak membawa payload sendiri; targetnya adalah destination yang tergantung pada /D, lalu harus Anda selesaikan terpisah

Ketika Anda menulis aksi, jenisnya sudah diketahui sejak awal, jadi semua ini tidak terlalu penting. Saat Anda membacanya, Anda harus bercabang dulu berdasarkan /S, lalu masuk ke key yang tepat, lalu menangani fakta bahwa konsep logis yang sama ("sesuatu yang ditunjuk aksi ini") dikodekan dengan tiga cara yang tidak kompatibel. Percabangan itulah yang diserap oleh getter bertipe. GetOutlineActionInfo dan GetAnnotActionInfo sama-sama mengembalikan record TPDFlibActionInfo:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

Record itu memberi tahu field mana yang bermakna lewat Kind. Jika Kind kembali akURI, baca URI dan abaikan sisanya. Jika yang kembali akGoTo, tidak ada field payload yang berlaku dan Anda lanjut ke destination, yang dibahas lewat panggilan terpisah di bawah. akNone adalah jawaban yang jujur ketika bookmark atau anotasi memang tidak punya aksi sama sekali, bukan nol yang harus Anda tebak maknanya

Menelusuri pohon outline untuk mencari bookmark

Sebelum bisa melakukan introspeksi pada sebuah bookmark, Anda perlu handle-nya. PDFlibPas mengidentifikasi node outline dengan ID integer, dan FindOutlineByTitle menemukan node berdasarkan teks yang terlihat dengan kendali eksplisit atas seberapa jauh pencarian berjalan:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

Argumen Depth adalah bagian yang layak diperhatikan. osdSiblingsOnly memindai rantai sibling pada level node awal lalu berhenti; mode ini akan menemukan bookmark sejajar, tetapi tidak pernah masuk ke child milik sibling. osdChildrenOnly melihat satu level ke bawah, ke child langsung dari node awal. osdFullSubTree melakukan rekursi melalui seluruh cabang. Memilih yang salah menghasilkan miss diam-diam, bukan error: pencarian sibling-only untuk judul yang berada dua level lebih dalam hanya mengembalikan nol, dan Anda menyimpulkan bookmark itu tidak ada padahal sebenarnya ada. Gunakan GetFirstOutline sebagai start ID untuk mencari dari root dokumen

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

Pencocokan dilakukan pada string judul yang persis sama, dibandingkan sebagai WideString, jadi sifatnya case-sensitive dan menghormati teks Unicode persis seperti yang tersimpan. Jika PDF sumber Anda datang dari producer yang tidak konsisten, normalisasikan judul yang Anda cari dengan cara yang sama seperti dokumen menyimpannya, atau Anda akan mengejar miss semu

Menyelesaikan aksi dan target bookmark

With a handle in hand, GetOutlineActionInfo gives you the typed view. The pattern is: call it, switch on Kind, read the field that kind populates

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

Di sinilah jebakan nyata pertama berada, dan inilah yang terungkap lewat umpan balik pengujian selama implementasi. Ada getter lama, GetActionURL, dan menggunakannya untuk membaca aksi URI adalah kesalahan yang paling mudah terlihat. GetActionURL menyelesaikan file specification melalui key /F. Itu memang tepat untuk GoToR dan Launch, karena targetnya benar-benar file, tetapi sama sekali bukan key yang tepat untuk aksi URI. Alamat pada aksi URI hanyalah string biasa di key /URI milik aksi itu sendiri, bukan file spec. Jika aksi URI dipaksa melewati jalur file-spec, hasilnya kosong atau tidak masuk akal. Getter bertipe menangani ini di dalam dengan membaca /URI langsung untuk akURI dan hanya memanggil resolver file specification untuk akGoToR dan akLaunch, persis seperti perbedaan yang sering kabur pada versi buatan tangan

Jenis penyesuaian tujuan dan geometri di baliknya

An akGoTo action means "navigate within this document," but it tells you nothing about where or how. That is the destination's job, and destinations carry more nuance than people expect. A PDF destination is not just a page number; it is a page plus a "fit" specification that says how the viewer should frame that page (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo returns it as a record:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

Kedelapan jenis fit menjawab pertanyaan framing yang berbeda. dkXYZ memposisikan titik tertentu di pojok kiri atas pada zoom yang eksplisit, jadi ia memakai Left, Top, dan Zoom. dkFit menyesuaikan seluruh halaman ke jendela dan mengabaikan koordinat. dkFitH dan dkFitV menyesuaikan lebar atau tinggi halaman dengan satu koordinat yang relevan saja, yakni tepi atas atau tepi kiri. dkFitR adalah yang menarik: ia menyesuaikan rectangle tertentu, jadi keempat sisi berpengaruh. Keluarga dkFitB* melakukan hal yang sama tetapi relatif terhadap bounding box konten yang terlihat, bukan seluruh halaman. Mengetahui field mana yang aktif untuk tiap jenis adalah perbedaan antara membaca destination dengan benar dan mencetak koordinat sampah yang kebetulan bernilai nol

PDF reader bookmark navigation panel showing a nested outline tree
Each bookmark in this navigation panel resolves to an action and, for internal jumps, a destination with its own fit type and coordinates.

Under the hood, the implementation leans on a deliberate piece of alignment that is worth knowing about because it explains why the mapping is reliable. The internal GetDestType returns an integer 1..8 for the eight fit kinds in exactly the XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV order. TPDFlibDestinationKind is declared so its ordinals line up one-to-one: dkXYZ is ordinal 1, dkFitBV is ordinal 8, with dkNone sitting at zero. So the conversion is a direct ordinal cast with a range guard, not a lookup table that can drift out of sync as the enum grows. That is a small detail, but it is the kind of thing that, done the naive way, becomes an off-by-one bug the first time someone reorders an enumeration

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

Page bernilai nol adalah sinyal bahwa destination tidak terselesaikan, biasanya karena aksi tidak membawa destination atau named destination tidak ditemukan. Periksa ini sebelum Anda mempercayai koordinat apa pun. Perlu juga dicatat bahwa GetOutlineDestinationInfo mencari di dua tempat tempat destination bisa berada: langsung pada /Dest milik bookmark, dan di dalam aksi GoTo yang tertanam melalui /D. Anda tidak perlu tahu bentuk mana yang dipakai producer

Aksi anotasi dan jebakan SelectPage

Anotasi link membawa aksi dengan cara yang sama seperti bookmark, dan GetAnnotActionInfo mengembalikan record TPDFlibActionInfo yang sama dengan pola jenis-lalu-payload yang sama. Tetapi ada jebakan yang bergantung pada state di sini yang tidak berlaku untuk outline, dan inilah jebakan ketiga

Anotasi milik halaman, dan PDFlibPas mengekspos anotasi pada halaman aktif melalui state yang baru valid setelah Anda memilih halaman itu. Panggil GetAnnotActionInfo tanpa lebih dulu memanggil SelectPage(N) dan handle anotasinya menjadi nol; panggilan itu mengembalikan akNone dan Anda keliru menyimpulkan bahwa halaman tersebut tidak punya anotasi yang bisa diproses. Perbaikannya cuma satu baris, tetapi mudah dilupakan saat Anda mengiterasi halaman demi halaman:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

Ada dua hal yang disengaja dalam loop itu. Pertama, SelectPage(P) selalu dipanggil sebelum akses anotasi apa pun pada setiap iterasi; state anotasi per halaman tidak terbawa ke halaman berikutnya. Kedua, pengujian keberadaan memakai GetAnnotActionID(1) <> 0 alih-alih CheckPageAnnots. Yang terakhir hanya melaporkan keberadaan sebagai flag bergaya boolean, bukan hitungan, jadi action ID yang bukan nol adalah cara yang lebih presisi untuk bertanya, "apakah ada anotasi pertama, dan apakah ia membawa aksi yang bisa saya baca?" Satu nuansa lagi yang patut dicatat: untuk anotasi, skrip aksi JavaScript dibaca langsung dari /JS, dengan mendekode stream saat skrip disimpan seperti itu dan membaca string di kasus lain, jadi ia aman untuk dua encoding umum tersebut

Di mana introspeksi sisi baca ditempatkan

Getter ini memang sengaja sempit. Mereka adalah read murni yang dibangun di atas lapisan action dan destination berbasis integer-handle yang sudah ada di library, jadi tidak menyentuh jalur tulis dan tidak menambah risiko pada dokumen yang juga sedang Anda edit. Mereka melaporkan apa yang benar-benar ada di file; mereka tidak memvalidasinya terhadap kebijakan atau menulis ulang apa pun. Jika tujuan Anda kebalikannya, yaitu membangun bookmark dan anotasi link yang membawa aksi-aksi ini sejak awal, itu berada di sisi tulis, dan pendampingnya di aksi formulir interaktif dan JavaScript di Delphi membahas cara membuatnya. Untuk mengeluarkan konten visual dan struktural dari PDF, bukan grafik navigasinya, lihat mengekstrak teks, gambar, dan font dengan PDFlibPas

Batas yang jujur untuk diingat adalah ini: introspeksi hanya melihat apa yang benar-benar ditulis producer. Bookmark yang action-nya dibiarkan rusak oleh generator, atau destination yang menunjuk ke named target yang tidak pernah didefinisikan, akan muncul sebagai akNone atau page nol alih-alih exception. Itu perilaku yang tepat untuk API baca yang mengaudit file tak tepercaya, tetapi berarti kode Anda harus memperlakukan hasil nol itu sebagai "tidak ada atau belum terselesaikan", bukan sebagai jaminan bahwa inputnya rapi. Introspeksi aksi dan destination bertipe yang ditunjukkan di sini merupakan bagian dari PDFlibPas, library PDF native untuk Delphi dan C++Builder