技术文章

在 Delphi 中将 PDF 页面渲染为 1 位单色

传真网关不想要你的 24 位页面渲染。保存了一百万张看起来像扫描发票的归档管线不想要,在寻找字符之前就把所有东西二值化为黑白的 OCR 前端也不想要。这三者想要的都是同一个东西:一个干净的 1 位位图,每个像素一位,每个点非墨即纸。如果你交给它们一个全彩的 BMP,它们无论如何都会丢弃每像素 23 位,而且通常会用比你自己所能做的还要糟糕的递色 (dithering) 处理来进行。有趣的问题是这种向下转换应该发生在哪里,而在 PDF Library for Delphi 中的答案证明了在扩展一个你不希望重写的渲染器时,一些有用的设计思维

PDF Library for Delphi 是一个适用于 Delphi 和 C++Builder 的原生 Object Pascal PDF 库。它的渲染核心将页面点阵化为位图,并且能够发出 BMP、PNG、JPEG、WMF 以及少数其他格式。它直到最近还不能做的,是交回一个真正的单色位图,或是只渲染页面的一部分。这两项功能都在 v3.83.0 中登陆,并且这两项功能都是建立在现有渲染器之上的轻量级便利层,而不是对光栅化器本身的变更。这个限制就是整个故事的重点

为什么在渲染后向下转换,而不是在渲染器内部

产生 1 位影像的明显方法是告诉光栅化器以 1 位进行绘图。但这也是会破坏其他所有东西的方法。渲染器的内部位图是在 PixelFormat := pf24bit 构造函数中使用写死的 PDFlibRenderer 来创建的,而且这个 24 位表面由每一个渲染路径所共享:PNG 导出、设备上下文 (device-context) 预览、JPEG 输出,全都如此。如果你在源头将它翻转为 pf1bit,你并没有增加一个单色功能,而是降低了库中每个调用者的色彩保真度,并且要为除错一打的下游回归问题签字负责

因此 RenderPageToMonochromeFile 采取了相反的路径。它正常地渲染页面,输出到一个临时的 24 位 BMP,然后才在后处理步骤中将其折叠为 1 位。渲染器未受触碰。单色行为完全存在于便利方法中,这意味着它不可能影响到任何没有调用它的人。这是一种值得明确指出的权衡:一个后处理付出了额外分配一次位图和一个临时文件的代价,作为交换,它让一个承重的核心完全置身事外。对于一个为了服务传真和归档这类边缘案例而存在的功能来说,这是账本上正确的一方

PDF Library for Delphi 管道图:PDF 页面渲染为临时 24 位 BMP,经 GDI HALFTONE 位块传输压缩为 1 位单色位图,供传真、归档和 OCR 工作流使用
新方法先渲染全彩页面,再在渲染器之外对完成的光栅做降色转换。传真网关、归档存储和 OCR 前端拿到的是真正的 pf1bit 位图
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.LoadFromFile('invoice.pdf');
    // 200 DPI 是经典的 Group 4 传真分辨率;页码从 1 开始
    Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
  finally
    Pdf.Free;
  end;
end;

1 位折叠实际上是如何发生的

向下转换依赖 GDI 而不是手写的二值化循环 (threshold loop),这个选择对输出质量至关重要。在该方法内部,24 位临时位图被加载到一个 TBitmap 中,接着以相同的尺寸建立第二个 TBitmap 的 PixelFormat := pf1bit,然后像素通过一次 blit 动作移过去:

PDF Library for Delphi:GDI 压缩细节:对比将灰阶抖动为点阵的 HALFTONE 拉伸位块传输与成块的 BLACKONWHITE 默认阈值
在 RenderPageToMonochromeFile 内部,一次 StretchBlt 把所有像素搬到同尺寸的 pf1bit 表面上。启用 HALFTONE 时,灰色变成抖动的墨点图案,而不是默认阈值产生的块状形状
// 在 RenderPageToMonochromeFile 内部,加载 24 位 ColorBmp 之后
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width  := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE 指示 GDI 将 24 位源图像抖动降采样为 1 位
SetStretchBltMode(MonoBmp.Canvas.Handle, HALFTONE);
StretchBlt(MonoBmp.Canvas.Handle, 0, 0, MonoBmp.Width, MonoBmp.Height,
  ColorBmp.Canvas.Handle, 0, 0, ColorBmp.Width, ColorBmp.Height, SRCCOPY);
MonoBmp.SaveToFile('out.bmp');

诀窍在于使用 SetStretchBltMode 的 HALFTONE。即使来源和目标位置的尺寸相同(因此不会发生缩放),伸展模式仍然管辖 GDI 如何将色彩对应到 1 位调色板。HALFTONE 使它应用半色调递色 (halftone dithering),将灰色区域和反锯齿文字边缘转化为黑白点图案,而不是生硬地裁剪至最接近的两种颜色之一。拿掉模式调用,或是使用预设的 BLACKONWHITE,灰阶内容就会色调分离成块状的二值化形状。对于扫描文件和 OCR 预处理输出,递色结果几乎总是你所需要的

有一个细节是没有谈判余地且容易出错的:临时渲染结果必须是 BMP。RenderPageToMonochromeFile 使用选项代码 0(即 BMP)调用通用渲染器。RenderPageToFile 上的 options 参数是一个小整数列举,这些值在这个目的下不能互换:0 是 BMP,1 是 JPEG,2 是 WMF,3 是 EMF,5 是 PNG 等等。向下转换器随后在临时文件上执行 TBitmap.LoadFromStream。如果你通过传递 2 来喂给它一个 WMF,那么该加载作业将会抛出 "Bitmap image is not valid"(位图影像无效),因为 Windows Metafile 是一个矢量记录流,而不是 DIB。单色向下转换从头到尾都是点阵作业,因此中介物必须是点阵格式

仅渲染页面的子区域

第二个方法 RenderPageRegionToFile 仅渲染页面的一个矩形,而不是整个页面。如果你建立过任何文件查看器,就会熟悉其使用案例:从合同中裁剪出签章块、为大型图纸的缩放地图产生图块,或者为缩图提取一个加盖章的区域,而不需付出在高 DPI 下点阵化整个页面的代价。方法特征非常直觉:

PDF Library for Delphi:以 PDF 点为单位的区域裁剪渲染:完整分辨率渲染的 PDF 页面上的 72,72,180,72 矩形在 150 DPI 下成为 375×150 像素位图,因为裁剪是裁切而非缩放
RenderPageRegionToFile 从全分辨率渲染中裁出一个以 PDF 点为单位的窗口。输出位图的尺寸由宽高乘以 DPI 再除以 72 决定,绝不是缩小后的整页
// Clip 为 PDF 点的 "Left,Top,Width,Height"(72 pt = 1 英寸)
// 这里:一个 2.5 英寸 x 1 英寸的盒子,距页面左上角向内一英寸
Pdf.RenderPageRegionToFile(150, 1, '72,72,180,72', 'sig-block.bmp');

clip 字符串是四个以逗号分隔的双精度浮点数,单位为 PDF 点,在方法内部进行手动解析以避开地区设置和 DelimitedText 的怪癖。从宽度和高度,该方法计算出输出位图尺寸为 Round(Width * DPI / 72) 乘 Round(Height * DPI / 72),在内存中配置一个精确为该尺寸的 pf24bit 位图,并通过 RenderPageToDCClip 渲染到其设备上下文中。结果文件只包含裁剪过的矩形,其大小对齐的是区域而不是整个页面

那个毫无作用的 clip 参数

这项工作的精妙之处就在这里。RenderPageToDCClip 带有一个 Clip 参数已经很长一段时间了,而且它是一个谎言。调用接受了这个参数,把它传递给 TPDFPageTree.RenderPageToDC,然后该实现就完全忽略它,从未把它交给渲染器。你可以传递你喜欢的任何矩形,但拿回来的却是整个页面。任何期望得到裁剪而接上 RenderPageToDCClip 的人都会得到一个全页渲染,根据他们的布局,他们甚至可能不会注意到

v3.83.0 接通了这条线路。RenderPageToDC 现在会解析同样的 "Left,Top,Width,Height" 点阵矩形,并在渲染器绘图之前,将其作为真正的 GDI 裁剪区域应用到目标设备上下文上。从点到设备像素的转换是惯常的 DPI / 72 缩放系数,应用于所有四个边缘。渲染前后的序列是标准的保存/裁剪/还原舞步:

// 在 TPDFPageTree.RenderPageToDC 内部,当 Clip 非空时
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
  Round(ClipLeft * ScaleFactor),
  Round(ClipTop * ScaleFactor),
  Round((ClipLeft + ClipWidth) * ScaleFactor),
  Round((ClipTop + ClipHeight) * ScaleFactor));
// ... 渲染器在此处绘制页面 ...
// 在 finally 块中:
RestoreDC(TargetDC, -1);

SaveDC / RestoreDC(-1) 对是让它能够被安全地重复调用的原因:裁剪区域被推入 DC 状态堆栈中,页面被绘制,然后无论渲染如何退出,原始的裁剪都会被弹出 (pop) 回来。RestoreDC(TargetDC, -1) 会还原最近保存的状态,这正是平衡保存/还原的标准惯用法。如果跳过还原,一个重复使用同一个 DC 来进行随后的全页渲染的调用者,将会发现它莫名其妙地被裁剪到最后一个区域。修复这个死参数也免费修复了 RenderPageRegionToFile,因为那个新方法正是走这条路径

一个要内化的行为要点:clip 是裁剪,它不是缩放。页面仍然以你所要求的 DPI、在其正常位置点阵化,而裁剪区域只是简单地舍弃矩形之外的所有东西。你不是在放大区域以填满输出;你是在全分辨率的渲染图中切出一个窗口。如果你想放大一个区域,请提高 DPI。矩形的坐标是在点对像素缩放后,在设备空间中解读的,从渲染表面的左上角开始测量,所以请从页面顶部开始向下规划你的 Left 和 Top。要更深入地了解 PDF Library for Delphi 如何驱动用于屏幕输出的设备上下文,关于打印预览和设备上下文输出的伴随文章会从显示端带你走过相同的 DC 管线

诚实的边界:1 位 BMP,不是 G4 TIFF

这很容易被过度推销为“随时可用于传真的输出”,所以这里清楚说明其极限。RenderPageToMonochromeFile 产生一个 pf1bit 的 BMP。它并不产生 CCITT Group 4 TIFF,而后者才是真正的传真工作流或 TIFF 归档档通常预期的格式。原因很具体,不是疏忽:PDF Library for Delphi 的 CCITT 单元目前能解码 G4 流,但没有 G4 编码器。没有编码器就没有地方可以写入压缩的单色连续数据,所以单色路径就停在未压缩的 1 位 DIB 上

在实践中,这仍然是有用的。1 位 BMP 是正确的像素格式,已经过递色处理并准备就绪,多数的传真、归档或 OCR 工具链会很乐意地摄入它,或者在下游的一个步骤中自己将它转换为 G4。但如果你需求就是直接从库出来的 Group 4 TIFF,现在还办不到,你应该自行规划一个压缩阶段。知道一个功能的界线在哪里,其价值不亚于知道它能做什么

这两个方法都被刻意设计得很小,而这是这篇文章值得带走的设计教训:一个位于渲染器之上的便利 API,可以增加真正的能力(单色输出、区域裁剪),而无需深入光栅化器并使所有其他调用者变得不稳定。当你确实需要在不同渲染引擎之间选择底层点阵化机制时,Delphi 中的多引擎 PDF 渲染概观深入探讨了其权衡。要查看完整的渲染层面和 API 的其余部分,PDF Library for Delphi Delphi PDF Library 产品页面拥有全貌