技術文章

Delphi PDF 中的 GoToR、GoToE 與 Launch 動作

PDFlibPas 為 Delphi 與 C++Builder 開發者提供三種離開目前頁面進行導覽的動作類型:GoToR(Go To Remote)會開啟另一個 PDF 檔案中的特定頁面,GoToE(Go To Embedded)會開啟嵌入目前文件中的 PDF 檔案,而 Launch 會執行外部程式,或透過作業系統命令介面開啟檔案。這三者都定義於 ISO 32000-1 §12.6.4,也就是同樣定義一般 GoTo 動作的動作類型區段,而且每一種都有容易讓人踩到的陷阱:頁碼的意義取決於建立它的呼叫、目標是名稱而不是檔案路徑,以及一組看起來相同、實際上供兩種不同檢視器使用的字串參數

這些都不是假設性的情境。一套技術參考套件可能包含主要手冊、由經銷商依自身排程更新的規格 PDF,以及與兩者一同安裝的校準工具,正好會依賴這類跨文件串接:必須落在規格檔第 5 頁的交叉參照、適合嵌入手冊而不是放在旁邊的資料表,以及直接交給校準工具處理的連結。本文正好與從現有 PDF 讀回書籤與註解動作互為鏡像:該文涵蓋讀取其他產生器已寫入檔案的 GoToR、Launch 或 GoToE 動作;本文則涵蓋從頭建立相同的三種動作類型,包括 PDFlibPas 在寫入任何位元組前所強制執行的欄位層級規則

PDF 動作離開目前頁面的三種方式

PDFlibPas 會在動作的 /S 索引處將本地導覽與其他情況分開,而 GoToR、GoToE 與 Launch 正是目標位於目前頁面之外的三種子類型:GoToR 位於 ISO 32000-1 §12.6.4.3,GoToE 位於 §12.6.4.4,Launch 位於 §12.6.4.5,全部都在同樣定義一般 GoTo 動作的 §12.6.4 動作類型區段中。一般 GoTo 動作的目的地會命名文件內已存在的頁面物件,因此 PDFlibPas 可以立即驗證;GoToR 與 GoToE 無法以相同方式驗證,因為外部檔案可能根本不存在於這台機器上,而嵌入檔案的頁數也不是主文件所追蹤的內容,所以兩者都會攜帶未解析的參照,而不是硬連結——GoToR 使用檔案規格加目的地,GoToE 使用嵌入檔案名稱加目標頁面——Launch 則完全捨棄目的地概念,只命名要由作業系統執行或開啟的項目。這種區分會在寫入端呈現為兩組呼叫:AddLinkToFileAddLinkToFileExAddLinkToEmbeddedPDFAddLinkToLocalFile 等高階單次建立器會一併建立頁面熱點連結註解及其動作,涵蓋大多數實際版面——讀者點選的文字行或圖示;SetActionRemoteDestinationExSetActionLaunchOptions 與對應的 AddActionNext* 等低階設定器,則會在你已持有控制代碼的物件上附加或取代動作:現有書籤、表單欄位觸發程序,或文件與頁面層級的生命週期事件。兩組呼叫最後都會寫入相同形狀的字典;差異在於呼叫它們時所在的位置,以及下一節所述的頁碼意義

如何建立開啟另一個 PDF 檔案中頁面的 GoToR 連結

GoToR 動作需要兩項內容——檔案規格與該檔案內的目的地——而 PDFlibPas 提供兩種不同呼叫來提供第二項內容,各自採用不同的頁碼慣例。高階頁面熱點建立器 AddLinkToFileAddLinkToFileEx 會驗證 PageDestPage 引數必須大於零,這與 PDFlibPas 其他地方使用的 1 起始編號相同,包括 SelectPage。低階設定器 SetActionRemoteDestinationEx 用於在已持有控制代碼的物件上附加或取代 GoToR 動作,則會驗證 DestPage 必須大於或等於零,並直接將它寫入動作的明確目的地陣列,不做任何調整:它需要目標文件的原始零起始頁面索引,也就是 ISO 32000-1 為遠端明確目的地指定的編號。若以傳給高階建立器的相同數字呼叫低階設定器,連結就會提早開啟一頁

var
  Lib: TPDFlib;
  ActionID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('manual.pdf', '') = 1 then
    begin
      Lib.SelectPage(12);
      // Page is 1-based here, same as SelectPage above: this opens
      // the fifth page of specs.pdf.
      Lib.AddLinkToFile(72, 700, 200, 16, 'specs.pdf', 5, 0, 0, 0);

      // A later maintenance pass repoints the same link at a
      // reorganized file. SetActionRemoteDestinationEx edits the
      // action directly, and DestPage here is the zero-based index
      // PDF itself uses for a remote explicit destination -- "the
      // fifth page" is now 4, not 5.
      ActionID := Lib.GetAnnotActionID(1);
      Lib.SetActionRemoteDestinationEx(ActionID, 'specs-2026.pdf',
        4, Ord(dkFit), 0, 0, 0, 0, 0, 0, -1);
    end;
  finally
    Lib.Free;
  end;
end;

SetActionRemoteDestinationEx 的其餘引數同樣是字面值。ValueMask 是位元集合——1 代表左側、2 代表頂端、4 代表右側、8 代表底部、16 代表縮放——PDFlibPas 會在寫入前依據 DestType 檢查它:dkFitR 目的地必須恰好提供 15(四個邊界全部指定,不含縮放),dkFitdkFitB 必須提供 0,而 dkFitH/dkFitV 只接受各自相關的單一座標。在其他有效遮罩中留為未設定的位元,不會從陣列中省略;它們會以明確的 PDF null 寫入,而 ISO 32000-1 將其視為該座標「保留檢視器目前值」——這是表示「跳至此頁,但維持縮放」的合法方式,而不是疏漏。縮放本身會以所傳值的分數儲存,因此要求 150 百分比的呼叫會將儲存值 1.5 傳入陣列,而有效輸入範圍是 0 到 6400

如何連結至嵌入自己文件中的 PDF

AddLinkToEmbeddedPDF 會建立 GoToE 動作,而其目標引數 EmbeddedFileName 是名稱而不是路徑:它必須符合建立附件時傳給 EmbedFileTitle 字串,因為該標題就是 PDFlibPas 儲存在文件 /EmbeddedFiles 名稱樹中的字面索引鍵,而 GoToE 是透過查找該名稱解析,不會再次接觸檔案系統。此函式只會檢查 EmbeddedFileName 不為空且 TargetPage 至少為 1——傳入從未實際嵌入的名稱時,呼叫仍會回傳成功,動作仍會寫入,而每位點選它的讀者都只會得到無法解析的連結

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.NewPage;
    // The Title argument becomes the key PDFlibPas stores in the
    // document's EmbeddedFiles name tree -- that string, not
    // "datasheet.pdf", is the target GoToE resolves against.
    if Lib.EmbedFile('Datasheet', 'datasheet.pdf', 'application/pdf') = 1 then
      Lib.AddLinkToEmbeddedPDF(72, 700, 200, 16, 'Datasheet', 3, 0, 0);
    Lib.SaveToFile('manual.pdf');
  finally
    Lib.Free;
  end;
end;

這裡有兩個版本下限,而不是一個。EmbedFile 需要 PDF 1.4 才能使用 /EmbeddedFiles 名稱樹,而 AddLinkToEmbeddedPDF 又會因 GoToE 動作類型本身將下限提高至 PDF 1.6,因此任何使用此功能的文件,其有效最低版本是 1.6,而不是 1.4。另請注意,這裡的 TargetPage 是 1 起始編號,符合一般 PDFlibPas 慣例——與上一節剛介紹的零起始 DestPage 形成刻意對比,也提醒你適用哪一種頁碼方案取決於動作類型與特定呼叫,而不是某項一概而論的規則。動作的目標字典也可以帶有 /R 項目,其中 C 代表子項目,P 代表父項目,支援進入嵌入檔案或返回其容器的兩段式鏈結;不過 AddLinkToEmbeddedPDF 只會建立子項目方向,因為對正在嵌入文件而非被嵌入文件的文件而言,這才是合理方向

Launch 動作:兩個不可互換的字串目標與一個 FileName

SetActionLaunchOptions 會從單一 FileName 引數將 Launch 動作的檔案目標寫入兩個不同索引,而這兩個索引存放的是兩種不同類型的字串。最上層的 /F 索引會取得檔案規格字典,透過 PDFlibPas 為 GoToR 使用的相同路徑轉換建立,這是 ISO 32000-1 §7.11.3 為檔案規格字典定義的可攜形式。/Win 子字典在 PDFlibPas 寫入時,會將自身的 /F 索引設為完全按照傳入內容取得的原始 FileName 值,完全不進行轉換,因為 ISO 32000-1 §12.6.4.5 將 /Win /F 記載為僅供 Windows 檢視器讀取的純 Windows 路徑字串。若傳入已轉換的可攜路徑並期待兩個索引最後完全相同,/Win 副本仍會原封不動地保留你交給函式的內容

var
  Lib: TPDFlib;
  ActionID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('manual.pdf', '') = 1 then
    begin
      Lib.SelectPage(1);
      Lib.AddLinkToLocalFile(72, 660, 220, 16, 'calibrate.exe', 0);
      ActionID := Lib.GetAnnotActionID(1);
      // Operation 0 leaves this as a normal open -- pass 1 to ask a
      // Windows viewer to print instead. Parameters and
      // DefaultDirectory only ever reach /Win /P and /Win /D, never
      // the top-level /F.
      Lib.SetActionLaunchOptions(ActionID, 'calibrate.exe',
        '/silent /profile:default', 'C:\Tools\Calibration', 0, -1);
    end;
  finally
    Lib.Free;
  end;
end;

請將 Launch 視為三者中摩擦最高的動作,因為它的全部目的就是在 PDF 沙盒外執行程式或開啟檔案,而所有主流檢視器都會據此處理。Adobe Acrobat 的 Enhanced Security 預設會封鎖 Launch 動作或顯示提示,除非目標位於明確信任的位置,而且多數企業 Acrobat 部署都會保持啟用這項保護。因此,交給大眾的文件中的 Launch 動作不是可靠的觸發方式:請預期它會被開啟檔案的檢視器封鎖、要求確認,或靜默忽略,並將它保留給你也能控制檢視器信任設定的封閉環境——內部資訊站、受控的企業部署,或永遠不會離開你管理之電腦的文件

PDF/A 閘門:GoToR 與 Launch 呼叫為何會回傳零

SetActionRemoteDestinationExSetActionLaunchOptions 在目標文件處於任何 PDF/A 符合性模式時都會直接拒絕;兩者都會將文件的 PDF/A 模式檢查作為第一個條件,在碰觸動作前以結果 0 結束,而且不會引發例外。這是刻意的設計。PDF/A 對互動動作的限制特別排除了 Launch,因為讓封存檔案具備執行任意程式的能力,正是長期封存格式要避免的環境相依行為,而 PDFlibPas 也在相同程式路徑對遠端跳轉設定器套用相同的保守閘門。開發時很容易忽略這項實際後果:在一般 PDF 上有效的相同呼叫,在設定了 PDF/A 符合性層級的文件上仍會編譯、執行,卻靜默地什麼都不做,因此請檢查回傳值,不要假定成功——這裡的 0 不是輸入格式錯誤,而是函式庫拒絕與文件自身符合性宣告衝突的要求

GoToR、GoToE 與 Launch 在更大型 PDFlibPas 工作流程中的位置

本文的三種動作類型並不都能到達相同位置。文件與頁面生命週期動作觸發程序的配套文章涵蓋 SetDocumentActionSetPageAction,這兩者可透過共用的 PDF_ACTION_BUILDER_REMOTE_DESTINATIONPDF_ACTION_BUILDER_LAUNCH 常數,將 GoToR 或 Launch 動作附加至 WillClose 等觸發程序——同一個建立器也涵蓋一般 URI 或 JavaScript 觸發程序。GoToE 沒有這類常數,也完全沒有通往該泛用建立器的路徑;PDFlibPas 建構 GoToE 的唯一方式是 AddLinkToEmbeddedPDF,因此它嚴格來說是頁面熱點動作,永遠不是文件或頁面層級的觸發程序。GoToR 與 Launch 若要到達泛用建立器,取捨就在於控制力:它只能建立指向具名遠端目的地的 GoToR,以及只帶檔案名稱與參數的 Launch 動作;本文介紹的明確頁面與適合類型定址,以及 Windows 專用的 Launch 選項,則只能直接透過 SetActionRemoteDestinationExSetActionLaunchOptions 存取

在圍繞這些設定器建立維護工具之前,有一項安全特性值得了解。SetActionRemoteDestinationExSetActionLaunchOptions 會先在暫存字典中建立完整的替換動作,只有在該暫存副本通過驗證後,才會將 /F/D/Win,以及 /NewWindow 索引刪除並複製到作用中的動作上——因此無論是 ValueMask 超出範圍或 FileName 為空等驗證失敗的呼叫,都會保留原始動作,以及已掛在其上的任何 /Next 鏈結,完全不會讓它處於半覆寫狀態。這一點很重要,因為 GoToR 與 Launch 動作都可以放在由 AddActionNextRemoteDestinationExAddActionNextLaunchEx 或更一般的 AddActionNextEx 建立的 /Next 鏈結中,讓單一觸發程序依序執行 JavaScript 記錄項目,再進行遠端跳轉。本文所述的 GoToR、GoToE 與 Launch 建構屬於 PDFlibPas,這是適用於 Delphi 與 C++Builder 的原生 PDF 函式庫