Technical Article

使用 HotPDF 在 Delphi 中将数据表渲染为 PDF

数据集是行和列;PDF 页面是一个空白的坐标网格,没有行和列的概念。跨越这种差距正是这里全部的工作。在 HotPDF 中没有能够接收数据集并为你提供格式化网格的 DrawTable 调用。相反,你得到的是构成网格的图元:用 TextOut 在一个点上放置字符串,用 SetFont 选择它的字体,用 RectangleFill 来为带状区域添加阴影,以及用 MoveTo / LineTo / Stroke 来绘制规则线条。一个实用的表格导出器,就是将行和列的思维转化为显式的 x 和 y 坐标的纪律,然后当数据运行超过页面底部时,保持这些坐标真实准确

接下来的示例报告了客户记录,但绘制代码中的任何内容都不需要知道也不关心这些行从何而来。原示例使用的是传统的 TTable;一个 FireDAC 查询、一个内存数据集,或者一个纯粹的记录数组同样可以直接输入这些未作改动的例程。关键在于,你可以一次一行地遍历数据,并从每一行中读出四个字符串字段。保持渲染与数据源分离,你就可以更改其中任何一方而不会干扰另一方

列的几何形状放在首位

在绘制任何一个字符之前,先决定每列的位置。这里的表格有四列,因此它需要四个左边缘和一个已知的右边距。像那些快餐式示例那样,在每一次 TextOut 调用中硬编码一个魔法数字,正是日后使得加宽表格变得痛苦不堪的原因。将边缘命名一次(以从左下角原点算起的磅值为单位),以后的每一次绘制调用就都通过名字来引用它们:

const
  ColNo   = 70;    // left edge of the "No." column
  ColName = 110;   // company name
  ColAddr = 300;   // street address
  ColCity = 480;   // city
  RowLeft = 50;    // table frame: left rule
  RowRight = 570;  // table frame: right rule
  RowStep = 20;    // vertical distance between baselines

procedure PrintRow(Page: THPDFPage; Y: Single;
  const ANo, AName, AAddr, ACity: string; Shaded: boolean);
begin
  if Shaded then
  begin
    // A shaded band behind the row. Rectangle takes X, Y, Width, Height.
    Page.SetRGBFillColor($00FFF3DD);
    Page.Rectangle(RowLeft, Y - 4, RowRight - RowLeft, RowStep);
    Page.Fill;
    Page.SetRGBFillColor(clBlack);
  end;
  Page.TextOut(ColNo,   Y, 0, ANo);
  Page.TextOut(ColName, Y, 0, AName);
  Page.TextOut(ColAddr, Y, 0, AAddr);
  Page.TextOut(ColCity, Y, 0, ACity);
end;

这里的两个细节发挥了作用。首先绘制阴影带,然后再将文本放置在上面,因为 PDF 中的绘制顺序即 Z 轴顺序:如果在文本之后填充矩形,就会把整行掩盖掉。而且交替的阴影并不是纯粹的装饰。在密集的报告中,它是防止视线滑到错误行上的成本最低的方法,这就是为什么循环随后会在每一行翻转一个布尔值,并直接将其传递给 Shaded 的原因

上面的列位置是固定的,这对于你能够控制架构的报表来说是坦诚的做法。当数据可变时,应去测量而不是猜测。HotPDF 在页面对象上暴露了文本宽度测量功能,因此生产版本的 PrintRow 可以获取每列中最长的预期值,在选定的字体大小下测量一次,并从这些宽度加上一个间距来推导左边缘。例程的形状没有改变;只是常量的来源改变了

表头、标尺及其所有者

如果一个表格滚出了页面并在下一页继续却没有列标签,它是无法阅读的。解决办法是将表头视为你需要重新绘制的东西,而不是只画一次的东西。把列标题和框住它们的水平标尺放在一个单独的例程中,不仅在开始时调用该例程,并且每次打开新页时再调用一次。因为表头和正文共享相同的列常量,所以它们在结构上就天然对齐了

procedure DrawHeader(Page: THPDFPage; var Y: Single; PageNo: Integer);
begin
  // Left: source label and page number. Right: generation time.
  Page.SetFont('Arial', [fsItalic], 10);
  Page.TextOut(RowLeft, Y, 0, 'customer.db   Page ' + IntToStr(PageNo));
  Page.TextOut(ColCity, Y, 0, DateTimeToStr(Now));

  // Two horizontal rules that box the column titles.
  Page.MoveTo(RowLeft, Y + 15);
  Page.LineTo(RowRight, Y + 15);
  Page.MoveTo(RowLeft, Y + 45);
  Page.LineTo(RowRight, Y + 45);
  Page.Stroke;

  // The column titles, in a heavier face so they read as headings.
  Page.SetFont('Times New Roman', [fsBold], 12);
  Page.SetRGBFillColor(clNavy);
  PrintRow(Page, Y + 25, 'No.', 'Company', 'Address', 'City', False);
  Page.SetRGBFillColor(clBlack);

  Y := Y + RowStep + 45;  // advance past the boxed header before the first body row
end;

请注意,DrawHeader 通过引用接收 Y 并将其向前移动。调用者永远不必去记表头有多高;绘制它的例程才是知道答案的例程。这个单一所有权规则确保了当你后来在表头带中添加标识或过滤摘要时,布局不会发生偏移。主体循环对此毫不知情。它只需从 Y 当前指向的任何位置继续绘制行即可

线条本身就是列表和表格之间的区别。垂直列分隔符是应用于 x 轴的相同理念:在每个列边缘使用 MoveTo / LineTo / Stroke,从顶部线条一直运行到页面上最后一行的底部。本示例只保留水平线以保持可读性,但是一旦列常量存在,在生产环境中的操作就很机械化了

光标循环控制分页

绘制是简单的一半。将玩具与真正的报表区分开来的另一半是分页:在绘制一行之前,知道它是否还能放得下,如果放不下,就从带新表头的新一页开始。这个决定只属于一个地方,即遍历数据的循环中,而不在其他任何地方

var
  Pdf: THotPDF;
  Page: THPDFPage;
  Y: Single;
  PageNo: Integer;
  Shaded: boolean;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'CustomerReport.pdf';
    Pdf.BeginDoc;
    Page := Pdf.CurrentPage;

    // Report title, once, at the top of the first page.
    Page.SetFont('Arial', [fsBold], 24);
    Page.TextOut(200, 800, 0, 'Customer Report');

    PageNo := 1;
    Y := 760;
    DrawHeader(Page, Y, PageNo);
    Shaded := False;

    CustomerTable.First;
    while not CustomerTable.Eof do
    begin
      // Out of room? Open a new page and repeat the header there.
      if Y < 60 then
      begin
        Pdf.AddPage;
        Page := Pdf.CurrentPage;   // AddPage moves CurrentPage forward
        Inc(PageNo);
        Y := 760;
        DrawHeader(Page, Y, PageNo);
      end;

      Shaded := not Shaded;
      Page.SetFont('Arial', [], 10);   // SetFont must be reissued on every new page
      PrintRow(Page, Y,
        VarToStr(CustomerTable['CustNo']),
        VarToStr(CustomerTable['Company']),
        VarToStr(CustomerTable['Addr1']),
        VarToStr(CustomerTable['City']),
        Shaded);

      Y := Y - RowStep;
      CustomerTable.Next;
    end;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

有两个坐标事实驱动着整个循环。PDF 测量 y 轴是从左下角向上测量的,所以各行是在页面上向行进的,方法是每次从 Y 中减去 RowStep,而且“页面是否已满”的测试触发条件是 Y 降至底边距以下,而不是超出某个顶端。如果方向搞反了,你的第一行就会打印在底边外,而循环还以为它有整整一页的空间

另一个事实几乎让每个人都吃过亏。AddPage 创建了一个新页面,并将 CurrentPage 重新指向它,但它没有带入任何东西:没有字体,没有填充颜色,没有位置。这就是为什么在每次 AddPage 之后都要从 CurrentPage 重新读取 Page,以及为什么要在正文各行之前重新发出 SetFont 指令的原因。如果跳过重新读取,你就会继续在刚刚留下的页面上绘制;如果跳过字体,新页面将以阅读器所退回的任何默认形式进行渲染

破坏表格导出器的那些情况

大多数表格错误都不会在几十行整齐排列的理想情况中出现。它们隐藏在边缘情况中,而一旦你知道它们在哪里,这些边缘就很容易测试了

  • 空数据集。对零行数据的循环会产生一个带有表头而下方却什么都没有的页面,这至少看起来像是故意如此的。而一个既没有表头又空白的页面则像是一个失败的产物。在发布之前,请决定你想要哪种结果
  • 刚好落在边界上的行。生成一个报告,其最后一行刚好位于边距之上一步,然后再生成一个其下一行位于边距之下一步的报告。差一错误(Off-by-one)的分页问题会一直隐藏,直到数据长度恰好处于错误的范围内
  • 超长值。如果公司名称的宽度超过了它的列宽,它就会延伸到下一列中。去测量这个字段,然后决定一项策略:换行到第二行显示,裁剪它,或者是用省略号截断。放任不理可不是什么策略
  • 空字段(Null fields)。直接将 null 读入 TextOut 中,可能会显示为字面文本 Null 或者显示为空白,这取决于你如何去转换它。要刻意去选择渲染方式,而不是让变体转换替你做选择

在你宣告完成之前,请用多个阅读器运行查看结果。字体替换和裁剪在不同的渲染器中的表现是不一样的,在一个 PDF 阅读器里看起来方方正正的表格,在另一个里可能就会出现列未对齐或者城市名称被剪裁的情况。确认重复的表头、行阴影和边距在转换后依然存在,并且确认数据跨越边界后页码保持连续

自己绘制网格而不是依赖于可视化的报表设计器,这意味着需要编写更多的代码,这种权衡值得清楚地说明:你掌握着每一个坐标,这正是你在处理服务器端批处理作业、发票和审计导出时所希望的,因为这些文件必须在每台机器上渲染得一模一样,而这正是你在处理一次性内部清单时宁愿避免的开销。对于前者而言,这种控制权在报表首次需要确保在生产环境中与你办公桌上的显示效果完全一致时,就已经值回票价了

如果你想首先单独了解 RectangleMoveToLineTo 的调用,上面的标尺和阴影带依赖于画布绘制演示中涵盖的相同的矢量和颜色原语。此处使用的绘制图元是适用于 Delphi 和 C++Builder 的 HotPDF 组件的一部分