传真閘道器不想要你的 24 位元页面渲染。保存了一百萬張看起來像扫描发票的归档管线不想要,在寻找字符之前就把所有东西二值化为黑白的 OCR 前端也不想要。这三者想要的都是同一个东西:一个乾淨的 1 位元位图,每个像素一个位元,每个点非墨即紙。如果你交給它们一个全彩的 BMP,它们无論如何都会丟棄每像素 23 個位元,而且通常会用比你自己所能做的还要糟糕的递色 (dithering) 处理來進行。有趣的問题是这种向下转换应该发生在哪里,而在 PDFlibPas 中的答案证明了在扩展一个你不希望重写的渲染器时,一些有用的设計思維
PDFlibPas 是一个适用於 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 位元。渲染器未受觸碰。單色行为完全存在於便利方法中,这意味著它不可能影响到任何没有呼叫它的人。这是一种值得明確指出的权衡:一个后处理付出了額外分配一次位图和一个暫存档的代價,作为交换,它让一个承重的核心完全置身事外。对於一个为了服务传真和归档这类边緣案例而存在的功能來说,这是帳本上正確的一方
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf');
// 200 DPI is the classic Group 4 fax resolution; page index is 1-based
Pdf.RenderPageToMonochromeFile(200, 1, 'invoice-page1.bmp');
finally
Pdf.Free;
end;
end;
1 位元摺疊实际上是如何发生的
向下转换依賴 GDI 而不是手写的二值化迴圈 (threshold loop),这个选择对输出质量至关重要。在该方法内部,24 位元临时位图被加载到一个 TBitmap 中,接著以相同的尺寸建立第二個 TBitmap 的 PixelFormat := pf1bit,然后像素透过一次 blit 动作移过去:
// inside RenderPageToMonochromeFile, after loading the 24-bit ColorBmp
MonoBmp.PixelFormat := pf1bit;
MonoBmp.Width := ColorBmp.Width;
MonoBmp.Height := ColorBmp.Height;
// HALFTONE tells GDI to dither the 24-bit source down to 1-bit
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 下点陣化整個页面的代價。方法特征非常直觉:
// Clip is "Left,Top,Width,Height" in PDF points (72 pt = 1 inch)
// Here: a 2.5in x 1in box, one inch in from the top-left of the page
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 缩放係数,套用於所有四個边緣。渲染前后的序列是标準的保存/裁剪/还原舞步:
// inside TPDFPageTree.RenderPageToDC, when Clip is non-empty
ScaleFactor := DPI / 72;
SaveDC(TargetDC);
IntersectClipRect(TargetDC,
Round(ClipLeft * ScaleFactor),
Round(ClipTop * ScaleFactor),
Round((ClipLeft + ClipWidth) * ScaleFactor),
Round((ClipTop + ClipHeight) * ScaleFactor));
// ... renderer draws the page here ...
// in the finally block:
RestoreDC(TargetDC, -1);
SaveDC / RestoreDC(-1) 对是让它能夠被安全地重复呼叫的原因:裁剪区域被推入 DC 状態堆疊中,页面被繪製,然后无論渲染如何退出,原始的裁剪都会被彈出 (pop) 回來。RestoreDC(TargetDC, -1) 会还原最近保存的状態,这正是平衡保存/还原的标準慣用法。如果跳过还原,一个重复使用同一个 DC 來進行隨后的全页渲染的呼叫者,将会发现它莫名其妙地被裁剪到最后一个区域。修復这个死参数也免費修復了 RenderPageRegionToFile,因为那個新方法正是走这条路径
一个要内化的行为要点:clip 是裁剪,它不是缩放。页面仍然以你所要求的 DPI、在其正常位置点陣化,而裁剪区域只是簡單地捨棄矩形之外的所有东西。你不是在放大区域以填满输出;你是在全解析度的渲染图中切出一个窗戶。如果你想放大一个区域,请提高 DPI。矩形的坐标是在点对像素缩放后,在设备空間中解读的,从渲染表面的左上角开始測量,所以请从页面頂部开始向下规划你的 Left 和 Top。要更深入地了解 PDFlibPas 如何驅动用於螢幕输出的设备上下文,关於列印预览和设备上下文输出的伴隨文章会从顯示端带你走过相同的 DC 管线
誠实的边界:1 位元 BMP,不是 G4 TIFF
这很容易被过度推銷为“隨时可用於传真的输出”,所以这里清楚说明其极限。RenderPageToMonochromeFile 产生一个 pf1bit 的 BMP。它并不产生 CCITT Group 4 TIFF,而后者才是真正的传真工作流或 TIFF 归档档通常预期的格式。原因很具体,不是疏忽:PDFlibPas 的 CCITT 單元目前能解码 G4 流,但没有 G4 編码器。没有編码器就没有地方可以写入压缩的單色連續数据,所以單色路径就停在未压缩的 1 位元 DIB 上
在实务上,这仍然是有用的。1 位元 BMP 是正確的像素格式,已經过递色处理并準備就緒,多数的传真、归档或 OCR 工具鏈会很樂意地攝入它,或者在下游的一个步驟中自己将它转换为 G4。但如果你需求就是直接从函式庫出來的 Group 4 TIFF,现在还辦不到,你应该自行规划一个压缩階段。知道一个功能的界线在哪里,其價值不亞於知道它能做什么
这两个方法都被刻意设計得很小,而这是这篇文章值得带走的设計教訓:一个位於渲染器之上的便利 API,可以增加真正的能力(單色输出、区域裁剪),而无需深入光栅化器并使所有其他呼叫者變得不稳定。当你確实需要在不同渲染引擎之間选择底层点陣化機制时,Delphi 中的多引擎 PDF 渲染概觀深入探討了其权衡。要查看完整的渲染层面和 API 的其餘部分,PDFlibPas Delphi PDF Library 产品页面擁有全貌