技術文章

在 Delphi 中刪除 PDF 頁面而不留下懸空參照

HotPDF Delphi Component 透過 THotPDF.DeletePage 從已載入的 PDF 刪除頁面,而從 2.751.0 版起,這個呼叫也會剪掉每一個仍然指向該頁面的文件層級參照:/Names /Dests 樹裡的具名目的錨點、舊式的 catalog /Dests 字典、書籤的 /GoTo 動作、/StructTreeRoot 底下的結構元素、ParentTree、註解的 OBJR 項目,以及留存頁面上的連結註解。頁面樹最後才重建,此時已經沒有其他東西能到達被刪除的物件

它防的失效很容易重現、卻很難診斷。把一份標記報告的封面頁刪掉、存檔、打開結果:Acrobat 顯示的頁數正確,但「目錄」書籤現在哪都到不了,無障礙檢查器回報一個沒有頁面的結構元素,而嚴格驗證器列出一筆指向已釋放物件的參照。頁面樹裡沒有任何地方是錯的。問題在於 PDF 頁面不只是 /Pages 的一片葉子,它是半個 catalog 都指著它的目標,而把葉子拿掉,就讓那些指標全部懸空

為什麼只從 /Kids 移除一頁並不夠

因為 ISO 32000-1 允許至少七種獨立結構持有對同一個頁面物件的參照,而其中只有一種是頁面樹。把該頁從 /Kids 拿掉並把 /Count 減一,滿足了 §7.7.3,而其他每一筆參照都變成指向一個在 xref 裡被釋放、或根本不存在於改寫後檔案裡的物件。沿著其中一個指標走到底的檢視器會拿到 null,而它拿那個 null 怎麼辦,就取決於該檢視器

  • /Names /Dests 底下的名稱樹(§7.7.4、§12.3.2.3)把名稱映射到目的錨點陣列,而陣列的第一個元素就是該頁
  • 直接放在 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 也可能做同樣的事
為什麼只把 HotPDF 頁面從 /Kids 移除並不夠:ISO 32000-1 允許 /Names /Dests 名稱樹、舊式 catalog /Dests 字典、大綱項目、帶 /Pg 的結構元素、ParentTree、連結註解與 /OpenAction 全都持有對同一個頁面物件的參照,而只有頁面樹會被重建
PDF 頁面是半個 catalog 都指著的目標:把葉子拿掉滿足了頁面樹,而其他每個指標都解析成 null,於是修剪過的報告失去它的目錄書籤,也過不了無障礙檢查

THotPDF.DeletePage 在碰頁面樹之前先清理什麼

對已載入的文件呼叫 THotPDF.DeletePage(PageIndex) 時,會先跑完整趟參照清掃,接著用 DeleteObj 把該頁面物件標記為已刪除、把任何小工具註解從 AcroForm 欄位樹上拆下、位移內部的頁面陣列,最後呼叫 RebuildLoadedPageTree 改寫 /Kids、/Count 以及每個留存頁面的 /Parent。清掃以固定順序走訪 catalog:/Names /Dests 名稱樹、舊式 /Dests 字典、/OpenAction、大綱樹、/StructTreeRoot 與它的 ParentTree,最後是每個留存頁面的 /Annots 陣列。每一步都依規格允許該結構在沒有這一頁時怎麼做,決定一筆參照是被移除、被改指向,還是被留下。在這些動作之前有兩道守衛:DeletePage 對超出範圍的索引丟出 Invalid page number,並拒絕移除最後一頁,因為一個零子項的 /Pages 節點不是合法的 PDF;而 DeletePages 採用與其他已載入文件頁面操作相同的 1 基底 "1,3-5,7-" 記法,並從被選中的最高索引往下迭代,讓您寫下的索引在它工作時保持有效

THotPDF.DeletePage 在碰頁面樹之前跑的固定參照清掃:守衛先拒絕超出範圍的索引或最後一頁,接著剪掉 /Names /Dests 與舊式 /Dests、丟掉 /OpenAction、把大綱改指向 NearestRetainedPage、剪掉 StructTreeRoot 與 ParentTree、移除留存頁面的連結,最後才跑 RebuildLoadedPageTree
每種結構都得到規格允許的處理:名稱消失、書籤落到最近的留存頁面、結構元素失去 /Pg 或直接消失,而 /Kids 的改寫只在沒有其他東西能到達被刪除物件之後才發生
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('tagged-report.pdf', '') > 0 then
    begin
      // 從零起算:丟掉封面頁。指向它的具名目的
      // 錨點、書籤、結構樹、ParentTree 與連結
      // 註解,都會在 /Pages 樹被重建之前
      // 先剪掉。
      Pdf.DeletePage(0);
      // 批次用時採用 1 基底範圍記法,內部從最高索引
      // 先處理,讓較小的索引保持有效。
      Pdf.DeletePages('3-4,9');
      Pdf.SaveLoadedDocument('tagged-report-trimmed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

具名目的錨點與書籤的處理方式差在哪裡

具名目的錨點被移除,書籤則被改指向,因為一個不再存在的名字是可以接受的結果,而一個沒有目的錨點的書籤是看得見的缺陷。在 /Names /Dests 樹裡,HotPDF 會走訪每個節點,對每一個目的錨點,不管是裸陣列形式還是帶 /D 鍵的字典形式,都用被刪除的頁面去測試,並在陣列的第一個元素就是該頁時移除那個名稱/值配對。一個 /Names 與 /Kids 最後都空的節點會被標記為已刪除並從父節點解開,所以這棵樹永遠不會留著空心的葉子。同樣的測試也會跑過舊式 catalog /Dests 字典,而 catalog 的 /OpenAction 如果開在該被刪除頁上,就直接丟掉。這裡有一條界線:當一個名稱樹節點失去項目時,HotPDF 會刪掉該節點的 /Limits 配對,而不是重新計算新的最低與最高鍵,而雖然檢視器在沒有它的情況下解析名稱一切正常,照著 ISO 32000-1 §7.9.6 讀的嚴格合規檢查器,可能會對一個缺少 /Limits 的非根節點提出警告

大綱項目則是反方向。RetargetOutlineDestinations 從大綱根開始走訪 /First 與 /Next,帶著一份已拜訪清單與 128 的深度上限,好讓一棵損壞的環狀樹無法把這個呼叫卡死,而對每一個指向該頁的 /Dest 陣列或 /GoTo 動作的 /D 陣列,它把第一個元素換成 NearestRetainedPage:被刪除頁面之後的那一頁,若被刪的是最後一頁則是它之前的那一頁。頁面參照之後的檢視參數保持原樣。所以一個原本指向被刪除章節開頭頁的書籤,會落在剩下內容的第一頁,而不是從側邊欄消失,這正是審閱者對一份修剪過文件所期待的行為。不過目的錨點的測試只比對明確的陣列:一個 /Dest 是名稱字串、原本解析到被刪除頁面的大綱項目不會被改指向,因為名稱樹的項目已經不見了,而那筆參照現在解析到的不是已釋放的物件而是一片虛無,所以檢視器會把它當成死書籤。大綱樹本身的機制,也就是 /First、/Next 與不那麼直觀的 /Count 語意,記在在已載入 PDF 上加入書籤與具名目的錨點的指南裡

// 驗證這趟清掃,而不是相信它。
Pdf.DeletePage(0);
if Pdf.ResolveLoadedNamedDestination('cover') = -1 then
  ShowMessage('Named destination "cover" was pruned');
// 原本指向封面的書籤現在解析到跟在它後面
// 的那一頁(刪除後是 0 基底的索引 0)。
if Pdf.GetLoadedBookmarkPageIndex('Contents') = 0 then
  ShowMessage('Bookmark retargeted to the nearest retained page');

結構樹與 ParentTree 會怎樣

只因為被刪除頁面才存在的結構元素會被移除,而橫跨好幾頁的元素失去它們的 /Pg 鍵,但保留子項。PruneStructureElement 從 /StructTreeRoot 沿著 /K 鏈下降到深度 128,同時處理 §14.7.2 允許的陣列形式與單一字典形式的 /K。對每個元素,它先剪子項,再評估元素本身:如果剪完之後 /K 空了,該元素被標記為已刪除,父項把它丟掉。如果元素自己的 /Pg 指著被刪除的頁面,而它還有子項以及一個 /P 父項,則只移除 /Pg,因為元素上的 /Pg 是它那些標記內容子項的預設頁,而那些子項可能明確參照其他頁。只有 /Pg 就是被刪除頁面、底下又什麼都不剩的元素,才會被直接移除

ParentTree 得到同樣的處理,而理由是開發期間真的踩到的那個:一個結構元素可以只從 ParentTree 到達,別無他路。number tree 把 /StructParents 整數映射到單一元素或一組元素,而 PruneParentTreeNode 對它找到的每個值跑 PruneStructureElement、移除已被剪掉的值、在值陣列為空時刪掉該 /Nums 配對,並解開一個 /Nums 與 /Kids 都已消失的節點。只剪 /K 的子代,會讓那些孤兒元素透過 /Pg 指著已釋放的頁面,並透過它們的 /MCR 子項指著已釋放的標記內容參照。如果您按結構順序抽取文字,這件事直接要緊:結構順序的文字抽取走的正是這些樹,而一個 /Pg 為 null 的元素,就是一段從閱讀順序裡默默掉出去的段落

留存頁面上的哪些連結註解會被移除

留存頁面上任何一筆連結註解,只要其 /Dest 陣列或 /GoTo 動作指向被刪除的頁面,就會連同它在結構樹上的歸屬一起被移除。RemoveRetainedPageDestinationAnnotations 走過目標以外每一頁的 /Annots 陣列、套用與大綱相同的目錨點測試、把匹配的註解標記為已刪除、從陣列中丟掉它,然後呼叫 PruneAnnotationReferencesInStructureTree,讓那個 /Obj 指著該註解的 OBJR 字典從它的結構元素上被移除,如果 OBJR 是該元素唯一的子項,元素本身也會被移除。把 OBJR 留在原地會違反 §14.7.4.3,該條要求 /Obj 必須參照一個存在的物件,而且在 PDF/UA 檢查裡會顯示為一個背後沒有註解的標記連結。注意與書籤的不對稱:連結是被移除,不是被改指向。正文裡一句「見第 3 頁」的交叉參照在第 3 頁消失之後就是錯的,而把它指到第 4 頁會是一種謊言,情況與書籤落在最近的章節不同,所以如果您的工作流程需要保住那些連結,請在呼叫 DeletePage 之前自己改指向

為什麼被移除的 /MCR 或 /OBJR 絕不能被登錄為已釋放

因為標記內容參照與物件參照通常是父元素 /K 陣列裡的直接字典,而增量變更註冊表會把一個直接物件解析到包含它的最近間接物件。當 RemoveArrayItem 從 /K 陣列丟掉一個子項時,只有在它是 THPDFLink 或非間接值時它才釋放那個記憶體中的物件,而 MarkRemovedObject 只在物件的編號大於零時才把物件登錄到已釋放清單。這個清掃的第一版沒有做那個區分,而在增量存檔裡的效果正是註冊表被設計來做的事:RegisterIncrementalChange 從那個直接的 /MCR 往上走到它的圖交易根,也就是擁有它的那個留存結構元素,並把該元素寫成 null。一份少了一頁的文件回來時,其他頁面上的標記內容悄悄變成未標記。對一個直接子項唯一正確的做法,是透過 TouchContainer 把它的容器標記為 dirty,好讓容器被改寫,並且完全不碰已釋放清單

為什麼被移除的 /MCR 或 OBJR 子項在 HotPDF 裡絕不能被登錄為已釋放:增量變更註冊表會把直接字典解析到最近的間接容器,所以第一版把留存結構元素寫成 null、讓存活的頁面悄悄變成未標記,而現在 TouchContainer 會改寫容器並完全不碰已釋放清單
釋放記憶體中的子項只保留給 THPDFLink 或非間接值、以及編號大於零的物件,所以增量存檔只會附加被碰過的容器與那個被釋放的頁面物件
// 增量更新:只有被碰過的容器與被釋放的
// 頁面物件會落進附加的區段裡。
Pdf := THotPDF.Create(nil);
try
  Pdf.BeginIncrementalUpdate('tagged-report.pdf');
  Pdf.DeletePage(0);
  // /K 失去一個直接 /MCR 的留存結構元素會被
  // 就地改寫,絕不會寫成 null。
  Pdf.SaveIncrementalUpdate('tagged-report-trimmed.pdf');
finally
  Pdf.Free;
end;

同樣的謹慎也決定了 DeletePage 對已載入文件刻意不釋放什麼。被刪除頁面的內容串流、XObject 與非小工具註解都留著當物件,因為一份載入的檔案可能把其中任何一個與留存的頁面共用,而在刪除當下沒有便宜的方法證明不是。移除頁面樹的那筆參照對正確性來說已經足夠;那些物件還占著的位元組是另一個問題,而物件相依圖與保留位元組分析正是用來量測一份修剪過的文件還帶著什麼的工具

DeletePage 與 DeleteLoadedPage:該呼叫哪一個

任何面向使用者的頁面移除都呼叫 DeletePage,而把 DeleteLoadedPage 留給整份文件正在被重排、沒有任何文件層級參照值得保留的情況。THotPDF.DeleteLoadedPage(PageIndex) 是 2.508.0 版加入的輕量變體:它位移內部的頁面陣列、呼叫 RebuildLoadedKidsArray 改寫 /Kids 與 /Count、讓渲染頁面快取失效,並觸發 OnLoadedDocumentModified。它不走名稱樹、大綱、結構樹或其他頁面的註解,也不把頁面物件標記為已刪除。在 N-up 拼版裡它就是對的工具:HotPDF 附加新拼好的版面,然後用 DeleteLoadedPage(0) 把每一張原始頁都丟掉,因為來源頁是被整批替換掉的,而版面內容指的是它們的資源而不是頁面物件。至於「把第 7 頁從這份合約拿掉」這種普通工作,DeletePage 是唯一能讓一份有標記、有書籤、有交叉連結的文件一致到足以通過驗證器的呼叫,不管走 SaveLoadedDocument 的完整改寫,還是走 SaveIncrementalUpdate 的增量更新都一樣。兩個方法都在 HotPDF Delphi Component 裡出貨,支援 Delphi 與 C++Builder,不需要任何外部檢視器執行環境或相依套件