壓縮功能出貨那一週,兩個抱怨抵達:掃描合約的字形現在帶著階梯狀、毛茸茸的邊,而封面頁上的透明商標坐在一圈蒼白光暈裡。PDFiumPas 在同一處回答這兩者。TPdf.OptimizeImages 會在縮小每張影像之前先量測它,然後挑選一個重取樣核心,並以 alpha 感知的形式累加色彩
過去並非如此。在 v3.100.0 之前,同一個方法用固定的最近鄰步驟對每張非雙層影像降取樣,而那正是會同時產生這兩種抱怨的演算法:它對每個輸出像素點取樣一個來源像素,並把完全透明像素底下的 RGB 當成讀者彷彿有一天會看到似地處理。v3.100.0 的重寫用五個核心、一條按量測結果選擇的規則,以及明確的工作記憶體預算,取代了那條單一路徑
為什麼降取樣會讓掃描文字看起來參差?
因為點取樣回答的是錯誤的問題。當 300 DPI 的掃描被重新對應到 150 DPI,每個目的地像素代表一個二乘二的來源像素區塊,而最近鄰保留其中一個、丟掉其餘三個。哪一個活下來取決於捨入,所以在來源中平滑反鋸齒的筆畫邊緣,變成每個像素丟一次銅板。結果是字形邊緣上經典的鋸齒階梯,加上半色調區域上的莫爾紋——那些被丟棄的取樣恰好帶著紋樣。這件事在 PDF 中比在螢幕上更要緊,因為損害是永久的。影像 XObject 把取樣資料連同 /Width、/Height 與 /BitsPerComponent 一起攜帶(ISO 32000-1 §8.9.5),而重取樣會把這三者在檔案內全部改寫。檢視器裡一次糟糕的縮放是一個您可以重畫的影格,PDFiumPas 在渲染快取與縮放效能中為此另有機制;一次糟糕的降取樣,則是您遞給客戶的一份新文件
PDFiumPas 如何量測細節並挑選核心
PDFiumPas 是逐張影像、而不是逐份文件決定。挑選核心之前,它從一個有界取樣網格算出正規化的亮度細節分數:水平與垂直步距是 (Width + 63) div 64 與 (Height + 63) div 64,所以一萬二千像素的掃描與三百像素的縮圖,成本都差不多是同一場 64 乘 64 的掃掠。在每個取樣位置,它把「與右邊鄰居及下邊鄰居的絕對差」跨最多三個通道加總,再除以取樣數乘以 255。分數落在 0 到 1 之間:平坦的商業圖形坐落在接近零處,稠密的攝影紋理則向上攀升
選擇階梯接著以固定順序執行。如果 ResampleFilter 是 pirfAdaptive 以外的任何值,就逐字使用該濾波器。否則:1 位元內容走 pirfBilevel;ContentClass 為 piccLineArt 走 pirfBox;縮放因子 4 以上也走 pirfBox,因為在那種縮減幅度下,區域平均既是最便宜、也是最正確的答案;piccPhoto、0.08 以上的細節分數,或 0.9 以上的 PreferredQuality,走帶三瓣核心的 pirfLanczos;縮放 2 以上或品質 0.7 以上,走半徑 2 的 pirfBicubic;剩下的一切走 pirfBilinear。由於 TPdfImageOptimizeOptions.Default 把 PreferredQuality 設為 0.85,預設執行絕不會退回雙線性,除非縮減溫和且內容平坦
uses
PDFium;
procedure ShrinkScannedPdf(const InputFile, OutputFile: string);
var
Pdf: TPdf;
Options: TPdfImageOptimizeOptions;
Report: TPdfImageOptimizeReport;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := InputFile;
// 預設值:TargetDpi 150、MinDpiRatio 1.5、PreserveBilevel True、
// MinDimension 8、pirfAdaptive、piccAuto、品質 0.85、64 MiB 預算
Options := TPdfImageOptimizeOptions.Default;
Options.TargetDpi := 150;
Options.MinDpiRatio := 1.5;
Options.ContentClass := piccAuto;
Options.PreferredQuality := 0.85;
if Pdf.OptimizeImages(Options, Report) and (Report.OptimizedCount > 0) then
Pdf.SaveAs(OutputFile);
finally
Pdf.Free;
end;
end;
一張影像只有在其水平與垂直擺放 DPI 中較大者除以 TargetDpi 達到 MinDpiRatio 時才會被碰觸。這個防護存在,是為了讓一張瞄準 150 DPI 目標的 160 DPI 照片,不會為了百分之六的增益而被重新編碼——那要賠上一代的品質。任一軸小於 MinDimension(預設為 8)的影像,會被當成圖示或尺規略過
為什麼透明商標會沾上白色邊鬚?
因為完全透明像素底下的色彩是任意的,而單純的加權平均會讓它投票。從設計工具匯出商標,其不可見邊距經常是白色、黑色,或畫布原本是什麼就是什麼;alpha 色版把它藏住,而對核心涵蓋範圍的直接加總立刻把它混回可見邊緣。PDFiumPas 藉由以預乘形式累加 BGRA 取樣、並只在目的地像素處才解除預乘,來避免這件事
具體說,每個有貢獻的取樣把 channel * alpha * weight 加進色彩累加器、把 alpha * weight 加進 alpha 累加器,並把 weight 加進權重和。目的地色彩接著除以 alpha 累加器,而不是除以權重和——這正是要緊的步驟:除以權重和會把色彩拖向不可見像素,除以累加的 alpha 則能重建「可見取樣真正達成共識」的那個色彩。目的地 alpha 是另一個量:255 * AlphaSum / WeightSum。無 alpha 格式照常除以權重和;FPDFBitmap_BGRx 目的地的襯墊位元組寫入常數 255;每個通道在儲存前都被夾制到 0 到 255。那個 alpha 通常來自影像字典中的柔邊遮罩條目(ISO 32000-1 §11.4),PDFium 早已把它合成進重取樣器收到的 BGRA 緩衝區
// 內部累加迴圈的形狀,針對每個有貢獻的來源取樣
if SrcFormat = FPDFBitmap_BGRA then
Alpha := PByte(PAnsiChar(Pixel) + 3)^ / 255
else
Alpha := 1;
for Channel := 0 to Min(BytesPerPixel, 3) - 1 do
Accumulated[Channel] := Accumulated[Channel] +
PByte(PAnsiChar(Pixel) + Channel)^ * Alpha * Weight;
AlphaSum := AlphaSum + Alpha * Weight;
WeightSum := WeightSum + Weight;
// ……而在目的地像素處,對 alpha 和反除預乘
if SrcFormat = FPDFBitmap_BGRA then
begin
if Abs(AlphaSum) > 1E-12 then
ValueSum := Accumulated[Channel] / AlphaSum
else
ValueSum := 0;
end
else
ValueSum := Accumulated[Channel] / WeightSum;
把 1 位元線稿擋在灰色地帶之外
把任何連續核心套用到雙層掃描都會產生灰色,而灰色恰好是傳真風格影像不被允許包含的東西。因此 PDFiumPas 預設不碰 1 位元影像:在 TPdfImageOptimizeOptions.Default 中 PreserveBilevel 為 True,這類影像原封不動地落進 SkippedCount。把它設為 False,pirfBilevel 路徑就會取代平滑核心接手。它走訪涵蓋每個目的地像素的確切來源矩形,按 BGR 記憶體順序以 0.114、0.587 與 0.299 權重平均亮度,並把結果在 127.5 處門檻化成乾脆的 0 或 255。沒有任何中間值能被寫入,所以邊緣保持俐落,細筆畫周圍不會形成灰色光暈;BGRA 來源的 alpha 色版照常平均,BGRx 目的地則得到常數 255。如果您需要的是底層像素而非更小的文件,從 PDF 檔擷取影像是另一條獨立路徑
影像超過工作記憶體預算時會怎樣?
它會原樣保留,而且會被計數。MaxWorkingBytes 預設為 64 MiB,並在兩處強制執行。建立目的地點陣圖之前,若寬乘高乘每像素位元組超過預算,PDFiumPas 就拒絕該影像。FPDFBitmap_CreateEx 成功之後,它再用真實步距乘高檢查一次,因為列襯墊可能把配置推過「天真乘積剛好過關」的那個限制。任一種拒絕都會終結目的地並回傳空。請看清這意味的降級:超預算的影像不會以較低品質重取樣,也不會被切成磚塊。原始影像留在文件中,BudgetExceededCount 與 SkippedCount 都會遞增,因此一次執行可能回報成功,而文件只被最佳化了一部分。那是刻意的故障安全行為,但它代表報告不是可讀可不讀的東西。另有一種不同的失效模式:PDFium 完全無法產生點陣圖的影像,例如 CMYK、JPX、JBIG2 或帶遮罩的來源,會改為遞增 FailedCount,同樣原樣保留
procedure OptimizeBatch(const Files: array of string);
var
Pdf: TPdf;
Options: TPdfImageOptimizeOptions;
Report: TPdfImageOptimizeReport;
I: Integer;
begin
Options := TPdfImageOptimizeOptions.Default;
Options.PreserveBilevel := False; // 使用雙層區域投票
Options.ContentClass := piccPhoto; // 為照片集強制使用 Lanczos
Options.MaxWorkingBytes := 256 * 1024 * 1024; // 為大型掃描保留餘裕
Pdf := TPdf.Create(nil);
try
for I := Low(Files) to High(Files) do
begin
Pdf.FileName := Files[I];
if not Pdf.OptimizeImages(Options, Report) then
begin
WriteLn('optimize failed: ', Report.ErrorMessage);
Continue;
end;
if Report.BudgetExceededCount > 0 then
WriteLn(Files[I], ': ', Report.BudgetExceededCount,
' image(s) over budget and kept at full size');
if Report.FailedCount > 0 then
WriteLn(Files[I], ': ', Report.FailedCount,
' image(s) could not be decoded to a bitmap');
if Report.OptimizedCount > 0 then
Pdf.SaveAs(ChangeFileExt(Files[I], '.opt.pdf'));
end;
finally
Pdf.Free;
end;
end;
出貨前先讀報告
TPdfImageOptimizeReport 是為了被診斷而打造,不只是被記錄。除了 OptimizedCount、SkippedCount 與 FailedCount,它為每個核心暴露一個計數器,所以 BoxFilterCount、BilinearFilterCount、BicubicFilterCount、LanczosFilterCount 與 BilevelFilterCount 會告訴您適應性規則對您的內容母體實際下了什麼結論。全 box 的結果代表縮減幅度很大,或內容被分類為線稿;一份您以為是線稿的文件出現全 Lanczos 結果,是 ContentClass 該明確設定的跡象。調校 PreferredQuality 時,AverageDetailScore 是拿來對照 0.08 Lanczos 門檻的數字,而 PeakWorkingBytes 顯示這次執行真正需要了 MaxWorkingBytes 的多少。無效選項會大聲失敗而非悄悄失敗:非正的 TargetDpi、低於 1 的 MinDpiRatio、落在 0 到 1 之外的 PreferredQuality,或非正的 MaxWorkingBytes,都會在任何頁面被碰觸前擲出 EPdfError。而 OptimizeImages 只編輯記憶體中的文件;每個被修改的頁面以 FPDFPage_GenerateContent 提交,之後您仍要自己呼叫 SaveAs。若想目視檢查改了什麼,請照把 PDF 頁面轉成 JPEG 影像所述,把變更前後的文件渲染成點陣圖,在完整縮放下比對
適應性重取樣是那種「正常時無形、出問題時生出支援工單」的功能,這正是量測、alpha 處理與記憶體預算必須一起落地、而不是當成三個個別改良的原因。如果您正在為 Delphi、C++Builder 或 Lazarus 產品評估它,完整的 API 介面與授權細節都在 PDFiumPas Delphi PDFium 元件頁面上