技術文章

在 Delphi 中使用 PDFium 元件將 PDF 頁面轉譯為 JPEG 影像

將 PDF 頁面轉譯為 JPEG 是兩個通常會被混在一起執行、然後被分開偵錯的操作;首先,您需要以選定的解析度將頁面點陣化為像素點陣圖;接著,您將該點陣圖傳遞給 JPEG 編碼器並選擇品質;PDFium 元件透過 RenderPage 負責前半部分,後半部分則是純 VCL,即來自 Vcl.Imaging.jpegTJPEGImage;兩者之間的銜接處正是關鍵決策所在,因為您在轉譯端選擇的解析度與在編碼端選擇的品質會相互權衡,並以極易出錯的方式影響檔案大小

在撰寫任何程式碼之前,必須先理解:PDF 頁面沒有像素;它是以點(points)來描述的,其中一個點為 1/72 英吋,而頁面是使用這些點進行測量的向量圖形;當您要求 PDFium 進行轉譯時,您是在選擇要將該圖形投影到多少像素上,而這個選擇就是 DPI;如果計算錯誤,您可能會在需要列印主檔時轉譯出模糊的縮圖,或者為一個註定只有 120 像素的預覽畫面分配一個 2 億像素的點陣圖

從 DPI 轉換為像素尺寸

RenderPage 需要整數的像素 WidthHeight,而不是 DPI;因此,首要工作是進行轉換;頁面會透過 PageWidthPageHeight(皆為 Double)以點為單位回報其大小,轉換公式與每個點陣化器所使用的相同:像素等於點乘以目標 DPI 除以 72;例如,一張 US Letter 頁面為 612 x 792 點;在 150 DPI 時,這會變成 1275 x 1650 像素,在 72 DPI 時,它保持為 612 x 792,也就是每個點一個像素,這是人們容易忘記的恆等轉換

// Pdf.PageNumber must already point at the page you want.
PixelW := Round(Pdf.PageWidth  * Dpi / 72);
PixelH := Round(Pdf.PageHeight * Dpi / 72);
Bitmap := Pdf.RenderPage(0, 0, PixelW, PixelH, ro0, [], clWhite);
// ... use Bitmap ...
Bitmap.Free;   // the function-form RenderPage hands you ownership

這四行程式碼中的兩個細節決定了程式碼是否正確;第一,函式形式的 RenderPage 會傳回一個由擁有的 TBitmap;PDFium 分配了它隨即釋放控制權,如果您沒有在每次迭代中釋放 Free 它,對數百個頁面進行批次處理將會洩漏數百個點陣圖,程式程序會隨之膨脹直到崩潰;第二是 Color 參數,此處為 clWhite;PDF 頁面通常假設是在不透明的白色底上繪製,將具有透明度的頁面轉譯到錯誤的背景顏色上會產生模糊的邊緣或多餘的深色光暈;白色是幾乎所有文件正確的預設值,該參數僅用於非白色的罕見情況

0, 0 是在縮放座標空間中相對於頁面的 LeftTop 位移,除非您要進行裁剪,否則請保持為零;ro0 是旋轉:將其保持為零,PDFium 會遵循該頁面在 /Rotate 項目中已宣告的任何旋轉,因此橫向編排的頁面在轉譯時會保持橫向,無需您進行任何操作

將點陣圖編碼為 JPEG

一旦點陣圖建立完成,JPEG 部分就非常簡單了,且這是純粹的 Delphi 實作;TJPEGImage.Assign 會將點陣圖複製進來,CompressionQuality 以 1 到 100 的刻度設定品質,而 SaveToFile 則寫入檔案;唯一需要注意的順序規則是,品質必須在儲存之前設定,因為它決定了 SaveToFile 所觸發的編碼

uses
  Vcl.Graphics, Vcl.Imaging.jpeg, PDFium;

procedure SavePageAsJpeg(Pdf: TPdf; PageNumber, Dpi, Quality: Integer;
  const FileName: string);
var
  Bitmap: TBitmap;
  Jpeg: TJPEGImage;
begin
  Pdf.PageNumber := PageNumber;
  Bitmap := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Dpi / 72),
    Round(Pdf.PageHeight * Dpi / 72),
    ro0, [], clWhite);
  try
    Jpeg := TJPEGImage.Create;
    try
      Jpeg.Assign(Bitmap);
      Jpeg.CompressionQuality := Quality;   // 1..100
      Jpeg.SaveToFile(FileName);
    finally
      Jpeg.Free;
    end;
  finally
    Bitmap.Free;
  end;
end;

那嵌套的 try/finally 看起來對於單頁輔助函式來說有些繁瑣,但對於批次處理來說完全正確;內層區塊釋放編碼器,外層區塊釋放點陣圖,任何一方因異常而觸發時仍會釋放其所擁有的資源;若將它們合併為一個,則在編碼期間發生異常可能會使點陣圖滯留;在長時間執行中,這就是成功完成的轉換器與在第 300 頁因損壞檔案和記憶體不足對話方塊而崩潰的轉換器之間的區別

選擇 DPI 和品質

這兩個調整選項與輸出的用途並非無關,常見的錯誤是出於謹慎而將兩者都調高;在 300 DPI 下轉譯並以品質 95 儲存的網頁縮圖會佔用數百 KB,卻只假裝成一個 120 像素的影像,瀏覽器在縮小尺寸時會丟棄其中的絕大部分;請將解析度與輸出實際所需的像素相匹配,然後選擇能在 JPEG 有損壓縮中保留且無明顯失真的品質

輸出DPIJPEG 品質
列表縮圖7260-70
螢幕預覽96-15080-85
高細節檢視200-30085-95
列印主檔300-60090-100

JPEG 品質本身也需要特別注意;它不是一個線性刻度;從 70 提升到 85 可以用適度的檔案增長換取真正的視覺改善,但從 95 提升到 100 則會使檔案大小大致翻倍,而產生的差異幾乎沒人看得出來,因為品質 100 仍然不是無損的,它只是停止丟棄大部分資訊;對於文字較多的頁面,JPEG 基於區塊的壓縮會將字符的清晰邊緣抹平成微弱的環狀偽影,這就是為什麼低於 80 的品質會使原本應該清晰的輸出看起來像掃描文字;如果頁面主要為文字且您可以更改格式,PNG 轉譯該文字時就不會產生環狀偽影,JPEG 則適用於照片和混合內容,因為其壓縮後的檔案確實更小

更快、更小的縮圖

當目標是縮圖而不是忠實重現時,您可以告訴轉譯器減少工作量;Options 參數接受一組 TRenderOption 旗標,其中有幾個旗標可以犧牲保真度來換取速度,這正是小圖預覽所需要的;reGrayscale 可以捨棄顏色,這樣轉譯速度更快,且能產生更小的點陣圖進行編碼;reNoSmoothImagereNoSmoothPath 則會跳過在縮圖比例下本就看不見的反鋸齒處理

function RenderThumbnail(Pdf: TPdf; PageNumber, MaxW, MaxH: Integer): TBitmap;
var
  Scale: Double;
begin
  Pdf.PageNumber := PageNumber;
  // Fit the page inside MaxW x MaxH while preserving aspect ratio.
  Scale := Min(MaxW / Pdf.PageWidth, MaxH / Pdf.PageHeight);
  Result := Pdf.RenderPage(0, 0,
    Round(Pdf.PageWidth  * Scale),
    Round(Pdf.PageHeight * Scale),
    ro0, [reGrayscale, reNoSmoothImage], clWhite);
end;

縮圖案例也展示了更簡潔的尺寸思考方式;與其透過 DPI,不如計算一個單一的縮放比例,使頁面符合邊框大小並維持長寬比,這正是兩個比例的 Min 所做的工作;直向頁面和橫向頁面最終都會放入同一個框中且不變形,您再也不用去思考什麼 DPI 對應於「符合 200 x 280」的大小;使用 reGrayscale 時有一個注意事項:它會將點陣影像內容轉換為灰色,但向量填滿和文字在引擎中仍會保留其顏色值,因此主要由向量藝術組成的頁面在傳回時可能不像該旗標名稱所暗示的那麼單色;若要獲得真正的完全灰階結果,使用 GrayscalePdfBitmap 轉換轉譯後的點陣圖才是可靠的途徑

批次處理整份文件

將這些整合到完整文件中就是對 PageCount 進行迴圈,每次移動一個 PageNumber;頁面是從 1 開始的:第一頁是 PageNumber := 1,而迴圈執行到包含 PageCount,而非 PageCount - 1;批次處理還必須遵循靜默載入約定;將 Active := True 設定在損毀的檔案或錯誤的密碼上時絕不會引發異常,它只會讓 Active 保持為 False;請在轉譯任何頁面之前檢查它,否則第一個 RenderPage 將會針對一個從未開啟的文件運作

procedure ExportAllPages(const PdfPath, OutDir: string; Dpi, Quality: Integer);
var
  Pdf: TPdf;
  I, Digits: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := PdfPath;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Could not open ' + PdfPath);

    Digits := Length(IntToStr(Pdf.PageCount));   // zero-pad so files sort right
    for I := 1 to Pdf.PageCount do
      SavePageAsJpeg(Pdf, I, Dpi, Quality,
        Format('%s\page_%.*d.jpg', [OutDir, Digits, I]));
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

透過 Digits 進行補零是一件小事,但可以節省日後一下午的時間;如果將檔案命名為 page_1.jpgpage_10.jpg,任何將其排序為字串的工具都會將 page_10 放在 page_1 之後,從而打亂順序;補足到最大頁數的寬度,因此一份 300 頁的文件會產生 page_001.jpg,這能使字典順序與頁面順序在下游的所有地方保持一致

對於大到轉換需要明顯時間的文件,請在 UI 執行緒之外執行,或在頁面之間傳送訊息以保持應用程式的回應,並為使用者提供停止的方法;如果您正在轉譯非常大的頁面,且希望在頁面中間而不是僅在頁面之間進行取消,PDFium 元件具有帶有取消權杖的漸進式轉譯路徑;這是一個比大多數批次匯出更繁重的機制,但當 600 DPI 的單一頁面本身慢到足以阻塞時,它就在那裡派上用場

最後一個值得了解的組合;點陣化頁面會捨棄其文字層:JPEG 是像素,其中的單字將不再是可選取或可搜尋的;當您同時需要影像和底層文字時,請為影像進行轉譯並單獨擷取文字,這在在 Delphi 中使用 PDFium 元件擷取 PDF 文件中的文字有詳細介紹;此處顯示的 RenderPage 多載和轉譯選項是適用於 Delphi 和 C++Builder 的 PDFium 元件 的一部分