用户在 Delphi 查看器里填好一份 PDF 表单,按下打印,出来的纸上字段全是空的,Letter 尺寸的页面还被拉满整张 A4。两个毛病都在打印循环里:PDFium Component 只把字段值画进位图(或者把它们烤进一份扁平化副本),而每一页都需要一个保持宽高比的缩放系数
表单层是第二遍绘制,而且得有张位图
在 Windows 上,设备上下文重载 TPdf.RenderPage(DC, Left, Top, Width, Height, Rotation, Options) 把打印机的 HDC 直接交给 FPDF_RenderPage。那次调用画的是页面内容,加上置了 reAnnotations 时的标记注释。交互式表单层根本到不了 DC——它属于 PDFium 的表单填充环境,由 FPDF_FFLDraw 绘制,而它唯一的目标是一个 FPDF_BITMAP,没有 HDC 变体。TPdf.RenderPage 的两个位图重载,只要文档打开时 FormFill 为 True(默认如此),就会在同一批像素上跑 FPDF_RenderPageBitmap 和 FPDF_FFLDraw。带 rePrinting 选项时,PDFium Component 在表单这一遍之前去掉字段高亮,之后恢复默认的高亮颜色与透明度,所以屏幕上可编辑字段周围那层浅色底依然在。rePrinting 同时映射到引擎的 FPDF_PRINTING 标志,把输出标记为打印——正是 ISO 32000-1 §12.5.3(表 165)用注释标志第 3 位处理的情形:Print 位为 0 的注释,合规阅读器不管它在屏幕上多显眼,都不许落到纸上
剩下的由平台决定。在 PDFium 引擎里,FPDF_RenderPage 通过 Windows GDI 画进设备上下文,只存在于 Windows 构建;而 FPDF_FFLDraw 只画进位图,别的什么都不画,每个平台都有。在 Linux 和其他非 Windows 目标上,根本没有 GDI 调用可做,所以 HDC 重载是靠渲染一张位图——表单层一并画上——再把它画到句柄背后的画布上来实现的。于是设备上下文调用在 Windows 之外带着字段值,在 Windows 上反倒把它们丢下。Delphi 与 Lazarus 共用的代码里,请把这个分支写明:文档有表单环境、且字段值需要落到纸上时,在任何平台上都直接要位图
打印作业该走哪条路
按纸上必须带什么来选。要活的表单值就走位图路,要矢量输出就走 HDC 路,而一份扁平化副本用一次保存加一次重载的代价把两者都给你。SetPdfPrintMode 是库级的,所以只在作业之间改,绝不在页与页之间改
| 路线 | 表单值上纸 | 输出 | 代价 |
|---|---|---|---|
RenderPage(DC, …) | Windows 上没有 | 矢量 GDI;SetPdfPrintMode 选 EMF(默认)或某个 PostScript 级别 | 后台数据最小、最快 |
RenderPage 到 TBitmap,再 StretchDIBits | 有,包括未保存的改动 | 按你请求的像素尺寸光栅化 | 每页 Width × Height × 4 字节,再加一份 DIB 拷贝 |
RenderPageBanded | 有 | 光栅,一次一条带 | 内存里只留一条带,而不是整张纸 |
扁平化副本,再 RenderPage(DC, …) | 有,作为烤进页面的内容 | 矢量 | 保存、重载、GenerateFormAppearances、FlattenAllPages(FLAT_PRINT) |
整张 A4 在 600 dpi 下大约是 4960 × 7016 个设备像素,按每像素 32 位算,一页就是 139,197,440 字节;再为 StretchDIBits 拷进一份 DIB,峰值翻倍,对 32 位进程是重担。查看器的打印按钮仍然该默认走位图路:用户在字段里敲了字、按下打印,就指望那个值出现在纸上,而分带能让内存有界。要离开应用的文档,比如归档对账单,交给扁平化副本更合适——值变成普通页面内容,打印机拿到的是矢量输出
uses
System.SysUtils, System.IOUtils, PDFium, FPdfUtils; // FPdfUtils:FLAT_PRINT
function OpenFlattenedPrintCopy(Source: TPdf; out TempName: string): TPdf;
begin
TempName := TPath.Combine(TPath.GetTempPath, TPath.GetRandomFileName + '.pdf');
if not Source.SaveAs(TempName) then
raise Exception.Create('Could not write the print copy');
Result := TPdf.Create(nil);
try
Result.FileName := TempName; // FormFill 保持 True(默认值)
Result.Password := Source.Password; // 副本保留自己的加密
Result.Active := True;
if not Result.Active then // 加载失败会让 TPdf 处于非活动状态
raise Exception.Create('Could not reopen the print copy');
Result.GenerateFormAppearances; // 用当前的 /V 重建 /AP
if not Result.FlattenAllPages(FLAT_PRINT) then
raise Exception.Create('Flattening failed');
except
Result.Free;
TFile.Delete(TempName);
raise;
end;
end;
扁平化这条路有一条顺序规则,比那个标志更要紧。FlattenAllPages 把每个控件的外观流(ISO 32000-1 §12.5.5)烤进页面内容,而外观流就是上一个生成器写下的东西,/V 里的字段值一点份都没有。被一个从不重建外观的工具填过的表单,扁平化出来是旧文本,或者什么都没有,所以 GenerateFormAppearances 要先跑:它用当前值重建文本字段的外观,同步复选框与单选按钮的 /AS 状态,并且要求文档打开时 FormFill 为 True。这一切都在副本上做,因为扁平化会重写它跑在的那个 TPdf 的页面,而开着的查看器还指望字段能继续编辑。副本随后走下面打印例程的矢量分支,表单层关掉;作业结束就释放它、删掉临时文件
把每页装进可打印区域
按两个系数里较小的那个缩一次,然后把结果居中。拉伸到 Printer.PageWidth × Printer.PageHeight,形状和纸不一样的页面全都会变形:A4 上的 Letter 页(612 × 792 点),或竖版纸上的横版页。打印机那边要的是可打印区域的设备像素(HORZRES 乘 VERTRES),驱动边距已经排除在外。PDF 这边,TPdf.PageWidth 和 PageHeight 报的已经是显示出来的页面:一张用 /Rotate 90 存的 A4 竖版页,回来是 842 × 595 点。ISO 32000-1 §7.7.3.3(表 30)说页面显示或打印时应按 /Rotate 旋转,PDFium 在渲染调用里就把这事做了,而 RenderPage 的 Rotation 参数只是用户转动视图时查看器额外加的那一圈。在那里再传页面自己的 PageRotation,页面就转了两次。只有那份额外的旋转是 90 或 270 度时,才为适配交换宽和高,这套两条规则与旋转下的缩略图适配与缩放是同一套;下面的例程给用 PDFium Component 打印 PDF 文档的基本循环加上了表单值分支和一条丢弃作业的取消路径
uses
Winapi.Windows, System.SysUtils, Vcl.Graphics, Vcl.Forms, Vcl.Printers, PDFium;
procedure FitRect(PageW, PageH: Double; Swap: Boolean;
AreaW, AreaH: Integer; out X, Y, W, H: Integer);
var
T, Scale: Double;
begin
if Swap then
begin
T := PageW; PageW := PageH; PageH := T;
end;
if (PageW <= 0) or (PageH <= 0) then
begin
X := 0; Y := 0; W := AreaW; H := AreaH;
Exit;
end;
Scale := AreaW / PageW;
if AreaH / PageH < Scale then
Scale := AreaH / PageH;
W := Round(PageW * Scale);
H := Round(PageH * Scale);
X := (AreaW - W) div 2;
Y := (AreaH - H) div 2;
end;
procedure BlitBitmap(DC: HDC; Bitmap: TBitmap; X, Y: Integer);
var
InfoSize, ImageSize: DWORD;
Info: PBitmapInfo;
Bits: Pointer;
begin
GetDIBSizes(Bitmap.Handle, InfoSize, ImageSize);
Info := AllocMem(InfoSize);
try
Bits := AllocMem(ImageSize);
try
GetDIB(Bitmap.Handle, 0, Info^, Bits^);
StretchDIBits(DC, X, Y, Bitmap.Width, Bitmap.Height, 0, 0,
Bitmap.Width, Bitmap.Height, Bits, Info^, DIB_RGB_COLORS, SRCCOPY);
finally
FreeMem(Bits);
end;
finally
FreeMem(Info);
end;
end;
function PrintPages(Pdf: TPdf; FromPage, ToPage: Integer; FormLayer: Boolean;
ExtraRotation: TRotation; var Cancel: Boolean): Boolean;
const
Opts: TRenderOptions = [reAnnotations, rePrinting];
var
Page, X, Y, W, H: Integer;
Bitmap: TBitmap;
begin
Result := False;
Printer.BeginDoc;
try
for Page := FromPage to ToPage do
begin
if Page > FromPage then
Printer.NewPage;
Pdf.PageNumber := Page;
FitRect(Pdf.PageWidth, Pdf.PageHeight, ExtraRotation in [ro90, ro270],
Printer.PageWidth, Printer.PageHeight, X, Y, W, H);
if FormLayer then
begin
Bitmap := Pdf.RenderPage(0, 0, W, H, ExtraRotation, Opts);
if Bitmap <> nil then // 退化页面尺寸时返回 nil
try
BlitBitmap(Printer.Canvas.Handle, Bitmap, X, Y);
finally
Bitmap.Free;
end;
end
else
Pdf.RenderPage(Printer.Canvas.Handle, X, Y, W, H, ExtraRotation, Opts);
Application.ProcessMessages; // 让取消按钮有机会置标志
if Cancel then
Break;
end;
Result := not Cancel;
finally
if Result then
Printer.EndDoc // 把作业提交给后台打印程序
else
Printer.Abort; // 取消或异常:什么都不打印
end;
end;
查看器调用它时把 Pdf.FormFill 当作 FormLayer 传进去;扁平化副本传 False。EndDoc 是提交,不是收尾清理。在 BeginDoc 之后,Printer.EndDoc 会把此前画好的所有东西当做一个有效作业交给后台打印程序,所以 finally 里无条件写 EndDoc,会把一次取消的打印变成只打半张纸。这个例程改在 finally 里看成功标志决定:最后一页之后 EndDoc,取消或异常之后 Abort。循环出口也要同样小心,因为 Delphi 和 Free Pascal 的 for 循环终值都只在进入时求一次。从取消处理器里把 ToPage 或份数设成零,停不住一个已经在跑的循环;要测标志然后 Break,而在份数与分页嵌套的循环里,得逐层跳出。又因为 Application.ProcessMessages 也会放菜单快捷键进来,作业跑着的时候把打开、保存和打印都禁掉
让光栅化那条路不把内存撑爆
TPdf.RenderPageBanded 按你选的高度把一页分成横条渲染,每条交给一个回调。每条都是整页的一个切片——不做任何重缩放——承载的字段值和整页位图一样。用 512 行一条,一页宽 4960 像素只需每条约 10 MB,而不是整张纸的 139 MB。差不多十四分之一的内存。TPdfBandCallback 是个方法指针,收到条序号、该条在页面像素里的顶部偏移和位图,返回 True 表示继续;RenderPageBanded 返回条数,失败时为 0
type
TBandPrinter = class
private
FDC: HDC;
FLeft, FTop: Integer;
public
constructor Create(DC: HDC; Left, Top: Integer);
function PrintBand(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean;
end;
constructor TBandPrinter.Create(DC: HDC; Left, Top: Integer);
begin
inherited Create;
FDC := DC;
FLeft := Left;
FTop := Top;
end;
function TBandPrinter.PrintBand(BandIndex, BandTopY: Integer;
Bitmap: TBitmap): Boolean;
begin
BlitBitmap(FDC, Bitmap, FLeft, FTop + BandTopY);
Result := True; // 返回 False 会中止剩余的条
end;
// 在页面循环里替代整页位图分支调用
procedure PrintPageBanded(Pdf: TPdf; Page, X, Y, W, H: Integer;
ExtraRotation: TRotation);
var
Bands: TBandPrinter;
begin
Bands := TBandPrinter.Create(Printer.Canvas.Handle, X, Y);
try
if Pdf.RenderPageBanded(W, H, 512, Bands.PrintBand, ExtraRotation,
[reAnnotations, rePrinting]) = 0 then
raise Exception.CreateFmt('Page %d could not be rendered', [Page]);
finally
Bands.Free;
end;
end;
ViewerPreferences 与权限位,宿主该拿它们做什么
在自己的代码里读它们、照它们行事;PDFium 只负责报告,页面照渲染。生成器可以把打印提示存进 catalog 的 /ViewerPreferences 字典(ISO 32000-1 §12.2 表 150),TPdf 把会影响打印对话框的那几个暴露出来:
PrintScaling(Boolean)在文件写着/PrintScaling /None时为 False,意思是按实际尺寸打印;是/AppDefault或压根没有这条时为 True。标准要求即使不弹打印对话框,也得照它执行PrintPaperHandling(TPrintPaperHandling)把/Duplex载成phSimplex、phDuplexFlipShortEdge、phDuplexFlipLongEdge或phUndefined。SetPdfPrintPaperHandlingDevMode把它写进DEVMODE.dmDuplex并置DM_DUPLEX;遇到phUndefined返回 False,记录原样不动,驱动默认值得以保留PrintCopies(Integer)是/NumCopies,要填进对话框的份数
权限位遵循同一条规则。对标准安全处理器,ISO 32000-1 §7.6.3.2(表 22)把 /P 的第 3 位给打印,修订版 3 及以后的处理器再把第 12 位给全保真打印;第 3 位置上、第 12 位为 0 时,打印被限制为外观的低级表示,可能已经降质。TPdf.GrantedPermissions 把掩码解码成 permPrint 和 permPrintHighQuality,HasPermission 测单个值;引擎你请它画什么就画什么,所以执行是宿主的活,通常就是禁掉打印按钮。只给了 permPrint 时,符合标准的读法是走位图路、用适中的分辨率,而不是全面授权才允许的矢量或 PostScript 输出。这些位描述的是“打开时的这份文档”:用所有者密码解开的文件报全权访问,而 UserHasPermission 只回答用户级的那几位
uses
Winapi.Windows, System.SysUtils, Vcl.Dialogs, Vcl.Printers, PDFium;
procedure PreparePrintJob(Pdf: TPdf; Dialog: TPrintDialog;
out ActualSize, HighQuality: Boolean);
var
Device, Driver, Port: array[0..MAX_PATH] of Char;
DevModeHandle: THandle;
DevMode: PDeviceMode;
begin
if not Pdf.HasPermission(permPrint) then
raise Exception.Create('This document does not permit printing');
HighQuality := Pdf.HasPermission(permPrintHighQuality);
ActualSize := not Pdf.PrintScaling; // /PrintScaling /None
Printer.GetPrinter(Device, Driver, Port, DevModeHandle);
if DevModeHandle <> 0 then
begin
DevMode := GlobalLock(DevModeHandle);
try
SetPdfPrintPaperHandlingDevMode(DevMode^, Pdf.PrintPaperHandling);
finally
GlobalUnlock(DevModeHandle);
end;
Printer.SetPrinter(Device, Driver, Port, DevModeHandle); // 对话框从这里起步
end;
Dialog.MinPage := 1;
Dialog.MaxPage := Pdf.PageCount;
Dialog.Options := Dialog.Options + [poPageNums];
if Pdf.PrintCopies > 1 then
Dialog.Copies := Pdf.PrintCopies;
end;
// ActualSize 为 True 时用它代替 FitRect
procedure ActualSizeRect(Pdf: TPdf; out X, Y, W, H: Integer);
begin
// PDF 这边每英寸 72 点,打印机那边每英寸设备像素
W := Round(Pdf.PageWidth / 72 * GetDeviceCaps(Printer.Handle, LOGPIXELSX));
H := Round(Pdf.PageHeight / 72 * GetDeviceCaps(Printer.Handle, LOGPIXELSY));
X := (Printer.PageWidth - W) div 2; // 页面比可打印区域大时为负:
Y := (Printer.PageHeight - H) div 2; // 边缘会被裁掉
end;
把 will-print 与 did-print 动作放在真正的打印边界上
文档级的打印脚本,宿主什么时候跑它们才跑。catalog 的附加动作字典(ISO 32000-1 §12.6.3 表 197)定义了 WP——打印前执行的 JavaScript 动作——和 DP——打印后执行。PDFium Component 把两者通过 ExecuteDocumentLifecycleAction(dlaWillPrint, ErrorMessage) 和 dlaDidPrint 暴露出来,并且把它们挡在渲染调用之外:缩略图、快照和格式转换也在渲染,而在文档脚本眼里,没有一个该像用户的打印动作。顺序得你自己搞对。will-print 脚本要是盖个日期或填一个“打印人”字段,就改了表单值,所以它要在第一页渲染之前跑;走扁平化路时,要在副本保存之前跑;did-print 只在 EndDoc 把作业提交之后才触发。表单环境缺失、或 LifecycleActionPolicy 拒绝时,这个调用返回 False 并带回一条消息;对来路不明的文件,lapDisabled 能把文档动作整个关掉。脚本能力更全的图景在用宿主事件运行 AcroForm JavaScript那篇
在 Windows 上,必须把表单值带上纸的作业该走位图分支:一般分辨率下 RenderPage 画进 TBitmap,600 dpi 及以上用 RenderPageBanded。PDFium Component 的设备上下文重载留给扁平化副本和没有字段的文档,在那里它的矢量输出才是更好的结果