技术文章

在 Delphi 中使用 PDFium 组件将 PDF 页面渲染为 JPEG 图像

把一页 PDF 渲染成 JPEG,其实就是两步,先把页面按你选定的分辨率光栅化成位图,再把位图交给 JPEG 编码器并选定画质。PDFium Component 通过 RenderPage 负责前半段,后半段则是纯 VCL,也就是 Vcl.Imaging.jpeg 里的 TJPEGImage。真正需要拿捏的,是这两段之间的交界,因为渲染端的分辨率和编码端的画质会一起影响文件大小,稍有偏差就会把结果带偏

动手写代码前,先记住一件事:PDF 页面本身没有像素。它是用 point 来描述的,1 point 等于 1/72 英寸,整页其实是按这些单位定义的矢量图。当你让 PDFium 渲染时,你是在决定要把这张矢量图投射成多少像素,而这就是 DPI。算错了,结果要么是本该拿去印刷的图变成模糊缩略图,要么是为了一个 120 像素的预览图却分配出一个两亿像素的巨大位图

从 DPI 到像素尺寸

RenderPage 只接受整数像素 WidthHeight,不接受 DPI,所以第一步就是换算。页面会通过 PageWidthPageHeight 报出它的尺寸,这两个值都是 Double,换算公式和所有光栅器一样:像素等于点数乘以目标 DPI 再除以 72。以 US Letter 为例,它是 612 by 792 points。150 DPI 时会变成 1275 by 1650 像素,72 DPI 时则还是 612 by 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 的大文件,结果浏览器缩放时又把绝大多数内容丢掉。正确做法是先按输出真正需要的像素数来定分辨率,再选择一个足以保留细节、又不会把 JPEG 的有损压缩痕迹放大的画质

JPEG 画质值得单独提醒一下。它并不是线性刻度。把质量从 70 提到 85,通常会带来明显的视觉提升,文件也只会适度变大;把质量从 95 提到 100,文件体积可能接近翻倍,但几乎没人能看出区别,因为 100 质量依然不是无损,只是少丢了一些内容。对于以文字为主的页面,JPEG 的块状压缩会把字形边缘磨出明显的 ringing,所以质量低于 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。这样不管是竖版长页还是横版宽页,都能稳稳放进同一个框里而不变形,你也不用再去反推“放进 200 by 280 的框需要多少 DPI”这类问题。使用 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 Component 还提供了带 cancellation token 的渐进式渲染路径;这比大多数批量导出需要的机制更重,但在单页 600 DPI 本身就很慢时,它就派得上用场

最后还要说明一个取舍。把页面光栅化之后,原本的文本层就被丢掉了:JPEG 只是像素,里面的文字不再可选也不可搜索。若你既要图像又要底层文本,就需要把渲染和提取分开处理,相关做法可以参考配套文章 在 Delphi 中使用 PDFium 组件从 PDF 文档中提取文本。这里展示的 RenderPage 重载和渲染选项都属于面向 Delphi 和 C++Builder 的 PDFium Component