把一份標記報告的封面頁切掉,開啟時頁數可以完全正確,卻仍在三個地方壞掉:點擊「Contents」書籤毫無反應,無障礙檢查器回報一個不屬於任何頁面的結構元素,嚴格驗證器則挑出一筆指向已釋放物件的參照。頁面樹本身沒問題。ISO 32000-1 §7.7.3 只要求該頁離開 /Kids、/Count 減一,而一份檔案可以在滿足這一條的同時,讓半個 catalog 仍指著那頁消失的頁面
七種結構都可能持有對同一頁的參照
其中只有一個是頁面樹。其餘散布在 catalog、大綱、邏輯結構和其他頁面的註解裡,而頁面物件一旦從檔案中消失,這些參照全都解析成 null。檢視器拿這個 null 怎麼辦是它自己的選擇,所以同一份修剪過的檔案在兩個閱讀器裡可能有不同的毛病
- Il name tree sotto
/Names/Dests(§7.7.4, §12.3.2.3) mappa nomi su array di destinazione il cui primo elemento è la pagina - 直接放在 catalog 裡的 1.2 版之前
/Dests字典,裝著同一種以名稱為鍵的陣列 - 大綱項目(§12.3.3)到達某頁的方式,可以是內嵌的
/Dest,也可以是帶/S /GoTo與/D陣列的/A動作 - 結構元素(§14.7.2)帶著
/Pg鍵,指明它們的標記內容住在哪一頁,而它們的/K子項可以是與該頁綁在一起的標記內容參照與物件參照(§14.7.4.3) ParentTree(§14.7.4.4)把頁面與註解的/StructParents編號映射回結構元素,而一個元素可以只住在那裡,完全不出現在從根開始的/K鏈上- 其他頁面上的連結註解(§12.5.6.5)帶著指向該頁的
/Dest或/GoTo動作,而 catalog 的/OpenAction也可能做同樣的事
THotPDF.DeletePage 在碰 /Kids 之前做些什麼
對已載入的文件呼叫 THotPDF.DeletePage(PageIndex) 時,會先把上述每種結構清掃一輪,最後才改寫頁面樹。索引從零起算。在任何東西被改動之前有兩道守衛:超出範圍的索引丟出 Invalid page number,要求移除僅存的一頁也會丟出例外,因為零個子項的 /Pages 節點描述的不是合法文件
清掃順序是固定的:/Names /Dests 樹、舊式 catalog /Dests 字典、/OpenAction、大綱、/StructTreeRoot 連同它的 ParentTree,最後是每個留存頁面的 /Annots 陣列。只有在這之後,頁面物件才被標記為已刪除,它的小工具註解才從 AcroForm 欄位樹上拆下,/Kids、/Count 與每個留存頁面的 /Parent 才被改寫。表單這邊,小工具被移走後一個子項都不剩的父欄位也會被移除,一路往上到 /Fields,所以修剪過的表單不會留著螢幕上什麼都沒有的欄位條目
DeletePages 接受 1 基底的範圍字串,例如 '1,3-5,7-',也就是其他已載入文件頁面操作使用的記法,並從被選中的最高頁碼往下刪除。由上往下刪,讓您寫下的編號在頁面於底下消失時仍保持有效。涵蓋所有頁面的範圍會丟出例外,理由與 DeletePage 拒絕最後一頁相同,而函式會傳回它移除了幾頁
var
Pdf: THotPDF;
Removed: Integer;
begin
Pdf := THotPDF.Create(nil);
try
// LoadFromFile 傳回它載入的頁數
if Pdf.LoadFromFile('tagged-report.pdf') > 0 then
begin
// 從零起算:頁面 0 是封面
Pdf.DeletePage(0);
// 1 基底範圍,按文件現在的狀態編號
Removed := Pdf.DeletePages('3-4,9');
Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
end;
finally
Pdf.Free;
end;
end;
名稱消失,書籤搬移
指向被刪除頁面的具名目的錨點會被移除,指向它的書籤則被改指向。在 /Names /Dests 樹裡,每個節點都會被走訪,每個目的錨點都會用規格允許的兩種形式測試——裸陣列與帶 /D 鍵的字典;陣列第一個元素是被刪除頁面時,名稱/值配對就跟著移除。中間節點若 /Names 與 /Kids 都不剩,就從父節點解開,所以樹上不留空心的葉子。舊式 catalog /Dests 字典逐一鍵值跑同樣的測試,而開在被刪除頁面上的 catalog /OpenAction 會被丟掉,開啟檢視就交給檢視器的預設行為
失去條目的節點也失去它的 /Limits 陣列。新的最低與最高鍵不會重新計算,而檢視器沒有它們照樣解析名稱。§7.9.6 確實要求中間節點與葉節點帶 /Limits,所以如果您管線裡的預檢步驟嚴格執行這條規則,請把修剪後的檔案在同一個工序裡跑一遍預檢
大綱透過 /First 與 /Next 走訪,帶著已拜訪清單與 128 的深度上限,所以自我迴圈的損壞 /Next 鏈無法把呼叫卡死。對每個指向被刪除頁面的 /Dest 陣列或 /GoTo 的 /D 陣列,第一個元素會換成它後面那一頁;被刪的是最後一頁時,則換成它前面那一頁。頁面參照之後的檢視參數保持原樣,所以 /XYZ 跳轉保留自己的 left、top 與 zoom 值,並把它們套用到新的頁面上
這個不對稱是刻意的。解析到空的名稱合法而無害;指向缺失目的錨點的 GoTo 只是不動作。目的錨點為 null 的書籤更糟:它還留在側邊欄、看起來可以點、點了卻沒反應。落在最近的存活頁面上,是兩個不完美結果中較好的一個。改指向只涵蓋明確的目的錨點陣列。/Dest 是名稱字串的大綱項目要透過名稱樹解析,清掃移除那個名稱之後,書籤就無處可去,所以大量使用具名書籤的文件在刪除後值得檢查一遍。下面的檢查用 ResolveLoadedNamedDestination 讀取名稱,它只看根節點的 /Names 陣列;對扁平的名稱樹能給出有意義的答案,分裂成 /Kids 的樹則對每個名稱都傳回 -1。大綱本身的結構,包括不那麼直觀的 /Count 語意,記在在已載入 PDF 上加入書籤與具名目的錨點的指南裡
// 驗證結果,而不是假設它沒問題。
// Memo1 是您自己表單上的一個 TMemo。
Pdf.DeletePage(0);
// ResolveLoadedNamedDestination 只讀根節點的 /Names 陣列,
// 所以這項檢查對扁平的名稱樹才有意義;在使用 /Kids
// 分裂的樹上,每個名稱都會傳回 -1,無論刪沒刪除
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
Memo1.Lines.Add('"cover" is not resolvable from the root /Names array');
// 原本指向封面的書籤現在解析到跟在它後面的
// 那一頁(刪除後是 0 基底的索引 0)
Memo1.Lines.Add(Format('Bookmark "Contents" -> page index %d',
[Pdf.GetLoadedBookmarkPageIndex('Contents')]));
修剪結構樹與 ParentTree
只為被刪除頁面存在的結構元素會被移除,橫跨好幾頁的元素則失去 /Pg 鍵但保留子項。走訪從 /StructTreeRoot 開始,沿 /K 下降到深度 128,同時接受 §14.7.2 允許的陣列形式與單一字典形式的 /K。每個元素先剪子項,再評估元素本身。剪完之後 /K 空了,該元素就被移除,父項把它丟掉。元素自己的 /Pg 指著被刪除頁面、但它還有子項與 /P 父項時,只移除 /Pg,因為元素上的 /Pg 只是給那些沒有自報頁面的標記內容子項用的預設頁,而那些子項很可能住在其他頁上。/Pg 是被刪除頁面、底下又空無一物的元素則直接移除;/Pg 指著該頁的標記內容參照字典也適用最後這條規則
ParentTree 得到同樣的處理,理由很容易錯過:一個元素可能只有從 ParentTree 才到得了。這棵 number tree 把 /StructParents 整數映射到單一元素或元素陣列。每個值都按上面的規則修剪,被移除的值從所屬陣列裡掉出,陣列最後空掉的 /Nums 配對被刪除,/Nums 與 /Kids 都沒有的節點被解開。跳過這一趟,孤兒們就會繼續透過 /Pg 指著已釋放的頁面。對按標籤順序抽取文字的人來說這再直接不過:結構順序的文字抽取走的正是這些樹,而 /Pg 為 null 的元素,就是一段從閱讀順序裡掉出去的段落
為什麼留存頁面上的連結是被移除,而不是改指向?
留存頁面上 /Dest 陣列或 /GoTo 動作指向被刪除頁面的連結註解,會從該頁的 /Annots 移除,它的標籤也跟著走。/Obj 指著該註解的 OBJR 字典從它的結構元素上取下;如果那個 OBJR 是元素唯一的子項,元素也被移除,然後 ParentTree 再修剪一次。把 OBJR 留在原地會違反 §14.7.4.3——該條要求 /Obj 參照一個真實存在的物件——而 PDF/UA 檢查器會看到一個物件參照無處可去的 Link 元素
書籤搬移,連結不會。正文裡「見第 3 頁」的交叉參照在第 3 頁消失後就是錯的,悄悄把它指到第 4 頁只是把壞連結變成誤導人的連結。書籤落在下一章是導航;正文連結則是對內容的宣稱。移除是較安全的預設。如果您的工作流程需要保住那些連結,請在呼叫 DeletePage 之前改寫它們的目的地,因為呼叫之後它們已經不在 /Annots 陣列裡了
增量存檔與直接子項的陷阱
在 BeginIncrementalUpdate 與 SaveIncrementalUpdate 之間,DeletePage 的行為完全相同。附加的區段裝著清掃改動過的物件,加上對擁有自己物件編號的被移除物件的已釋放條目,例如頁面本身與被刪除的連結註解。原始 %%EOF 之上的所有內容逐位元組保持原樣,符合 §7.5.6 的要求
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('tagged-report.pdf');
Pdf.DeletePage(0);
// 失去直接 /MCR 子項的結構元素,會以
// 改寫後的形式附加,/K 陣列變短
Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
Pdf.Free;
end;
這正是手寫修剪器出錯的地方。標記內容參照(/Type /MCR)與物件參照(/Type /OBJR)通常是坐在父元素 /K 陣列裡的直接字典。直接物件沒有物件編號、沒有 xref 條目,所以無法單獨釋放。直覺的做法是照樣把每個被移除的子項「釋放」掉,而任何把直接物件映射到持有它的間接物件的寫入器,接著就會釋放那個持有者——也就是留存的結構元素。該元素被寫成 null,從未被碰過的頁面上的標記內容就此失去標籤。HotPDF 只在物件擁有自己的編號時才把它送進已釋放清單;對直接子項,它把容器元素標記為已變更,讓元素在子項消失的狀態下被改寫,已釋放清單保持乾淨
同樣的謹慎也決定了 DeletePage 對已載入文件刻意不碰什麼。被刪除頁面的內容串流、XObject 與非小工具註解都以物件的身分留著,因為載入的檔案可能把其中任何一個與留存頁面共用,而在刪除當下沒有便宜的方法證明不是。移除頁面樹的那筆參照,對一份正確的檔案來說已經足夠。這些殘留還占多少位元組是另一項量測,物件相依圖與保留位元組分析就是做這件事的工具
DeletePage 還是 DeleteLoadedPage?
凡是使用者要求的頁面移除都用 DeletePage。THotPDF.DeleteLoadedPage(PageIndex) 是輕量變體:它套用同樣的兩道守衛、位移已載入的頁面清單、改寫 /Kids 與 /Count、讓渲染頁面快取失效,並觸發 OnLoadedDocumentModified。它不碰名稱樹、大綱、結構樹或其他頁面的註解,也不把頁面物件標記為已刪除。ComposeLoadedPagesNUp 就是它適合的場合:它附加新拼版完成的版面,把來源頁畫成 form XObject,然後用 DeleteLoadedPage(0) 丟掉每張原始頁,因為版面引用的是頁面的內容而不是頁面物件。至於在一份有標記、有書籤、有交叉連結的檔案上「把第 7 頁從這份合約拿掉」,DeletePage 才是讓每筆參照保持一致的呼叫,無論您用 SaveLoadedDocument 存檔,還是用 SaveIncrementalUpdate 做增量更新
這兩個方法、範圍式的 DeletePages,以及上面用到的增量更新路徑,都是 HotPDF Delphi Component 的一部分,它在機器上不需要檢視器執行環境,就能從 Delphi 與 C++Builder 程式碼編輯已載入的 PDF