從 PDF 中刪除頁面,並不會一併刪除其字型、影像或內容串流。losLab PDF Library 透過標記清除(mark-sweep)收集器來回收這些物件,此收集器從 trailer 根節點開始正向走訪物件圖,移除所有沒有任何東西指向的間接物件。此程序只在完整儲存(full save)時執行,預設為關閉,並會回傳它捨棄的物件數量
為何刪除 PDF 頁面不會縮小檔案?
因為刪除頁面只是一次參照層級的編輯,並非儲存空間操作。DeletePages(StartPage, PageCount) 會將頁面物件從頁面樹中解除連結,並修復曾指向它們的大綱(outline)條目。它做不到的是判斷這些頁面所用的字型程式、內容串流與影像 XObject 現在已成死物,因為在刪除當下,檔案裡沒有任何記錄能說明還有誰可能指向它們。這些物件仍留在文件物件清單中,而完整儲存會把每一個都重新寫回檔案。結果就是大多數技術支援討論串的起頭抱怨:客戶刪除了九成頁面、儲存後,檔案卻只縮小了百分之二。更糟的是,這種洩漏會不斷累積。載入、刪除、儲存,再載入、再刪除、再儲存,檔案會單調地愈長愈大,頁數卻愈來愈少。這與 字型子集化與影像降採樣 所解決的問題不同,那類操作是讓存活物件變小;這裡的問題不是物件太大,而是它們根本已不再屬於文件的一部分
根集合是 trailer,不是頁面樹
PDF 物件圖沒有反向參照欄位。格式規範中並未定義參照計數,也沒有反向指標清單,而確實存在的 /Parent 鍵只屬於頁面樹之類的特定結構,並不屬於整個物件圖。間接物件本身沒有任何內容能告訴你誰指向了它,所以「還有人在用物件 47 嗎」這個問題只有一種回答方式:從已知的根節點正向走訪,看看能不能抵達它。這正是為何 losLab PDF Library 的收集器採用標記清除機制,而非參照計數方案
根節點來自檔案 trailer(ISO 32000-1 §7.5.5)。共有三個鍵承載根節點:/Root,也就是 §7.7.2 所定義的文件目錄(catalog),頁面樹、名稱、大綱、AcroForm 與中繼資料都掛在它下面;/Info,文件資訊字典;以及 /Encrypt,加密字典。其餘兩個 trailer 鍵則是障眼法。/ID 是由兩個位元組字串組成的陣列,/Prev 則是指向前一個交互參照區段的整數位元組偏移量。兩者都不是間接參照,因此都不會貢獻根節點。losLab PDF Library 會把整個 trailer 字典排入佇列,而不是只挑三個具名的鍵,這樣做不花額外成本,還能讓任何私有的 trailer 擴充保持存活
走訪過程本身是疊代式而非遞迴式。當走訪遇到間接參照時,只會記錄物件編號與世代(generation),標記對應的欄位,並將其推入 FIFO 佇列,而不是立即解參照,這樣可以避免深層頁面樹與長大綱鏈占用呼叫堆疊,也能避免同一物件被解碼兩次。直接字典、陣列與串流字典則會進入第二個佇列,並由一個已造訪集合把關,因為真實文件裡確實存在循環:頁面的 /Parent 會指回它的頁面樹節點,大綱項目也會透過 /Prev 與 /Next 雙向串接。世代編號是比對條件的一部分,不是裝飾用的細節。一個參照只有在物件編號與世代都相符時才會被解析;指向某個編號但世代不同的參照,會依規範要求視為 null 物件處理,絕不會被當成有效的邊
如何在儲存時啟用垃圾回收?
垃圾回收是選擇性啟用(opt-in)功能,屬於儲存選項記錄的一部分。它預設為 False,因為收集器對物件圖執行的是破壞性操作,任何函式庫都不該在呼叫端從未要求檢查的情況下,悄悄刪除物件
var
Pdf: TPDFlib;
Opt: TPDFlibSaveOptions;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report-500pages.pdf', '') <> 1 then
Exit;
Pdf.DeletePages(11, 490); // keep the first ten pages
FillChar(Opt, SizeOf(Opt), 0);
Opt.CompressContent := True;
Opt.CompressFonts := True;
Opt.OptimizeContentStreams := True;
Opt.PackObjectStreams := True;
Opt.GarbageCollect := True; // drop everything the pages left behind
Pdf.SaveToFileOptions('report-10pages.pdf', Opt);
finally
Pdf.Free;
end;
end;
還有另外兩個進入點能觸發同一個收集器。SetGarbageCollect(1) 會在所選文件上設定旗標,讓一般的 SaveToFile 也會遵循此設定;GarbageCollectObjects 則會立即執行整個流程,並回傳已移除的孤立間接物件數量。想要一個能記錄日誌或用於斷言的數字時,就該用這個立即執行的版本,而且值得檢查回傳值,因為負數並不代表一個計數
var
Removed: Integer;
begin
Pdf.DeletePages(11, 490);
Removed := Pdf.GarbageCollectObjects;
if Removed < 0 then
// The graph could not be fully decoded. Nothing was swept and the
// document is unchanged; save it without GC or reject the input.
LogWarning('object graph incomplete, GC skipped')
else
LogInfo(Format('reclaimed %d orphaned objects', [Removed]));
end;
這條失敗路徑比看起來更重要。物件是延遲解碼的,一個從未被解碼過的物件完全不會透露任何參照關係。如果收集器把一個無法解碼的物件當成空節點處理,就會把所有唯獨透過它才能抵達的東西一併掃除。因此走訪過程會在觸及每個物件時強制進行解碼,只要有一次解碼錯誤,整個流程就會中止並回傳負數,且文件會保持逐位元組不變。清掃一張你只理解一部分的物件圖,正是收集器把一份受損檔案變成一份被摧毀檔案的方式
什麼會讓一個天真的 PDF 收集器出錯?
有兩個細節,而且兩者出錯時都是悄無聲息、而非大聲示警。第一個是物件串流(object stream)。自 PDF 1.5 起,非串流物件可以壓縮存放於 /ObjStm 容器內(§7.5.7),它的交互參照條目則是一個 type 2 條目,指名容器本身加上容器內的索引位置。因此,一個壓縮物件只能透過其容器才能抵達。如果只標記了成員本身,卻因為沒有任何東西把容器當作文件物件參照而將容器清掃掉,就會寫出一份 xref 指向已不存在物件的檔案。容器屬於結構性儲存,而非文件資料,所以它在你走訪的物件圖中從不會以邊的形式出現。losLab PDF Library 的處理方式,是在容器消失之前,先把所有存活下來的壓縮成員從其來源容器中分離,之後儲存時再把這些存活者重新打包進全新的物件串流。第二個細節是串流物件實際上參照了什麼。位元組內容本身並不屬於物件圖的一部分。一段用 /F1 12 Tf 繪製文字的內容串流,是以資源名稱來指名字型,而該名稱要透過頁面的 /Resources 字典來解析,因此可達性的邊是頁面 → /Resources → /Font → 字型物件,絕不會經過串流的酬載內容。串流唯一貢獻的參照都來自它的字典,其中 /Length、/Filter 與 /DecodeParms 皆允許是間接參照。一個為了尋找參照而解析串流位元組的收集器,是在做白工;一個直接跳過串流字典的收集器,則會遺失長度物件並損毀檔案
被釋放的物件編號後續會如何
它們會變成空閒條目,而且不會在同一次儲存中被重複使用。清掃過程會以遞減順序走訪物件清單,讓刪除動作維持索引穩定,並且只在最後統一重建一次查詢索引,而不是每移除一個就重建一次;每移除一個物件,都會把該編號記錄進空閒清單,其世代加一,這正是 §7.5.4 對可能日後重用的條目所規範的做法。已經到達 65535 的世代則維持原樣,代表該編號永久退役。物件編號刻意不做壓縮整理。收集完成後,檔案裡會留下空洞:物件 12 可能是空的,而 13 與 14 仍在使用中,trailer 的 /Size 仍然回報最大編號加一,而不是存活物件的數量。這是合法且正常的現象。重新編號雖能在交互參照表裡省下幾個位元組,卻得改寫文件中的每一個參照,而這種變動會悄悄讓外部所有持有物件編號的東西失效。你拿回的檔案大小縮減,來自物件本體,而不是 xref 表
什麼情況絕不能執行收集器
絕不能用於增量更新(incremental update)。收集器只會在完整儲存時啟動,當文件正在被附加寫入時,該旗標根本不會被讀取,這道門檻並不是需要繞過的限制。增量更新(§7.5.6)不會動到原始位元組,而是附加一個新的交互參照區段,透過 /Prev 串接到前一個區段。每一個較早的修訂版本,仍然指向它原本就指向的那些物件,所以某個物件即使在目前版本中已無法抵達,在較舊的版本裡卻很可能依然可達。刪除它會破壞除最新版本以外的所有版本,其中的機制在 增量更新與附加模式儲存 一文中有說明。同樣的道理也排除了在已簽署文件上執行垃圾回收的可能性,因為讓收集得以進行的那次完整重寫,本身就會使簽章失效
也值得說清楚收集不是什麼。它不是一個清理消毒(sanitizer)工具。收集器只會移除沒有任何東西參照的物件;它對這些物件的內容是否敏感毫無立場,而仍被參照的物件無論內容為何都會原封不動地保留。如果目標是讓資訊無法復原,而不只是讓檔案變小,那麼物件圖就是錯誤的處理層級,這時該用的是 指令層級遮蔽與文件清理消毒。這兩者依此順序搭配使用效果很好:先遮蔽並清理消毒,再執行收集,這樣遮蔽動作所分離出的物件才會真正離開檔案。資源清除 API 中也存在同樣的搭配,傳入垃圾回收選項會讓清除動作之後接著執行一次收集,並在 OrphanObjectsRemoved 中回報它移除的孤立物件
最後有個值得養成的習慣。在任何執行頁面刪除的批次作業中,把 GarbageCollectObjects 的回傳值記錄下來,並在數週的真實文件運作中持續觀察。如果一份你剛砍掉一半內容的檔案回傳零,代表上游還有某個你沒預料到的東西持有參照,通常是一個名稱樹條目、一個大綱目的地,或是一個附加頁面已被移除、自己卻存活下來的 AcroForm 欄位。收集器是你所能擁有最廉價的可達性除錯工具,因為它回答了 PDF 格式本身拒絕回答的問題
本文所述的垃圾回收器、儲存選項記錄與資源清除 API,都是 losLab PDF Library(適用於 Delphi 與 C++Builder)的一部分,其產品頁面提供完整的儲存流程參考文件,包含收集、物件串流打包與線性化之間的交互作用