假設您接手一整個資料夾的 PDF,任務看似簡單:找出哪些書籤會開啟外部 URL、哪些會執行 JavaScript,以及內部書籤實際跳到哪個位置。翻開 API 參考資料後,卻發現函式庫能建立每一種動作,卻沒有方法可以讀回既有動作。PDF 工具經常出現這種讀寫能力不對稱的情況。建立一個開啟 https://example.com 的書籤只需一行程式碼;若要詢問既有書籤「會執行什麼動作、目標又是什麼」,通常得自行走訪 /A、/S、/Dest 的原始物件樹,還要正確處理一系列目的地檢視模式
PDFlibPas 是適用於 Delphi 與 C++Builder 的原生 Object Pascal PDF 函式庫,過去也有相同缺口:寫入端提供完整設定方法,讀取端卻只回傳原始 TPDFObject,其餘解析工作得由開發人員自行完成。v3.77.0 新增一組小而明確的型別化檢視方法,以記錄型別回報動作種類、動作內容與目的地幾何資訊。本文說明這些方法如何對應 ISO 32000-1 的動作與目的地模型,並解析三個容易讓自製讀取程式在沒有警告的情況下產生錯誤的陷阱
為什麼讀取動作比寫入動作更難
PDF 動作是一個字典,其中的 /S 鍵指定子類型,例如 GoTo、GoToR、URI、Launch、Named、JavaScript,以及較少見的其他類型(ISO 32000-1 §12.6.4)。難點在於每一種子類型都把內容放在不同的鍵,並沒有統一的目標欄位。URI 動作把位址存入 /URI;GoToR 與 Launch 動作把檔案規格存入 /F;JavaScript 動作把指令碼存入 /JS,而且內容可能是字串或資料流。GoTo 動作本身沒有這類內容,其目標是掛在 /D 下的目的地,必須另外解析
寫入動作時,您一開始就知道其種類,因此上述差異不成問題。讀取動作時,則必須先依 /S 分流,再存取正確的鍵,並處理同一個邏輯概念(「這個動作指向什麼」)所使用的三種互不相容編碼。型別化讀取方法會封裝這些分支。GetOutlineActionInfo 與 GetAnnotActionInfo 都會回傳 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;
這筆記錄透過 Kind 指出哪些欄位有效。若 Kind 回傳 akURI,請讀取 URI 並忽略其餘欄位。若回傳 akGoTo,內容欄位都不適用,接下來應以另一個呼叫解析目的地。當書籤或註解完全沒有動作時,akNone 會明確表達此狀態,不必猜測零值代表什麼
走訪大綱樹以尋找書籤
檢視書籤內容之前,您需要先取得其識別碼。PDFlibPas 使用整數 ID 識別大綱節點;FindOutlineByTitle 可依畫面上顯示的文字尋找節點,並明確控制搜尋深度:
type
TPDFlibOutlineSearchDepth =
(osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);
function FindOutlineByTitle(const Title: WideString;
StartOutlineID: Integer;
Depth: TPDFlibOutlineSearchDepth): Integer;
Depth 引數特別重要。osdSiblingsOnly 只掃描起始節點同一層的相鄰節點,不會進入任何相鄰節點的子節點。osdChildrenOnly 只查看起始節點下一層的直接子節點。osdFullSubTree 則會遞迴走訪整個分支。選錯模式不會產生錯誤,只會找不到項目:若標題位於兩層以下,僅搜尋同層節點便會回傳零,讓人誤以為書籤不存在。若要從文件根節點開始搜尋,請把 GetFirstOutline 當作起始 ID 傳入
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;
標題會以 WideString 進行完全相符比對,因此區分大小寫,也會忠實比對文件中儲存的 Unicode 文字。若來源 PDF 由不同軟體產生,請依文件實際使用的形式正規化搜尋標題,否則即使書籤存在仍可能找不到
解析書籤的動作與目標
取得識別碼後,GetOutlineActionInfo 會提供型別化資訊。使用方式很直接:呼叫方法、依 Kind 分支,再讀取該種類所填入的欄位
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;
第一個真正的陷阱就在這裡,而且是在實作測試時才被發現。舊有方法 GetActionURL 看似適合用來讀取 URI 動作,實際上卻會透過 /F 鍵解析檔案規格。對目標確實是檔案的 GoToR 與 Launch 而言,這個作法沒有問題;但 URI 動作使用的是另一個鍵。其位址是直接存放在動作 /URI 鍵中的字串,不是檔案規格。若把 URI 動作交給檔案規格解析流程,只會得到空白或無意義的結果。型別化讀取方法會為 akURI 直接讀取 /URI,只有 akGoToR 與 akLaunch 才使用檔案規格解析器,避免自製程式混淆兩者
目的地檢視模式與幾何資訊
akGoTo 表示「在這份文件內移動」,但沒有說明目的地在哪裡,也沒有說明檢視器應如何顯示該位置。這些資訊由目的地負責,而且細節比一般預期更多。PDF 目的地不只是頁碼,而是頁面加上一種檢視模式,用來指定檢視器如何呈現該頁(ISO 32000-1 §12.3.2.2)。GetOutlineDestinationInfo 會以記錄型別回傳這些資訊:
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;
八種目的地種類對應不同的畫面呈現方式。dkXYZ 會在指定縮放比例下,把特定座標放到左上角,因此使用 Left、Top 與 Zoom。dkFit 讓整頁配合視窗大小,並忽略座標。dkFitH 與 dkFitV 分別依單一上緣或左緣座標配合頁面寬度或高度。dkFitR 會讓指定矩形配合視窗,因此四個邊界值都有效。dkFitB* 系列採用相同概念,但依據可見內容的邊界方框,而不是完整頁面。只有先判斷種類,才能知道哪些欄位有效,避免把預設為零的無效座標當成結果

底層實作採用刻意安排的數值對應,因此轉換可以保持可靠。內部 GetDestType 依 XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV 的順序,為八種目的地種類回傳 1..8。TPDFlibDestinationKind 的列舉值採用相同順序:dkXYZ 的序數為 1,dkFitBV 為 8,而 dkNone 為 0。因此轉換可在檢查範圍後直接轉換序數,不需要一份可能隨列舉擴充而失去同步的對照表。這項細節雖小,卻能避免重新排列列舉值後出現偏移一位的錯誤
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 為零表示目的地無法解析,通常是因為動作未包含目的地,或找不到指定名稱的目的地。信任任何座標之前,請先檢查此值。另請注意,GetOutlineDestinationInfo 會檢查目的地可能出現的兩個位置:書籤本身的 /Dest,以及內嵌 GoTo 動作中的 /D。您不需要事先知道產生器採用哪一種形式
註解動作與 SelectPage 陷阱
連結註解承載動作的方式與書籤完全相同,GetAnnotActionInfo 也會回傳相同的 TPDFlibActionInfo 記錄,並採用先判斷種類、再讀取內容的模式。不過此處有一項不適用於大綱的狀態條件,也是第三個陷阱
註解隸屬於頁面,而 PDFlibPas 透過目前頁面的狀態公開註解;只有選取該頁面之後,這項狀態才有效。若未先呼叫 SelectPage(N) 就呼叫 GetAnnotActionInfo,註解識別碼會是零,該呼叫也會回傳 akNone,讓您誤以為頁面沒有可執行動作的註解。修正方式只需一行,但逐頁迴圈中很容易漏掉:
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;
迴圈中有兩個刻意安排的細節。第一,每次迭代都在存取註解之前呼叫 SelectPage(P),因為每頁的註解狀態不會自動延續到下一頁。第二,存在性測試使用 GetAnnotActionID(1) <> 0,而不是 CheckPageAnnots。後者只會以布林旗標回報是否存在註解,不是註解數量;非零動作 ID 因此更能精確回答「第一個註解是否存在,且是否包含可讀取的動作」。另一項細節是,註解的 JavaScript 動作會直接讀取 /JS:內容若以資料流儲存就先解碼,否則直接讀取字串,因此可支援兩種常見編碼形式
讀取端檢視功能的適用場景
這些讀取方法刻意維持精簡範圍。它們建立在函式庫既有的整數識別碼、動作與目的地層之上,只讀取內容,不會進入寫入流程,也不會增加同時編輯文件的風險。它們只回報檔案中的資料,不會依政策驗證或改寫內容。若您的需求相反,想建立帶有這些動作的書籤與連結註解,請參閱在 Delphi 中建立互動式表單動作與 JavaScript。若要擷取 PDF 的可見內容與結構,而不是導覽關係,請參閱使用 PDFlibPas 擷取文字、影像與字型
請留意這項功能的界線:它只能檢視產生器實際寫入的內容。若書籤動作格式錯誤,或目的地指向從未定義的命名目標,結果會是 akNone 或頁碼零,而不是例外。對於需要稽核不受信任檔案的讀取 API,這是合適的行為;但程式仍應把零值結果解讀為「不存在或無法解析」,不能視為輸入格式正確的保證。本文使用的型別化動作與目的地檢視功能,包含在適用於 Delphi 與 C++Builder 的原生 PDF 函式庫 PDFlibPas 中