技术文章

用 PDFiumPas 在 Delphi 中自适应重采样 PDF 图像

压缩功能上线后一周,两个抱怨来了:扫描的合同现在字形呈阶梯状、毛茸茸,封面上透明的 logo 坐在一圈淡色光晕里。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(Delphi)中毁掉扫描文字:每个输出像素保留四个源像素中的一个、丢弃其余,产生走样的字形边缘和莫尔纹,五个重采样核替换了它
点采样每个输出像素保留一个源像素、扔掉另外三个,这就是 PDFiumPas 如今提供五个核而不是一个的原因

PDFiumPas 如何测量细节并选核

PDFiumPas 逐图像决策,不是逐文档。选核之前,它从一个有界采样网格算出归一化的亮度细节分:水平和垂直步长是 (Width + 63) div 64(Height + 63) div 64,所以一万二千像素的扫描和三百像素的缩略图花费大致相同,都是 64 乘 64 的扫掠。在每个采样位置,它把与右侧邻居和下方邻居的绝对差累加起来,跨至多三个通道,然后除以样本数乘以 255。分数落在 0 到 1 之间,平淡的商务图形接近零,密集的照片纹理往上爬

然后选择阶梯按固定顺序运行。如果 ResampleFilter 不是 pirfAdaptive,就原样使用那个滤波器。否则:1 位内容走 pirfBilevelContentClasspiccLineArtpirfBox;缩放因子 4 或以上也走 pirfBox,因为在那种缩小比例下面积平均既是最便宜也是最正确的答案;piccPhoto、细节分 0.08 或更高、或 PreferredQuality 0.9 或更高走带三瓣核的 pirfLanczos;缩放 2 或以上或质量 0.7 或更高走半径 2 的 pirfBicubic;剩下的一切走 pirfBilinear。由于 TPdfImageOptimizeOptions.DefaultPreferredQuality 设为 0.85,默认运行从不回退到双线性,除非缩小比例温和且内容平淡

PDFiumPas 在 Delphi 中如何选择重采样核:有界的 64 乘 64 扫掠产出归一化细节分,然后固定的条件阶梯把每张图像路由到双值、盒式、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)的图像作为图标或线条被跳过

为什么透明 logo 会带上白边?

因为完全透明像素底下的颜色是任意的,而朴素的加权平均让它投票。从设计工具导出一个 logo,不可见的边距常常是白色、黑色或画布本来的任何颜色;alpha 通道遮住它,而在核覆盖范围上直接求和会立刻把它混回可见边缘。PDFiumPas 通过以预乘形式累加 BGRA 样本、只在目标像素处撤销预乘来避免这一点

具体说,每个贡献样本给颜色累加器加 channel * alpha * weight,给 alpha 累加器加 alpha * weight,给权重和加 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 告诉你自适应规则对你的语料实际得出了什么结论。全盒式结果意味着缩小比例很陡或内容被归类为线稿;在一份你本以为是线稿的文档上出现全 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 组件页