PDFium Component for Delphi(PDFiumPas)用 TPdf 上的三個改寫 API 縮小 PDF 檔案:SaveAsReachable 丟掉無人參照的物件,SaveAsDeduplicated 合併逐位元組相同的字型、影像與 Form XObject,SaveAsFlateRecompressed 以最高壓縮等級重編碼既有 Flate 流。每個 API 都回傳一份報告記錄,讓您看清刪了什麼、省了多少位元組
常見的觸發是一張寫著「我們刪了二十頁、檔案反而變大」的支援單。有人打開合約、刪掉附錄、併入封面頁、存檔。每一步都是增量更新,舊物件還躺在檔案裡;之後那份文件被載入、用 FPDF_SaveAsCopy 寫出時,它們原封不動又出來了。PDFium 序列化的是它記憶體物件表裡的東西;它沒有任何公開呼叫可以列出間接物件、刪除某一個、或改寫參照。因此本文的一切都跑在 PDFiumPas 這層包裝裡,疊在 PDFium 產出的位元組之上
FPDF_SaveAsCopy 為什麼把刪掉的頁留在檔案裡?
FPDF_SaveAsCopy 保留孤立物件,是因為 PDFium 把交叉參照表裡每一條在用條目都載入文件,不管還有沒有東西指向它。ISO 32000-1 §7.5.6 描述的增量更新,就是把新物件版本與新 xref 區追加到檔尾。刪一頁只編輯了頁面樹;頁面字典、它的內容流,以及只有那頁在用的字型與影像,仍列為在用物件。完整的非增量存檔也改變不了這件事,因為從物件表的角度看,那些物件完全合法
頁面合併、其他工具做的表單攤平、損壞檔案的修復存檔之後,同樣的殘留都會出現。它們在任何檢視器裡都看不見,貢獻的只有位元組。掃描文件上一張孤兒頁影像可以是好幾 MB
用 SaveAsReachable 做標記清除
TPdf.SaveAsReachable 用經典的標記清除一遍拿掉孤兒:先讓 PDFium 寫出目前文件的非增量副本,把那份副本解析成物件圖,從根標記一切可達之物,再寫出一個只含被標記物件的單一傳統修訂。物件串流裡的物件沿途被實體化成普通間接物件,所以輸出永遠用樸素的 xref 表
挑根是天真實作出錯的地方。顯而易見的種子是 trailer /Root 指名的 Catalog。從那裡,走訪會到達頁面樹、頁面資源、字型、影像、Form XObject、AcroForm、結構樹、書籤、名稱樹、Optional Content、XMP 詮釋資料、輸出意圖與內嵌檔案。但文件資訊字典是由 trailer 的 /Info 條目(ISO 32000-1 §7.5.5)參照的,不是 Catalog。只從 /Root 下種,您處理的每份檔案的 Title、Author 與 Producer 就悄悄沒了。PDFiumPas 用 /Root 與 /Info 兩者為走訪下種,重建的 trailer 也只在該物件真的活下來時才帶上 /Info
走訪器是帶明確堆疊與已訪集合的深度優先走訪,參照循環(頁面樹的父連結、註解的 /P 回指)掛不住它。指向已空閒物件編號的參照,直接不跟過去、而不當成錯誤,這讓這一遍對輕度損壞的輸入保持寬容
物件編號保留原樣,不重新編號
TPdf.SaveAsReachable 讓每個存活物件保在原本的物件編號與 generation 上。清掉的物件以自由條目寫進 xref 表(ISO 32000-1 §7.5.4),在用編號之間的空隙用自由條目填補,物件 0 仍是自由清單的頭。因為什麼都不重新編號,活下來的每個參照不用改一個位元組就依然正確,也不存在可能弄錯的新舊映射。代價是幾條用不到的 xref 行、每條 20 位元組,這筆交易划算
uses
PDFium, FPdfCompress;
procedure PruneOrphans(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
Report: TPdfPruneReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
if not Pdf.SaveAsReachable(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('%d bytes -> %d bytes, kept %d object(s), pruned %d',
[Report.SourceByteCount, Report.OutputByteCount,
Report.KeptObjectCount, Report.PrunedObjectCount]));
finally
Pdf.Free;
end;
end;
TPdfPruneReport 帶著 SourceByteCount(PDFium 產出的非增量副本大小)、OutputByteCount、KeptObjectCount、PrunedObjectCount、Succeeded 與 ErrorMessage。檔名多載先寫到目標旁邊的暫存檔,改寫成功才替換目標,所以失敗的執行絕不留下被截斷的 PDF
這些 API 會拒收哪些檔案?
三個改寫 API 都拒收加密文件,預設也拒收有簽章的。加密輸入以 Succeeded = False 加錯誤訊息收場,而不是丟例外:單一修訂的改寫保不住加密字典依賴的物件編號、位元組範圍與逐物件金鑰,輸出會讀不了。帶數位簽章的文件會丟 EPdfError,除非您傳 AllowSignedDocument = True,因為完整改寫必然使簽章位元組範圍失效。設這個旗標代表您接受一份簽章會顯示為損壞的檔案;拿來做封存副本是對的,拿來寄回合約是絕對不行
SaveAsDeduplicated 怎麼判定兩張影像相同?
TPdf.SaveAsDeduplicated 只在字典、整個可達子圖與原始串流位元組全部吻合時,才把兩個資源視為相同。它先跑同一趟可達性走訪,然後給每個可達的字型、影像 XObject 與 Form XObject 指紋,把每組相符者併到物件編號最小的成員上,改寫所有參照指向那位倖存者。被合併的重複項跟孤兒一樣變成自由 xref 條目。這正是「logo 被內嵌 400 次」那類經典回報的解藥——mail merge 工具在每一頁都重嵌同一批資源時就會產出
指紋是遞迴計算的 64 位元 FNV-1a 雜湊。對每個候選,走訪器取物件本體,把每個 N G R 參照換成它指向物件的指紋,再對結果求雜湊。這讓雜湊與物件編號無關:同一字型的兩份複本,就算描述子碰巧是物件 12 與 87,仍產出同一個值。結果做了記憶化,共用的子物件只雜湊一次;走訪中的集合對回邊回傳固定哨兵,循環圖因此會終止
微妙的 bug 藏在串流承載裡。為了安全地找參照,PDFiumPas 掃描的是每個物件的一份副本,其中串流資料、字串與註解都用空格抹掉;不然一段碰巧長得像 12 0 R 的二進位像素資料就會被當成參照。只雜湊那份消毒過的副本,兩張字典相同(同樣的 /Width、/Height、/BitsPerComponent、/Filter、/Length)而像素不同的影像就會塌縮成同一個指紋,頁面上第二張照片被悄悄換成第一張。SaveAsDeduplicated 會把 stream 與 endstream 之間的原始位元組附加進被雜湊的資料,像素因此是身分的一部分。比較之前候選也先按類別分組,字型與影像之間罕見的雜湊碰撞永遠合併不到一起去
參照改寫把同一招反向再用一次:參照在抹除後的副本裡定位,位元組卻從原始本體複製,所以被修改的只有字典與陣列結構,二進位承載原樣通過
procedure MergeDuplicateResources(Pdf: TPdf; const TargetPdf: string);
var
Report: TPdfDedupReport;
begin
if not Pdf.SaveAsDeduplicated(TargetPdf, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('fonts %d, images %d, forms %d merged; %d -> %d bytes',
[Report.MergedFontCount, Report.MergedImageCount,
Report.MergedFormXObjectCount,
Report.SourceByteCount, Report.OutputByteCount]));
end;
TPdfDedupReport 要細讀。三個 Merged*Count 欄位旁邊還有 KeptObjectCount 與 PrunedObjectCount,而這份報告裡的 PrunedObjectCount 只計被合併的重複項。不可達物件照樣被丟掉,但不計入這個數字。日誌需要孤兒數量的話,從單獨一趟 SaveAsReachable 拿。另一個限制是:比對對編碼後的串流逐位元組精確,同樣的 logo 由兩個產生器以兩個不同 Flate 等級壓縮,就不會被合併
以最高等級重壓縮 Flate 流
TPdf.SaveAsFlateRecompressed 把每個可達的 FlateDecode 流解壓縮,再以最高壓縮等級重新壓縮,只有結果更小且過了您的門檻才採用。許多產生器用快速的預設等級寫 Flate,內容流與內嵌字型程式往往還能再縮。物件編號與 generation 原地不動;變的只有承載與它直接的 /Length
TPdfFlateRecompressOptions 有兩個欄位、沒有預設建構子,所以兩個都要設。MinSavingBytes 是每條流的最小絕對節省,MinSavingRatio 是原始長度的比例(0.05 即 5%);比例為 0 就停用比例測試。重建的流以 stream、CRLF、新承載、CRLF、endstream 寫出,結果不依賴原始檔的斷行方式
procedure RecompressFlate(Pdf: TPdf; const TargetPdf: string);
var
Options: TPdfFlateRecompressOptions;
Report: TPdfFlateRecompressReport;
begin
Options.MinSavingBytes := 256; // 忽略蠅頭小利
Options.MinSavingRatio := 0.05; // 以及 5% 以下的一切
if not Pdf.SaveAsFlateRecompressed(TargetPdf, Options, Report) then
raise Exception.Create(Report.ErrorMessage);
Writeln(Format('recompressed %d, skipped %d, failed %d; streams %d -> %d bytes',
[Report.RecompressedCount, Report.SkippedCount, Report.FailedCount,
Report.OriginalStreamBytes, Report.RecompressedStreamBytes]));
end;
邊角案例的處理刻意保守。/Length 是間接參照的流直接跳過,因為改寫必須存直接的整數。解壓縮失敗的承載計入 FailedCount、原樣複製。沒過門檻的 Flate 流計入 SkippedCount。DCT、JPX、JBIG2 與 CCITT 影像永遠不進候選清單,所以這一遍碰不到 JPEG 品質。要有損地省影像,請在最終存檔前用 PDFiumPas 的自適應影像重採樣
寫那個門檻測試時踩到一個可移植性陷阱:初稿拿 Single(Saving)/Single(OrigLen) 來比,Saving 宣告為 Int64。Delphi 接受,FPC 3.2.2 卻以 Illegal type conversion: "Int64" to "Single" 停下來,因為在 FPC 裡那個語法是型別轉型、不是數值轉換。樸素的 Saving / OrigLen 在兩個編譯器裡都是真除法、可以直接跟 Single 比較,那些轉型本來就多餘
三個 API 該按什麼順序跑?
先跑 SaveAsDeduplicated,重新載入輸出,再跑 SaveAsFlateRecompressed。去重已經包含可達性清掃,在它前面再來一趟 SaveAsReachable 是多餘的;SaveAsReachable 留給只想要孤兒清理與精確刪除數的工作。重壓縮放第二,昂貴的解壓加壓工作就只在每條存活流上跑一次,而不是每個重複項一次。重壓縮那遍還會從 /Root 與 /Info 再走一遍圖,順手清掉只被某個被合併重複字型還指著的字型描述子或字型檔
procedure ShrinkPdf(const SourcePdf, TargetPdf: string);
var
Pdf: TPdf;
TempPdf: string;
begin
TempPdf := ChangeFileExt(TargetPdf, '.dedup.pdf');
Pdf := TPdf.Create(nil);
try
Pdf.FileName := SourcePdf;
Pdf.Active := True;
MergeDuplicateResources(Pdf, TempPdf); // 清孤兒 + 合併
Pdf.Active := False;
Pdf.FileName := TempPdf;
Pdf.Active := True;
RecompressFlate(Pdf, TargetPdf); // 對存活者重新壓縮
finally
Pdf.Free;
DeleteFile(TempPdf);
end;
end;
如果還要套遮蔽(redaction),放在這一切之前做,實體移除內容正是新孤兒的源頭;運算子層級遮蔽與資源修剪一文講的是遮蔽那一側。管線裡也務必把最終檔案重新載入做一次健全性檢查:頁數與來源一致,是您覆寫任何客戶在乎的東西之前最便宜的保險
FAQ
SaveAsReachable 會重新編號物件或改動參照嗎?
不會。TPdf.SaveAsReachable 讓每個存活物件保在原本編號與 generation 上,把移除的物件標成自由 xref 條目,因此沒有任何參照需要改寫
SaveAsDeduplicated 會不會誤合併兩張不同的影像?
光靠字典吻合不會。指紋包含原始串流位元組與完整被參照子圖,而且候選只在同類別(字型、影像、Form XObject)內比較
這些 API 保得住我的數位簽章有效嗎?
保不住。每個 API 都執行完整改寫,簽章位元組範圍必然破損,所以帶簽章的檔案會被拒收,除非您傳 AllowSignedDocument = True 並接受輸出裡失效的簽章
三個 API 都隨 PDFium Component for Delphi and C++Builder 出貨,本部落格其他文章介紹的遮蔽與影像重採樣功能也一樣在