技术文章

在 Delphi 中使用 PDFium 组件打印 PDF 文档

PDF 的坐标是用磅(points)来量的,而打印机的坐标走的是设备单位,这俩家生来就老死不相往来,除非你亲自上手给它们牵线搭桥。很多 Delphi 程序打出来的文件要么被砍了一半,要么被拉成畸形,要么干脆吐张白纸出来,病根全在这儿:文件虽然喂对了,但坐标系乱成了一锅粥。PDFium 组件只负责把渲染的活儿给干得漂漂亮亮;至于怎么跟打印机打交道,那是 VCL 标准的那套老把式。只要你摸清了这俩家各自的脾气,其实也就是几行代码的功夫,就能把它们给撮合在一起

先渲染再打印这套流水线是怎么转的

PDFium 组件可没那个本事直接去敲打印机的门。正确的套路是:先按你想要的分辨率,把页面结结实实地渲染成一张 TBitmap 位图,然后拿着 StretchDIBits 这把刷子,把这张位图生生给糊到打印机的画布(canvas)上去。TPdf.RenderPage 吐出来的是个全凭你处置的位图,所以这图要多少个像素,全凭你一句话。要紧的是,你得在选项里塞个 [rePrinting] 进去,PDFium 一看这个牌子,就会立马换上另一套专门用来打印的渲染路子:它会把那些只适合屏幕的特效(比如 LCD 子像素微调)一脚踢开,并且老老实实地按页面的 MediaBox(媒体框)规矩来出图。你要是忘了加 rePrinting,那你就是硬把一张为屏幕量身定做的图塞给了打印机;在显示器上看是挺美,可一旦到了高 DPI 的打印机上,原本为了 96 DPI 屏幕精雕细琢的微调,在 300 甚至 600 DPI 的纸上绝对糊成一团浆糊

在你打算碰任何页面属性之前,TPdf.Active 是你唯一必须去验的门禁。这破组件把所有加载失败的丑事全捂在肚子里了:你要是拿着个破损的文件,或者是密码不对的文件去强推 Active := True,它连半个异常(exception)都不带报的,只会默默地把 Active 挂在 False 上。所以,赋完值之后必须亲自去查岗。你要是敢对着一个根本没激活的文档去问 PageCount 或者 PageWidth,它全给你报个 0 出去。这这种不声不响的 0,一旦被送进打印机后台池(spooler),等你想去查为啥打出来是张白纸的时候,绝对能把你的头都给抓秃

最精简的打印循环怎么写

最老实、没那么多花花肠子的搞法就是:开文件、开打印任务、一页一页翻、关文件完事。里头唯一藏着刺的地方,就是打头一页之前,绝对不能手贱去喊 Printer.NewPage,这就是为啥得弄个 FirstPage 标志位在那儿守着。至于 StretchDIBits 这道工序,就是顺着 GetDIBSizesGetDIB 这套老规矩,从位图句柄里把那些个设备无关的像素渣渣全给抠出来,最后一把刷满整个打印机的画布:

procedure PrintPdfFile(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Bitmap: TBitmap;
  InfoHeaderSize, ImageSize: DWORD;
  InfoHeader: PBitmapInfo;
  Image: Pointer;
  FirstPage: Boolean;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    if not Pdf.Active then
      Exit;  // load failed silently; bail out

    Printer.Title := Pdf.Title;
    Printer.BeginDoc;
    try
      FirstPage := True;
      for I := 1 to Pdf.PageCount do
      begin
        if FirstPage then
          FirstPage := False
        else
          Printer.NewPage;

        Pdf.PageNumber := I;

        // Render at printer resolution; rePrinting adjusts the render path
        Bitmap := Pdf.RenderPage(
          0, 0,
          Printer.PageWidth,
          Printer.PageHeight,
          ro0,
          [rePrinting]
        );
        try
          GetDIBSizes(Bitmap.Handle, InfoHeaderSize, ImageSize);
          InfoHeader := AllocMem(InfoHeaderSize);
          try
            Image := AllocMem(ImageSize);
            try
              GetDIB(Bitmap.Handle, 0, InfoHeader^, Image^);
              StretchDIBits(
                Printer.Canvas.Handle,
                0, 0, Printer.PageWidth, Printer.PageHeight,
                0, 0, Bitmap.Width, Bitmap.Height,
                Image, InfoHeader^, DIB_RGB_COLORS, SRCCOPY
              );
            finally
              FreeMem(Image);
            end;
          finally
            FreeMem(InfoHeader);
          end;
        finally
          Bitmap.Free;
        end;
      end;
    finally
      Printer.EndDoc;
    end;
  finally
    Pdf.Active := False;
    Pdf.Free;
  end;
end;

Printer.PageWidthPrinter.PageHeight 直接塞给位图当长宽,等于你就是照着打印机本身的像素尺寸在出图,连设备 DPI 这笔烂账都帮你算清了。后头的 StretchDIBits 也就顺理成章地把这堆像素一比一地糊在纸上。只要 PDF 页面的尺寸跟打印纸正好是个天作之合,这法子出来的质量绝对是天花板级别的,连算盘都不用你打。可要是它俩根本就不是一个模子刻出来的,那你可就得亲自上阵算缩放了

要是页面跟纸大小对不上眼怎么办

你拿张 A4 竖版的 PDF,绝对不可能严丝合缝地塞进美式信纸(US Letter)的打印机里;要是你硬把一张横版的页面塞给只认竖版的打印机,那绝对被砍得连妈都不认识。江湖上最稳的套路是:拿打印机像素和 PDF 磅数去打个商量,算出个不偏不倚的缩放比例(scale factor),然后在长宽两边一块儿下手,这样图才不至于被拉变形。Pdf.PageWidthPdf.PageHeight 报出来的尺寸是磅(1 磅等于 1/72 英寸)。拿这数乘上你想要的 DPI 再除以 72,就能换成那一档 DPI 下的像素数。最后在 X 轴和 Y 轴的比例里挑个小的(Min),这就是能把整页安安稳稳塞进打印范围里的最大尺寸了:

// Fit PDF page to printable area, preserving aspect ratio
var
  ScaleX, ScaleY, Scale: Double;
  DestWidth, DestHeight: Integer;
  Dpi: Integer;
begin
  Dpi := 300;  // target render resolution
  Pdf.PageNumber := PageIndex;

  ScaleX := Printer.PageWidth  / (Pdf.PageWidth  * Dpi / 72);
  ScaleY := Printer.PageHeight / (Pdf.PageHeight * Dpi / 72);
  Scale  := Min(ScaleX, ScaleY);

  // Clamp to 1.0 for shrink-to-fit only (no enlargement)
  if Scale > 1.0 then Scale := 1.0;

  DestWidth  := Round(Pdf.PageWidth  * Dpi / 72 * Scale);
  DestHeight := Round(Pdf.PageHeight * Dpi / 72 * Scale);

  Bitmap := Pdf.RenderPage(0, 0, DestWidth, DestHeight, ro0,
    [rePrinting, reAnnotations]);
  // ... transfer with StretchDIBits as above
end;

Dpi = 300 去对付办公室里的那些打印机,绰绰有余。要是你脑子一热非要上 600 DPI,光是一张 A4 纸大的位图,里头就得塞进差不多 3400 万个像素;弄成个 32 位位图的话,一张图就得吃掉差不多 100 兆内存。对付那些全是字的普通公文,那点质量提升塞牙缝都不够,但内存这笔账可是实打实的要命。除非你要拿去印刷厂,或者是那种满篇全是矢量图的工程图纸,否则快把 600 DPI 这张大牌收起来吧

后边那段代码里冒出来的 reAnnotations 标志位,跟 rePrinting 俩人各走各的道。要是你的用户嚷嚷着非得把图章、高亮还有那些批注框也一起印出来,你就加上它。要是只想印个光秃秃的正文,那就一脚把它踢开。这俩标志位你随便凑一块儿,它们不打架

对付旋转的烂摊子

PDFium 是靠着 PDF 肚子里那个 /Rotate 的标签来记页面是怎么转圈的,你只要去摸一把 Pdf.PageRotation,就能拿回个 TRotationro0, ro90, ro180, ro270)。可坑爹的是,打印机的坐标系脾气怪得很,它那头的 90 度和 270 度,正好跟屏幕上看到的转圈方向是反的。你要是憨憨地拿着 PageRotation 直接塞给 RenderPage,连根毛都不改,那恭喜你,绝大部分的 Windows 打印驱动绝对会把你那个竖版文档里夹杂的横版页面,结结实实地给你倒印出来。治这毛病的偏方简单粗暴:在去叫 RenderPage 之前,搞个偷梁换柱——把 ro90 换成 ro270,把 ro270 换回 ro90,至于 ro0ro180 这俩大爷,就让它们老实呆着别动

在把程序交差之前,你必须得在你那台倒霉打印机上亲自把这事给验了。这帮子打印驱动厂商在旋转这事上压根就没个统一的规矩,有些驱动在 GDI 那层自己就手贱把旋转给修正了。要是你发现打出来的东西居然转了两次圈(倒了两次),那就赶紧把上面那个掉包的把戏给撤了;要是它根本不转,那就加上。想最快揪出这种破烂烂摊子,就搞个一会儿横一会儿竖、交错混编的文档去打一遍,是人是鬼立马现出原形

跑大活儿时怎么看好你的内存

你每去喊一次 RenderPage,它都会去给你圈一块新地皮盖个 TBitmap,而且全权交给你负责收尸(Free)。上面那个例子里的 try/finally Bitmap.Free,就是为了保住单页循环时的命。这图你拿着用完了就赶紧宰了,千万别想着攒一堆:光是一个 200 页的文档,要是全拿 300 DPI 渲染,第一张纸还没走到打印后台呢,你那好几个 G 的内存就全被这帮位图给吞光了

那个把数据倒给打印机的 AllocMemFreeMem 对子,也是一模一样的铁律。GetDIBSizes 告诉你这 DIB 的头脸和像素到底要吃多少内存;你就在这一页的功夫里,一口气干完圈地、倒数据、刷墙,然后立马把地给退了。这两个地方但凡有一个漏了水,碰上个几十页的活儿,这打印任务就能把你程序的堆(heap)给榨个精光,当场咽气

要是你想把这打印活儿踢到后台线程上去跑,那你必须得死守一条底线:把 TPdf 连同那些 VCL 调打印机的破代码,全死死地按在同一个线程里。TPdf 本身可不是啥线程安全的铁板一块,那帮子跨实例的家伙全在那儿共用 PDFium DLL 肚子里的全局状态;最保命的套路就是:一个线程配一个专门的 TPdf,每个都老老实实去开自己那份独门的文件副本

这篇用来显摆的渲染和文档 API,全都是给 Delphi 和 C++Builder 备的那个 PDFium 组件 的亲骨肉