為了在 Delphi 中縮減 PDF 檔案大小,losLab PDF 函式庫提供了三個能解決三個最大肥大來源的 API:SubsetEmbeddedFonts 將每個嵌入的 TrueType 字型程式重寫為文件實際渲染的字形 (glyphs),DownsampleImages 重新取樣超過目標 DPI 的點陣圖片,而 NormalizeLZWStreams 將舊版的 LZWDecode 壓縮取代為 FlateDecode。每一個都會傳回它所變更的物件數量,因此若傳回零便告訴您該次執行是一個空操作 (no-op),而不是一個無聲的失敗
為什麼我合併後的 PDF 會比其來源檔案還要大?
合併或以程式產生的 PDF 通常會過大,原因有三個:完全嵌入的字型、取樣遠高於其顯示解析度的圖片,以及仍使用舊版 LZW 過濾器壓縮的串流。ISO 32000-1 §9.9 允許產生器嵌入完整的字型程式,且大多數的產生器確實這麼做,因為這是安全的預設值。一個完整的 Arial FontFile2 可達數百 KB;將它嵌入十幾個來源檔案,把它們合併,您便帶上了十幾份沒有人輸入過的字元其字形外框的複本。合併本身並不會產生浪費,它只是將其集中到單一檔案中,讓總量終於變得肉眼可見
圖片是第二個元兇。將一張寬達 4800 像素的掃描圖檔放入四分之一頁的框架中,其傳送的像素資料量大約是 300 DPI 列印管線所能使用的 40 倍。第三個元兇則較為安靜:使用 LZWDecode 過濾的串流。ISO 32000-1 §7.4.4 指定了 LZWDecode 和 FlateDecode 兩者,並指出 Flate 通常能壓得至少一樣好;實際上,在相同的資料上,Flate 的輸出始終比較小,而 LZW 則主要存活在歷史上某個時點曾經過 1990 年代工具處理的檔案中。本文的其餘部分將帶您了解修復每個問題的三個 losLab PDF 函式庫處理步驟,然後將它們結合成一條管線
使用 SubsetEmbeddedFonts 進行字型子集化 (Font subsetting)
SubsetEmbeddedFonts 將載入文件中每個嵌入的 TrueType 字型縮減為文件實際使用的字元,且它不需要任何引數,因為它是從內容串流本身衍生出保留清單。在內部,該處理程序透過 GetTextRuns 走訪每頁的內容串流,收集各個字型資源下所參照到的字元碼,建立一個保留清單,並將原始的字型程式交給 Windows 的 FontSub 引擎 (CreateFontPackage) 來產生一個子集。重寫後的程式會就地取代 FontFile2 串流,並且 BaseFont 名稱會獲得一個 LOSABC+ 標籤,這是 ISO 32000-1 §9.6.4 為子集字型所定義的六個大寫字母加上加號的慣例。這個前綴也是讓該呼叫具有冪等性 (idempotent) 的原因:執行該處理程序兩次,已子集化的字型將被識別並跳過,所以將其接入可能重複訪問檔案的批次作業中是安全的
var
Lib: TPDFlib;
Fonts: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('merged-report.pdf', '') = 1 then
begin
Fonts := Lib.SubsetEmbeddedFonts;
// Fonts = number of FontFile2 programs rewritten;
// 0 means nothing embedded, or everything already subsetted
Lib.SaveToFile('merged-report-subset.pdf');
end;
finally
Lib.Free;
end;
end;
有兩項實作細節值得了解,因為它們解釋了 API 的邊界。首先,該處理程序以 FontFile2 為目標,因此它涵蓋了嵌入的 TrueType 程式;以 Type 1 或純 CFF 嵌入的字型則保持原樣,而不去冒險。其次,它依賴 FontSub,這使得 SubsetEmbeddedFonts 成為 Windows 專用的功能。實作上一個更微妙的重點是:一個字型是否符合資格,是藉由實際解析 FontDescriptor → FontFile2 參考鏈來決定的,而不是透過信任已嵌入旗標的啟發式判斷,因為載入文件中的字型從未經過會設定這類旗標的建立端簿記作業。如果解析出來的串流存在,該字型就是一個候選者;如果不存在,就會被略過而不會報錯
誠實的權衡:子集字型只包含在子集化當下存在的字形。如果下游工具或是您自己的程式碼稍後以相同字型加入了文字,任何在子集之外的字元將沒有外框,並會被渲染為遺失字形。請將子集化作為變更內容的最後一個步驟,絕對不要在編輯階段之前進行。如果您計畫稍後將字型抽出來重複使用,同樣的注意事項也適用;關於使用 PDFlibPas 提取文字、圖片和字型的文章,涵蓋了提取出來的子集程式能與不能給您什麼東西
DownsampleImages 如何決定要縮小哪些圖片?
DownsampleImages(MaxDPI, Quality, Filter) 只對它能自信稱之為過度取樣 (oversampled) 的圖片進行重新取樣,並且使用了一個刻意保守的 DPI 估算值。PDF 圖片 XObject 儲存了像素尺寸,但沒有可信任的實體解析度,而來自來源圖片的任何 DPI 標籤鮮少能在載入-編輯-儲存循環中存活下來。所以該處理程序估算 SrcDPI = 像素寬度 / 8.5,實際上是在問:如果這張圖片橫跨 Letter 頁面的全寬,它的解析度會是多少?只有估算值超過 MaxDPI 的圖片才會被動到。這種偏誤是故意的:放在頁面上一小塊的圖片,其真實 DPI 高於估算值,所以該處理程序寧可少觸發,也不願降低它無法測量之列印品質資產的畫質
1 到 100 之間的 Quality 會選擇 JPEG 重新編碼的品質,而 0 則保持輸出為無損的 PNG 風格的 Flate;Filter 選擇重新取樣的核心,0 為方塊平均,而 1 為雙線性。對於掃描的辦公室文件,DownsampleImages(150, 75, 1) 是一個合理的起點;對於任何可能會被重新列印的內容,請將 MaxDPI 提高至 300,或者直接略過這個處理步驟。降取樣 (Downsampling) 是這三個步驟中唯一會失真的,所以它應該放在一個可以讓您的使用者將其關閉的設定後面
使用 NormalizeLZWStreams 轉換舊版 LZW 串流
NormalizeLZWStreams 是一個穩賺不賠的功能:它會就地將每個 LZWDecode 串流無損地解壓縮,並用 FlateDecode 將其重新壓縮,然後傳回轉換的串流計數。它既能處理單一的 /Filter /LZWDecode 條目,也能處理出現在過濾器鏈陣列內部的 LZW,在那裡只會替換 LZW 連結,而保留過濾器鏈的其餘部分。預測器參數 (Predictor, Columns, Colors, BitsPerComponent) 會從串流的 DecodeParms 中讀取,並傳遞給解壓縮器,因此經預測器編碼的圖片資料能正確無誤地進行來回轉換 (round-trip)。因為這兩種過濾器都是位元精確的編解碼器,所以解碼出來的位元組在前後是完全相同的;只有容器的壓縮方式改變了,這也是為何這個處理步驟安全到可以無條件在每個檔案上執行的原因
在一份沒有 LZW 串流的文件上,該呼叫只會傳回 0 且什麼都不動,函式庫的迴歸套件明確測試了這一點:一個新建立、只有 Flate 的檔案必須回報零轉換。當這個處理步驟位於一條處理數千個異質檔案的管線中(有些來自 2024 年,有些來自 1998 年)時,這種空操作保證就很重要了
Delphi 中完整的檔案大小最佳化管線
這三個處理步驟組合成單一的載入-最佳化-儲存函式,而且順序並沒有您想像中那麼重要,因為它們運作在不相交的物件類型上:字型、圖片 XObject 以及串流過濾器。先執行子集化依然是個俐落的選擇,因為這是一個帶有編輯順序限制的處理步驟
function OptimizePDF(const Src, Dst: string): Boolean;
var
Lib: TPDFlib;
Fonts, Images, Streams: Integer;
begin
Result := False;
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(Src, '') <> 1 then
Exit;
Fonts := Lib.SubsetEmbeddedFonts; // TrueType FontFile2 -> subset
Images := Lib.DownsampleImages(150, 75, 1); // >150 DPI -> JPEG q75, bilinear
Streams := Lib.NormalizeLZWStreams; // LZWDecode -> FlateDecode
Result := Lib.SaveToFile(Dst) = 1;
// Log Fonts/Images/Streams: three zeros mean the file was already lean
finally
Lib.Free;
end;
end;
像函式庫驗證自身那樣驗證管線:來回測試 (round-trip)。v3.130 的迴歸測試會建立一份文件、儲存它、重新載入它、執行最佳化、再次儲存,然後斷言三件事:輸出變小了、傳回的計數符合預期,而且重新載入的最佳化檔案仍然能被解析與渲染。針對您自己生產環境檔案樣本重現這種建立-最佳化-重新載入迴圈,並比對前後提取出來的文字,這是一個小時的投資,卻能在客戶打開一張破損發票的很久以前就抓出整合上的錯誤
// Round-trip check: the optimized file must still load cleanly
Lib := TPDFlib.Create;
try
Assert(Lib.LoadFromFile('merged-report-opt.pdf', '') = 1);
Assert(Lib.GetPageCount > 0);
finally
Lib.Free;
end;
管線在合併工作流程中應置於何處?在合併之後,而不是合併期間。先合併並將最佳化應用在單一結果上,意味著每個嵌入的字型只需針對所有被使用之字元的聯集子集化一次,而不是針對每個來源檔案各自進行。如果合併吞吐量是瓶頸,PDFlibPas 提供了避開完整物件解析的位元組層級快速路徑,這在有關利用位元組參照位移 (byte reference shifting) 快速合併 PDF 的文章中有說明;而對於無法完全載入記憶體的龐大輸入,針對大型 PDF 的直接存取合併與分割則介紹了串流路線。這兩者都能自然地與合併輸出後的最後一步最佳化處理搭配
這三個處理步驟不會做的事情
losLab PDF 函式庫的最佳化三重奏刻意排除了任何會改變文件語意的動作。SubsetEmbeddedFonts 不會將跨合併來源中重複的字型統一成單一程式,它是各自獨立進行縮減的;重複資料刪除 (deduplication) 是另一個風險較高的轉換。DownsampleImages 會放過那些儘管人類肉眼能看出其相對於框架而言尺寸過大,但其保守的 DPI 估算值仍低於閾值的圖片。而且這些處理步驟都不會去動文件的結構,所以一份因數千個孤立物件而肥大的檔案,需要的是重寫風格的儲存,而不是這些串流層級的處理步驟。在這些限制之內,字型子集化、圖片降取樣以及 LZW 轉 Flate 正規化的組合,各自用一個可預測的 API 呼叫,移除了三個經典的 PDF 肥大來源。這三個函式與前面探討的合併、提取及渲染 API,都作為適用於 Delphi、C# 與 VB.NET 之 losLab PDF 函式庫的一部分提供