把一个 PDF 页面渲染到 Windows 设备上下文上做打印预览,会让三套坐标系挤在同一行代码里,而它们很少意见一致。PDF 页面以点为单位,原点在左下角。屏幕 DC 以像素为单位,原点在左上角,还带一个由你选定的缩放系数。而打印机 DC,也就是预览本该预测的那一个,按设备分辨率以像素度量,但它的原点落在可打印区域的角上,而不是整张纸的角上。三者中任何一个弄错,预览看着都没问题,印出来的页面却会偏移、缩放失当,或者沿着某条边被裁掉。常见症状是一张带框线的表单,预览时居中,印出来上边和左边的框线被削掉了,因为激光打印机没法在最外侧那几毫米上着墨,而没人告诉过预览这件事。losLab PDF Library(PDF Library for Delphi)用设备上下文渲染调用、一层虚拟打印机配置,以及依据打印机自身度量生成的预览位图,覆盖了整条路径,而最后这一项正是让预览对那道页边距诚实起来的关键
纸张几何不等于可打印几何
任何打印目标都由两个矩形来描述,而多数预览缺陷就住在两者之间的偏移里。纸张矩形是物理纸页。可打印矩形是打印引擎实际能触及的那块更小的区域,四周内缩一圈硬件边距,其大小因打印机型号而异,有时还因纸盒而异。库的打印层把两者都量了出来。底层的 TPLPrinter 类用 PageWidth 与 PageHeight 暴露可打印区域,用 FullPageWidth 与 FullPageHeight 暴露整张纸,再用 PrintOffsetX 和 PrintOffsetY 给出两者原点之间的间隔,全部以 GetDPI 所报告分辨率下的设备像素为单位。一个诚实的预览会把这同一组数字按比例缩到屏幕分辨率,而不是把页面画进控件恰好拥有的那个矩形里。跳过这一步,预览就会悄悄假定页边距为零,而那正是没有哪台真实打印机会采用的取值
经 RenderPageToDC 做屏幕预览
对于屏幕上的预览控件,RenderPageToDC(DPI, Page, DC) 会把已加载文档的某一页直接绘制到任意 GDI 设备上下文上,无论那是一个 TPaintBox 的画布、一张离屏位图,还是一个元文件 DC。DPI 参数设定缩放。96 近似于经典显示器上的 100% 视图,把它翻倍,渲染尺寸也随之翻倍
procedure TPreviewForm.PreviewBoxPaint(Sender: TObject);
begin
// 下面这三项是粘性的库状态,不是按次调用的参数:
FPdf.SetRenderDCOffset(FOffsetX, FOffsetY);
FPdf.SetRenderDCErasePage(1);
FPdf.SetRenderCropType(0);
FPdf.RenderPageToDC(FPreviewDpi, FCurrentPage, PreviewBox.Canvas.Handle);
end;
陷阱在于,DC 渲染路径是由粘性的库状态操纵的,而不是由按次调用的参数。SetRenderDCOffset、SetRenderDCErasePage 和 SetRenderCropType 各自都会一直保留到有东西改动它们为止,因此一个在用户调过缩放视图之后才跑的缩略图循环,会继承上一条代码路径遗留下来的偏移或裁切。症状是预览只在特定的导航顺序下才漂移,而这大概是缺陷复现难度的天花板了。像上面那样在绘制处理函数开头把所有相关状态都设一遍,代价为零,却能把这一整类问题消掉。附近还藏着第二个乘数。有效输出分辨率是渲染缩放乘以 DPI 参数,而 SetRenderScale 虽然默认为 1.0,一旦改过也同样保留,于是某个把它调大过的导出功能会悄悄把此后每一次预览都重新缩放,直到有东西把它设回去
滚动式查看器和局部重绘有专门的变体。RenderPageToDCClip 在设备上下文之外还接受一个裁剪规格,因此让窗口的某一条带失效时只会重绘那一条带,而不是把整页重新光栅化一遍。在大幅面页面的高缩放级别下,这就是一个能跟住滚动条的查看器与一个在滚动条后面拖影的查看器之间的差别
与预览相符的打印作业
打印这一侧通过一台虚拟打印机来运作。NewCustomPrinter 把一台系统打印机克隆进一份库私有的配置,而 SetupPrinter 在不触碰机器全局 DevMode 的前提下调整这个克隆体:纸张作为设置 1 传入(一个 DMPAPER_* 常量),方向作为设置 11。回报是隔离。一个服务可以打印 A4 标签,而宿主的默认打印机仍停在 Letter 上,事后也无需还原任何东西
var
Pdf: TPDFlib;
Virt: WideString;
Opt: Integer;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report.pdf', '') <> 1 then
raise Exception.Create('load failed');
Virt := Pdf.NewCustomPrinter(Pdf.GetDefaultPrinterName);
Pdf.SetupPrinter(Virt, 1, 9); // 设置 1 = 纸张,DMPAPER_A4
Pdf.SetupPrinter(Virt, 11, 1); // 设置 11 = 方向,1 = 纵向
Opt := Pdf.PrintOptions(1, 1, 'Monthly Report'); // 适应纸张,自动旋转并居中
Pdf.PrintDocument(Virt, 1, Pdf.PageCount, Opt);
finally
Pdf.Free;
end;
end;
PrintOptions 值得细读。它返回一个选项句柄,你必须把它传给 PrintDocument 或 PrintPages;它不是环境状态。把选项建好却忘了把句柄传过去,失败是无声的。作业会用默认设置打印出来,而在本该按适应纸张策略处理、结果一张超大页面被裁掉之前,没人会察觉。页面缩放参数正是这项策略的落脚点。不缩放保留尺寸精度,这对要拿尺子去量的表单很要紧。适应纸张把一切重新缩放到纸面上。缩小超大页面则不动正常页面,只在某页超出可打印区域时才介入,这对混合文档集来说通常是正确的默认值。自动旋转并居中标志让横向页面无需第二条代码路径
已经通过 VCL 对话框流程管理着一个 TPrinter 的应用可以直接把它交过来。PrintDocumentToPrinterObject 和 PrintPagesToPrinterObject 接受配置好的 TPrinter 实例,从而把标准打印对话框保留为面向用户的配置界面,同时由库负责页面渲染。在同一条代码路径里混用这两种做法,往往会把这项工作本想消除的几何漂移又请回来,所以请二选一。虚拟打印机路线适合无人值守的服务;TPrinter 路线适合交互式应用
选择性输出也是同一套办法。PrintPages 接受一个区间字符串,因此传入虚拟打印机名称、'2-5,12' 和选项句柄,就会在几何约定完好的前提下打印第 2 到 5 页以及第 12 页,而同样的语法也驱动打印到文件的各个变体。对于没有物理设备可接的无人值守环境,这些文件变体是务实的答案:在一台根本没有驱动打印队列的构建服务器上对打印几何做回归测试。每次构建都把同一份文档经同一套选项渲染成文件产物,一次几何回归就变成一处 diff,而不是三周后的一封客户报告
用打印机自身度量生成的预览位图
一份按假定页面尺寸在 96 DPI 下渲染的预览回答的是错误的问题。它展示的是页面长什么样,而不是这台打印机会在这种纸上印出什么。GetPrintPreviewBitmapToString 用与最终作业相同的自定义打印机和相同的选项句柄来构建预览,从而补上了这道缺口,于是纸张尺寸、方向、缩放策略、旋转和硬件偏移全都被喂进了位图。返回的就是纸面上会出现的东西
procedure ShowPrinterTruePreview(Pdf: TPDFlib; const Virt: WideString; Opt: Integer);
var
Data: AnsiString;
Strm: TMemoryStream;
Bmp: TBitmap;
begin
Data := Pdf.GetPrintPreviewBitmapToString(Virt, 1, Opt, 1200, 0);
Strm := TMemoryStream.Create;
try
Strm.WriteBuffer(PAnsiChar(Data)^, Length(Data));
Strm.Position := 0;
Bmp := TBitmap.Create;
try
Bmp.LoadFromStream(Strm);
PreviewImage.Picture.Assign(Bmp);
finally
Bmp.Free;
end;
finally
Strm.Free;
end;
end;
MaxDimension 参数为位图的长边设定上限。1200 像素对预览对话框来说依然清晰,即便是 E 号工程图也能把内存控制在适度水平,而在那种图纸上按打印机的 600 DPI 做全分辨率渲染会跑到数 GB
记住用户的打印机选择
在两次会话之间会忘掉设置的打印对话框,自己就会生出一批支持工单。DevMode 这一对函数,GetPrinterDevModeToString 和 SetPrinterDevModeFromString,把一台打印机的完整驱动配置序列化成一个不透明字符串,你可以把它塞进用户偏好里,下次会话再还原回来,其中包括那些通用 API 懒得建模的驱动专有选项。请按 GetPrinterNames 给出的名称来持久化打印机,绝不要按列表索引。每添加或移除一台打印机,索引顺序都会变,于是保存下来的索引会在列表下一次变动时悄悄指向错误的设备。当记住的设备已经彻底消失时,GetDefaultPrinterName 负责兜底
纸盒选择让这套持久化故事变得完整。GetPrinterBins 报告驱动所暴露的纸张来源,这对信头纸工作流很要紧:第一页从信头纸盒进纸,其余从普通纸盒进纸。这是用户期望应用连同其他一切一起记住的策略,而一个印在错误纸张上的打印作业,即便 PDF 的每个字节都正确,读起来也像是个缺陷
预览与打印要用同一个引擎
最后还有一个决定在暗中左右保真度。渲染引擎的选择对屏幕和打印机两种目标都生效,因此人很容易受诱惑:用快的引擎做预览,用精确的引擎去打印。请抵住这个诱惑。让预览和作业分别走不同的引擎,会把打印机真实预览本想消除的那种保真度漂移原样请回来,而且是以只在纸上才显形的方式。内置、Cairo 与 PDFium 三个引擎之间的取舍在 Delphi 中的多引擎 PDF 渲染中有权衡;挑一个,两边都用它
大到不便在打印前完整加载的文档,可以走 大型 PDF 的合并、拆分与直接访问中所述的直接访问路径,它能从一个文件句柄把页面渲染到设备上下文上,而无需构建文档树。完整的打印 API 参考位于 losLab PDF Library for Delphi 产品页