技術文章

在 Delphi 中用 PDFiumPas 做 PDF 影像適應性重取樣

壓縮功能出貨那一週,兩個抱怨抵達:掃描合約的字形現在帶著階梯狀、毛茸茸的邊,而封面頁上的透明商標坐在一圈蒼白光暈裡。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 在渲染快取與縮放效能中為此另有機制;一次糟糕的降取樣,則是您遞給客戶的一份新文件

在 Delphi 用的 PDFiumPas 中,最近鄰降取樣為何會毀掉掃描文字:每個輸出像素保留四個來源像素之一、丟掉其餘,產生鋸齒字形邊緣與莫爾紋,而五個重取樣核心會取代它
點取樣對每個輸出像素保留一個來源像素、丟掉另外三個,這正是 PDFiumPas 現在提供五個核心而非一個的原因

PDFiumPas 如何量測細節並挑選核心

PDFiumPas 是逐張影像、而不是逐份文件決定。挑選核心之前,它從一個有界取樣網格算出正規化的亮度細節分數:水平與垂直步距是 (Width + 63) div 64(Height + 63) div 64,所以一萬二千像素的掃描與三百像素的縮圖,成本都差不多是同一場 64 乘 64 的掃掠。在每個取樣位置,它把「與右邊鄰居及下邊鄰居的絕對差」跨最多三個通道加總,再除以取樣數乘以 255。分數落在 0 到 1 之間:平坦的商業圖形坐落在接近零處,稠密的攝影紋理則向上攀升

選擇階梯接著以固定順序執行。如果 ResampleFilterpirfAdaptive 以外的任何值,就逐字使用該濾波器。否則:1 位元內容走 pirfBilevelContentClasspiccLineArtpirfBox;縮放因子 4 以上也走 pirfBox,因為在那種縮減幅度下,區域平均既是最便宜、也是最正確的答案;piccPhoto、0.08 以上的細節分數,或 0.9 以上的 PreferredQuality,走帶三瓣核心的 pirfLanczos;縮放 2 以上或品質 0.7 以上,走半徑 2 的 pirfBicubic;剩下的一切走 pirfBilinear。由於 TPdfImageOptimizeOptions.DefaultPreferredQuality 設為 0.85,預設執行絕不會退回雙線性,除非縮減溫和且內容平坦

PDFiumPas 在 Delphi 中如何選擇重取樣核心:一場有界的 64 乘 64 掃掠產生正規化細節分數,然後一條固定的條件階梯把每張影像路由到雙層、box、Lanczos、雙三次或雙線性濾波器
細節分數在一萬二千像素掃描與縮圖上成本相同,其下的階梯在第一個符合的條件處停止
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 緩衝區

PDFiumPas 在 Delphi 中如何去除透明 PDF 影像的白光暈:取樣以預乘形式累加,目的地色彩除以累加的 alpha 而非權重和,使不可見像素無法投票
把預乘色彩除以累加的 alpha,能重建可見取樣達成共識的色彩;除以權重和則會把邊緣拖向不可見像素
// 內部累加迴圈的形狀,針對每個有貢獻的來源取樣
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.DefaultPreserveBilevelTrue,這類影像原封不動地落進 SkippedCount。把它設為 FalsepirfBilevel 路徑就會取代平滑核心接手。它走訪涵蓋每個目的地像素的確切來源矩形,按 BGR 記憶體順序以 0.114、0.587 與 0.299 權重平均亮度,並把結果在 127.5 處門檻化成乾脆的 0 或 255。沒有任何中間值能被寫入,所以邊緣保持俐落,細筆畫周圍不會形成灰色光暈;BGRA 來源的 alpha 色版照常平均,BGRx 目的地則得到常數 255。如果您需要的是底層像素而非更小的文件,從 PDF 檔擷取影像是另一條獨立路徑

影像超過工作記憶體預算時會怎樣?

它會原樣保留,而且會被計數。MaxWorkingBytes 預設為 64 MiB,並在兩處強制執行。建立目的地點陣圖之前,若寬乘高乘每像素位元組超過預算,PDFiumPas 就拒絕該影像。FPDFBitmap_CreateEx 成功之後,它再用真實步距乘高檢查一次,因為列襯墊可能把配置推過「天真乘積剛好過關」的那個限制。任一種拒絕都會終結目的地並回傳空。請看清這意味的降級:超預算的影像不會以較低品質重取樣,也不會被切成磚塊。原始影像留在文件中,BudgetExceededCountSkippedCount 都會遞增,因此一次執行可能回報成功,而文件只被最佳化了一部分。那是刻意的故障安全行為,但它代表報告不是可讀可不讀的東西。另有一種不同的失效模式: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 是為了被診斷而打造,不只是被記錄。除了 OptimizedCountSkippedCountFailedCount,它為每個核心暴露一個計數器,所以 BoxFilterCountBilinearFilterCountBicubicFilterCountLanczosFilterCountBilevelFilterCount 會告訴您適應性規則對您的內容母體實際下了什麼結論。全 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 元件頁面