HotPDF 透過 AddLoadedOutline 在 Delphi 中為現有 PDF 新增書籤,這會在已載入的檔案中建立任何巢狀層級的大綱項目,並透過 ResolveLoadedNamedDestination 與 AddLoadedNamedDestination 讀取和寫入具名目的地;載入檔案、建置樹狀結構、呼叫 SaveLoadedDocument,原本空白的閱讀器側邊欄現在便會包含一個可運作的目錄
促成這一切的情境非常常見;您將十幾個合約合併為一個審查套件,或是將三個產品手冊拼接成一個單一的可分發檔案,且輸出在結構上沒有問題:每頁都在、每個字型都完整;接著有人在 Acrobat 中開啟它,卻發現書籤面板是空的;一份沒有導覽功能且長達 400 頁的檔案在技術上是完整的,但在實際使用上卻根本無法使用;在 2.347.0 版本之前,HotPDF 可以讀取、修改和刪除已載入檔案中的書籤,但無法建立它們;現在這個差距已被彌補,本篇文章將引導您瞭解新的 API 介面,以及如果您將 /Count 鍵視為簡單的項目計數器,則會讓您陷入困境的 PDF 秘辛
大綱樹狀結構的實際本質
PDF 大綱是字典的雙向連結樹狀結構,而不是扁平列表,這種結構解釋了 API 中的每個參數;ISO 32000-1 §12.3.3 對此進行了定義:檔案目錄指向一個 /Outlines 根,根透過 /First 與 /Last 指向其第一個和最後一個子項目,而同層級項目則透過 /Next 與 /Prev 進行鏈接,同時每個項目都透過 /Parent 指回上層;每個項目都包含一個 /Title 字串,且通常包含一個 /Dest,用於說明點擊它時應引導讀者前往何處
目的地有兩種形式,ISO 32000-1 §12.3.2 刻意將它們分開;明確目的地是一個行內陣列,例如 [page /XYZ x y zoom]:對頁面物件的直接參照加上檢視規格;具名目的地則是儲存一個名稱字串,而實際的目標存在於檔案目錄下 /Names /Dests 的名稱樹中,這一層間接設定可讓多個連結共享同一個目標,且在不變動連結的情況下移動目標;HotPDF 在建立書籤時會寫入明確的 /XYZ 目的地,並為您提供讀取與擴充名稱樹的獨立呼叫
如何在 Delphi 中為現有 PDF 新增書籤?
在 LoadFromFile 之後呼叫 AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom):傳入 ParentIndex = -1 代表頂層項目,或是傳入現有頂層項目的以零為基底之索引,以便在其下方建立巢狀結構;如果檔案從未有過大綱,該函式會建立 /Outlines 根項目、建置項目字典、寫入指向以零為基底之 DestPageIndex 的 /Dest [page /XYZ x y zoom] 陣列,並將新項目連結至同層級項目鏈中;它在成功時傳回 0,失敗時傳回 -1(例如 DestPageIndex 超出範圍時)
一個與排序相關的連結細節非常重要:新項目會插入到其父項目子鏈的「前端」,做為新的 /First;因此,如果您將「Chapter 1」與「Chapter 2」新增為頂層項目,Chapter 2 會顯示在側邊欄中 Chapter 1 的上方,而 Chapter 1 則會移至頂層索引 1;實際的模式是按相反的閱讀順序新增項目,或是新增每個父項目並在其仍位於索引 0 時立即填入其子項目
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
begin
// Add chapters in reverse reading order: each new
// top-level entry becomes /First (index 0).
Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
// Chapter 1 is now top-level index 0; nest sections
// under it (children also prepend, so reverse order).
Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
end;
finally
Pdf.Free;
end;
end;
X、Y 與 Zoom 參數直接對應到 /XYZ 目的地,且預設值皆為 0;PDF 將座標原點置於左下角,因此 Y = 792 會指向 US Letter 頁面的頂部,而根據 ISO 32000-1,/XYZ 欄位中的零值會通知檢視器保留其目前設定,這幾乎在所有情況下都是您所希望的縮放行為;所有操作都發生在記憶體中已載入的物件圖上;在執行 SaveLoadedDocument 之前不會寫入磁碟,這與已載入檔案 API 用於 在已載入 PDF 上重寫標題、作者與 XMP 中介資料 的「先編輯後儲存」模式相同
重新定位書籤:頁面跳轉與 URI 動作的對比
現有書籤可以重新連結而不需要重新建立,HotPDF 為這兩種情況提供了兩種不同的呼叫,因為底層的 PDF 結構確實不同;SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) 會將位於 Index 的頂層項目 /Dest 替換為全新的 /XYZ 陣列,這是解決合併後常見失敗(即每個書籤仍然指向原始合併前檔案的頁碼)的工具;SetLoadedOutlineURI(Index, URI) 則在結構上執行不同的操作:它附加一個帶有 /S /URI 的 /A 動作字典,將書籤轉換為網頁連結,而不是檔案內跳轉
// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);
// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');
Pdf.SaveLoadedDocument('retargeted.pdf');
設計樹狀結構時,請明確區分這兩種機制;/Dest 是純粹的檔案內部導覽;URI 動作則完全離開檔案,且 ISO 32000-1 期望大綱項目透過其中一種機制進行導覽,而不是同時使用兩者;因此,請將 SetLoadedOutlineURI 保留給尚未包含頁面目的地的項目,例如樹狀結構末端的「產品首頁」或「回報問題」葉節點,而不是轉換後的章節標題;此處寫入的 URI 動作與用於頁面內容連結註解的 /S /URI 結構相同,這在 使用 HotPDF 在 PDF 檔案中建立超連結的指南 中有更深入的介紹
具名目的地在已載入 PDF 上如何運作?
具名目的地是 ISO 32000-1 §12.3.2.3 的穩定錨點機制:檔案在目錄的 /Names /Dests 名稱樹中保留名稱到目的地的對應關係,而不是在每個連結中嵌入頁面參照,且連結按名稱參照項目;ResolveLoadedNamedDestination(Name) 在該樹中尋找名稱,並傳回其指向的以零為基底之頁面索引,如果名稱不存在則傳回 -1;解析器可處理規格允許之對應值的兩種儲存形式:無封裝的目的地陣列(如 [page /XYZ x y zoom]),以及將陣列封裝在 /D 鍵底下的字典形式
AddLoadedNamedDestination(Name, DestPageIndex, X, Y) 則相反:它在 /Names /Dests 樹中註冊一個新錨點,並在檔案沒有該樹時建立該樹,成功時傳回 True;這兩個呼叫共同涵蓋了合併檔案必須遵循上游工具寫入的錨點,並發布新錨點供下游連結定位的工作流程
var
PageIdx: Integer;
begin
// Honor an anchor another tool wrote into /Names /Dests.
PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
if PageIdx >= 0 then
Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);
// Publish a new anchor for other links to target.
if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
Pdf.SaveLoadedDocument('anchored.pdf');
end;
實際的限制:HotPDF 寫入的目的地(無論是書籤還是具名錨點)都是 /XYZ 目的地;規格定義的其他明確類型(例如 /Fit、/FitH、/FitB 等)不會由此類呼叫產生,儘管縮放比例為零的 /XYZ 已經涵蓋了「前往此位置,保留我的縮放設定」的主要使用情境;此外,GetLoadedBookmarkPageIndex(Title) 這項傳入書籤標題並傳回其目標頁面的便利尋找方法只會掃描大綱樹的頂層,因此請用它來驗證您剛建立的章節層級項目,而不是深度巢狀的葉項目
/Count 陷阱:它計算的是子孫項目,而不是頂層項目
大綱根部的 /Count 鍵並非如大多數開發人員所假設的意思,而錯誤的假設會產生看起來像資料損毀的測試失敗;ISO 32000-1 §12.3.3 將 /Count 定義為所有層級上「可見子孫項目」的總數,而不是頂層項目的數量;在個別項目上,正負號也帶有特定含義:正值表示項目已展開且側邊欄中會顯示該數量的子孫項目,而負值表示項目已收合且其內部隱藏了 |N| 個子孫項目
這是當建置已載入檔案書籤 API 時,從 HotPDF 的迴歸測試套件中發現的;一個測試檔案包含三個頂層書籤,其中一個帶有子書籤;在刪除一個頂層項目後,讀取根部 /Count 的 GetLoadedOutlineCount 傳回了 1,而不是預期的 2;沒有任何內容損毀:最初的 3 從一開始就不是「三個頂層項目」,而正確的重新計算必須遍歷頂層鏈,累加每個節點及其正數的 /Count,並跳過收合節點的子孫項目;這對您自己的程式碼的啟示很直接:絕不要在結構編輯後斷言 /Count 的變化量,因為該值會隨可見子孫項目呈非線性變化,請改用內容驗證結構
// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
ShowMessage('Outline verified');
這在已載入檔案工具集中的位置
書籤與具名目的地的建立,健全了已載入檔案 API 已經建構一段時間的架構:同一個「載入-編輯-儲存」循環已經能重寫檔案中介資料、新增連結註解,以及 透過 XFDF 匯入與匯出實現註解的雙向轉換;其模式是統一的:LoadFromFile、一系列針對記憶體中物件圖的 *Loaded* 呼叫,接著是單次 SaveLoadedDocument,這使得將大綱建置折疊至現有的合併或蓋印管線中,做為儲存之前的額外步驟變得非常簡單
此處討論的 API(包括 AddLoadedOutline、SetLoadedOutlineDestination、SetLoadedOutlineURI、ResolveLoadedNamedDestination、AddLoadedNamedDestination 與 GetLoadedBookmarkPageIndex)隨 Delphi 與 C++Builder 的標準 HotPDF 元件 出貨,不需要外部相依性,且不需要獨立的檢視器執行期環境