技術文章

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

把一份標記報告的封面頁切掉,開啟時頁數可以完全正確,卻仍在三個地方壞掉:點擊「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 也可能做同樣的事
Perché rimuovere una pagina HotPDF da /Kids non basta: ISO 32000-1 lascia che il name tree /Names /Dests, il dizionario /Dests legacy del catalog, gli outline item, gli elementi di struttura con /Pg, il ParentTree, le annotazioni link e /OpenAction tengano tutti un riferimento allo stesso oggetto pagina, e solo l'albero delle pagine viene ricostruito
Una pagina PDF è un target verso cui punta metà del catalog: togliere la foglia soddisfa l'albero delle pagine mentre ogni altro puntatore risolve a null, quindi un report potato perde il bookmark Contents e fallisce il controllo di accessibilità

THotPDF.DeletePage 在碰 /Kids 之前做些什麼

對已載入的文件呼叫 THotPDF.DeletePage(PageIndex) 時,會先把上述每種結構清掃一輪,最後才改寫頁面樹。索引從零起算。在任何東西被改動之前有兩道守衛:超出範圍的索引丟出 Invalid page number,要求移除僅存的一頁也會丟出例外,因為零個子項的 /Pages 節點描述的不是合法文件

清掃順序是固定的:/Names /Dests 樹、舊式 catalog /Dests 字典、/OpenAction、大綱、/StructTreeRoot 連同它的 ParentTree,最後是每個留存頁面的 /Annots 陣列。只有在這之後,頁面物件才被標記為已刪除,它的小工具註解才從 AcroForm 欄位樹上拆下,/Kids、/Count 與每個留存頁面的 /Parent 才被改寫。表單這邊,小工具被移走後一個子項都不剩的父欄位也會被移除,一路往上到 /Fields,所以修剪過的表單不會留著螢幕上什麼都沒有的欄位條目

THotPDF.DeletePage 在碰頁面樹之前跑的固定參照清掃:守衛先拒絕超出範圍的索引或最後一頁,接著剪掉 /Names /Dests 與舊式 /Dests、丟掉 /OpenAction、把大綱目的錨點移到最近的留存頁面、剪掉 StructTreeRoot 與 ParentTree、移除留存頁面的連結,最後才改寫 /Kids、/Count 與 /Parent
每種結構都得到規格允許的處理:名稱消失、書籤落到最近的留存頁面、結構元素失去 /Pg 或直接消失,而 /Kids 的改寫只在沒有其他東西能到達被刪除物件之後才發生

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 只在物件擁有自己的編號時才把它送進已釋放清單;對直接子項,它把容器元素標記為已變更,讓元素在子項消失的狀態下被改寫,已釋放清單保持乾淨

為什麼被移除的 /MCR 或 OBJR 子項在增量更新裡絕不能被登錄為已釋放:它是元素 /K 陣列裡的直接字典,釋放它就等於釋放包含它的結構元素,後者會被寫成 null、讓存活的頁面變成未標記,而正確做法是改寫容器、完全不碰已釋放清單
只有擁有自己物件編號的物件才會進已釋放清單,所以增量存檔附加的是改寫過的容器,加上頁面與其他被移除間接物件的已釋放條目

同樣的謹慎也決定了 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