技术文章

在 Delphi 中使用 losLab PDF 库将 RTF 转换为 PDF

RTF 存在的时间已经足够长,以至于它会出现在没人预料到的地方:遗留的报表生成器、邮件合并管道,以及早于现代文字处理器的法律文档档案。动态地将其转换为 PDF 是一项经常出现的需求,而在 Windows 上实际可行的方法并不是使用专用的 RTF 解析器,而是 Windows 本身已经通过 TRichEditEM_FORMATRANGE 提供的渲染路径。losLab PDF 库的 DLL 版本公开了一个虚拟设备上下文,可以直接插入到该管道中

机制:虚拟 DC 和 EM_FORMATRANGE

Rich Edit(富文本编辑)控件可以针对任何设备上下文对其内容进行分页,而不仅仅是物理打印机。EM_FORMATRANGE 消息指示控件将一系列字符布局到给定的 DC 中,并返回它成功容纳的最后一个字符的位置。重复调用它,每次推进 cpMin,您就可以获得逐页的输出。losLab PDF 库的 GetCanvasDC 提供了一个内存中的 DC,其大小可以根据您指定的任何页面尺寸进行调整;在将页面渲染到其中之后,LoadFromCanvasDc 会将结果捕获为 PDF 页面。这就是整个管道

首先要确保做对的一件事是:必须调整 TRichEdit 控件的大小以匹配目标页面。如果控件小于或大于 DC 尺寸,分页将无法与最终 PDF 中的内容对齐。对于 A4 输出,标准做法是在加载 RTF 文件之前,使用与调整 DC 大小相同的缩放辅助工具,将控件的像素尺寸设置为在 96 DPI 下匹配 210 x 297 毫米

Delphi 实现

以下代码使用了 PDFlibAX_TLB 导入单元,它封装了该库的 DLL 版本。表单托管了一个 TRichEdit 和一个按钮;表单的 OnCreate 处理程序负责调整控件大小并加载 RTF,而按钮点击则驱动转换循环

unit MainUnit;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms,
  Dialogs, StdCtrls, ComCtrls, PDFlibAX_TLB, ActiveX;

type
  TForm1 = class(TForm)
    RichEdit1: TRichEdit;
    Button1: TButton;
    procedure FormCreate(Sender: TObject);
    procedure Button1Click(Sender: TObject);
  private
    function PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
      FirstChar: Integer): Integer;
  end;

var
  Form1: TForm1;
  PdfDoc: TPDFLibrary;

implementation

{$R *.dfm}

procedure TForm1.FormCreate(Sender: TObject);
begin
  PdfDoc := TPDFLibrary.Create(Self);
  // Size the control to A4 at screen DPI so pagination matches the DC
  RichEdit1.Width  := Round(ScaleX(210, mmPixel));
  RichEdit1.Height := Round(ScaleY(297, mmPixel));
  RichEdit1.Lines.LoadFromFile(
    ExtractFilePath(Application.ExeName) + 'document.rtf');
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Dc: HDC;
  PageNumber, LastChar, PdfDocId: Integer;
begin
  PageNumber := 1;
  LastChar   := 0;
  repeat
    // Obtain a virtual DC sized to A4
    Dc := PdfDoc.GetCanvasDC(
      Round(ScaleX(210, mmPixel)),
      Round(ScaleY(297, mmPixel)));
    // Render the next page of RTF content into the DC
    LastChar := PrintRtfBox(Dc, RichEdit1, LastChar);
    // Capture the DC contents as a PDF document
    PdfDoc.LoadFromCanvasDc(96, 0);
    PdfDocId := PdfDoc.SelectedPdfDocument;
    PdfDoc.SaveToFile(
      ExtractFilePath(Application.ExeName)
      + 'Output' + IntToStr(PageNumber) + '.pdf');
    PdfDoc.RemovePdfDocument(PdfDocId);
    Inc(PageNumber);
  until LastChar = 0;
end;

function TForm1.PrintRtfBox(hDc: HDC; rtfBox: TRichEdit;
  FirstChar: Integer): Integer;
var
  RcDrawTo, RcPage: TRect;
  Fr: TFormatRange;
  NextCharPosition: Integer;
begin
  RcPage.Left   := 0;
  RcPage.Top    := 0;
  RcPage.Right  := rtfBox.Left + rtfBox.Width  + 100;
  RcPage.Bottom := rtfBox.Top  + rtfBox.Height + 100;

  RcDrawTo.Left   := rtfBox.Left;
  RcDrawTo.Top    := rtfBox.Top;
  RcDrawTo.Right  := rtfBox.Left + rtfBox.Width;
  RcDrawTo.Bottom := rtfBox.Top  + rtfBox.Height;

  Fr.hdc         := hDc;
  Fr.hdcTarget   := hDc;
  Fr.rc          := RcDrawTo;
  Fr.rcPage      := RcPage;
  Fr.chrg.cpMin  := FirstChar;
  Fr.chrg.cpMax  := -1;

  NextCharPosition :=
    SendMessage(rtfBox.Handle, EM_FORMATRANGE, 1, LPARAM(@Fr));
  if NextCharPosition < Length(rtfBox.Text) then
    Result := NextCharPosition
  else
    Result := 0;  // signals last page
end;

end.

循环在做什么

PrintRtfBox 填充 TFormatRange 结构并通过 SendMessage 将其传递给 Rich Edit 控件。控件从 cpMin 开始渲染字符,当 DC 填满时停止,并返回第一个未容纳的字符的位置。当返回值等于或超过总文本长度时,说明每个字符都已经渲染完毕,该函数返回零,从而终止 repeat...until 循环

每次迭代都会生成一个 PDF 文件,命名为 Output1.pdfOutput2.pdf 等等。如果您希望得到一个单一的多页文档,该库的页面追加 API 允许您在事后将它们组合起来,或者您可以重构循环,在单个文档会话中调用 AddPage。上面采用的每次迭代 SaveToFile 然后 RemovePdfDocument 的模式,将峰值内存限制在了一页内容的大小内,这对于非常长的 RTF 文件来说很重要

容易让人绊倒的尺寸细节

传递给 LoadFromCanvasDc 的 96 DPI 参数告诉库 DC 是在什么屏幕分辨率下渲染的,这样它就可以为 PDF 页面计算出正确的点到像素映射。如果这个参数弄错了,那么在输出中即使图像在屏幕上看起来是正确的,文本也会以错误的大小显示

添加到 RcPage.RightRcPage.Bottom+100 是超出控件可见边缘的一个小边距。Rich Edit 使用 rcPage 矩形来决定在哪里分割页面;如果没有这个边距,正好落在边界上的一行可能会在两页中重复出现。它并不是一个魔法常量:您只需要让它足够大,以确保页面边界干净地落在控件的布局区域内,而不是落在最后一个像素上即可

最后,在 FormCreate 运行时,控件必须已经附加到一个可见的表单窗口,这样在第一次调用 SendMessage 之前其窗口句柄才是有效的。如果表单尚未显示,在运行时动态创建的 TRichEdit 在渲染循环开始之前需要显式调用 HandleNeeded

处理字体和 RTF 特性

因为渲染是由 Windows Rich Edit 引擎完成的,所以字体替换遵循其用于显示和打印的相同规则。RTF 文件中引用的并在机器上安装的字体将被忠实地渲染;缺失的字体将被静默替换,这可能会改变行长和分页。对于生产环境的批量转换,这一点值得显式测试:加载使用您的 RTF 来源中每种字体的文档,并确认输出的页数与您在手动打印预览中预期的相匹配

表格、嵌入图像和大多数富文本格式特性无需任何额外处理即可工作,因为 Rich Edit 会原生渲染它们。唯一可能令人惊讶的领域是使用以 twips 表示的自定义段落间距或首行缩进的文本:Rich Edit 的内部坐标系是 twips(1/1440 英寸),而您在 TFormatRange 中设置的 DC 坐标是当前 DPI 下的像素。该控件在内部进行转换,但如果您以编程方式构造 RTF,则应验证您的边距值使用的单位是否正确

DPI 感知和高 DPI 显示器

在以 150% 缩放(144 DPI)运行的显示器上,ScaleX(210, mmPixel) 将返回比在 100% 显示器上更大的像素数。PDF 库记录您传递给 GetCanvasDC 的任何像素尺寸,并使用 LoadFromCanvasDc 中的 DPI 参数来反向计算 PDF 中的物理页面大小。只要您传递的 DPI 值与您的应用程序运行时的 DPI 相匹配,无论显示缩放比例如何,输出的页面大小都将是正确的

如果您的应用程序不具有 DPI 感知能力(旧的默认设置),Windows 会缩放屏幕 DC,而您的像素计算在高 DPI 机器上将会出错。最简单的修复方法是在应用程序清单中声明 DPI 感知;然后应用程序会接收真实的设备像素,并且您传递给 LoadFromCanvasDc 的 96 应该被替换为从 GetDeviceCaps(GetDC(0), LOGPIXELSX) 获得的实际显示 DPI。上面的代码示例硬编码了 96,因为它适用于 100% 的缩放环境,并使示例保持简短

输出结构:每页一个文件还是组合文档

上面的循环将每一页写到一个单独的 PDF 文件中。这是否符合您的需求取决于下游的使用场景。报表生成系统通常需要单独的页面,因为它们稍后通过合并或重新排序页面来组装最终的文档。如果您从一开始就想要一个单一的 PDF,该库允许您在单个会话中创建包含多页的文档:在循环外部创建一次文档,在循环内部调用添加页面的方法而不是 SaveToFile,并在循环退出后保存完整的文档。这避免了中间文件,是大多数单文档转换场景的正确结构

对于大型 RTF 文件,值得在循环中添加一些进度反馈,因为转换速率大致与页数成正比,一个 200 页的文档可能需要几秒钟。repeat...until 结构很容易扩展:在每次迭代后跟踪进度条更新中的字符偏移量,使用 LastChar 除以从 RichEdit1.GetTextLen 获取的总字符数

这里显示的 GetCanvasDCLoadFromCanvasDc 方法是适用于 Delphi 和 C++Builder 的 losLab PDF 库 的一部分