技术文章

打印 PDF 表单值,同时保持页面比例

用户在 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 共用的代码里,请把这个分支写明:文档有表单环境、且字段值需要落到纸上时,在任何平台上都直接要位图

PDFium Component 在 Delphi 里打印表单字段:TPdf.RenderPage 画到打印机 HDC 时调用 FPDF_RenderPage,不含表单层,而 TBitmap 重载在 StretchDIBits 之前多跑一遍 FPDF_FFLDraw
Windows 上只有 RenderPage 的位图重载会跑一遍 FPDF_FFLDraw,所以填好的字段值走那条路能上纸,走设备上下文那条就没了

打印作业该走哪条路

按纸上必须带什么来选。要活的表单值就走位图路,要矢量输出就走 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 的页面,而开着的查看器还指望字段能继续编辑。副本随后走下面打印例程的矢量分支,表单层关掉;作业结束就释放它、删掉临时文件

PDFium Component 在 Delphi 里做扁平化打印副本:SaveAs 到临时文件,带 FormFill 重开,GenerateFormAppearances,FlattenAllPages(FLAT_PRINT),然后用 RenderPage 走设备上下文做矢量打印
在 FlattenAllPages 之前调用 GenerateFormAppearances,烤出来的外观流才会对上当前字段值,而不是上一个生成器留在 /AP 里的东西

把每页装进可打印区域

按两个系数里较小的那个缩一次,然后把结果居中。拉伸到 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 文档的基本循环加上了表单值分支和一条丢弃作业的取消路径

PDFium Component 在 Delphi 打印时的页面适配:Letter 页被拉到 Printer.PageWidth 与 PageHeight,与按保持比例缩放并居中放进可打印区域形成对比,而 /Rotate 已经含在 TPdf.PageWidth 里了
按两个系数里较小的那个缩放,每页都不会变形而且居中,只有查看器额外转了 ro90 或 ro270 时才交换宽高
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 的设备上下文重载留给扁平化副本和没有字段的文档,在那里它的矢量输出才是更好的结果