技术文章

使用 HotPDF 图元在 PDF 中绘制图表

HotPDF 没有图表对象。没有 TPDFChart,没有 AddBarSeries,也没有任何可以接收数字数组并返回渲染图形的东西。相反,它为您提供了一个页面画布,其中包含每个 PDF 绘制模型所使用的相同的底层词汇:矩形、线条、圆形、填充、描边以及放置在精确坐标处的文本。因此,HotPDF 文档中的图表是您构建出来的,而不是请求得来的。这听起来似乎工作量很大,但实际并非如此。一旦您编写了一次坐标计算,条形图就成了对矩形的循环,折线图成了一条多段线,饼图成了一扇圆弧,并且您可以控制结果的每一个像素

这一点很重要,因为人们首先想到的替代方案是,将屏幕图表控件光栅化为位图并将图像粘贴到页面中,这样得到的是锁定在屏幕分辨率的图表,打印出来会很模糊并且会使文件变得臃肿。使用 HotPDF 的矢量图元绘制图表,可以在任何缩放级别和任何打印 DPI 下保持输出清晰,因为条形和轴是真正的 PDF 路径运算符,而不是像素。代价是您必须自己控制布局。具体机制归结为几个步骤:一个会让所有人困惑的坐标翻转,一个成型的条形图,折线图的多段线技巧,以及饼图切片的圆弧数学计算

唯一困难的部分是坐标系

屏幕图形将原点放在左上角,Y 轴向下增长。PDF 则恰恰相反。原点位于页面的左下角,Y 轴向上增长,单位为磅(1/72 英寸)。HotPDF 中的每一个绘制调用,如 TextOut、Rectangle、MoveTo、LineTo、Circle,都使用这种左下角、Y 轴向上的约定。如果您将屏幕图形的直觉带过来,您的第一个图表将会倒过来画,并且会跑出页面的底部

因此,任何图表中真正的工作就是一种映射:将数据值转换为遵循 Y 轴向上的 Y 坐标。确定一个绘图矩形,即图表所在区域的四个数字,然后将数据中的最小值映射到底边缘,最大值映射到顶边缘。对于在 0 到 MaxValue 刻度上值为 V 的条形,该条形的上边缘为 PlotBottom + (V / MaxValue) * PlotHeight,并且条形从 PlotBottom 向上生长。弄对了这一个表达式,其余的一切就只是记账式的工作。下面的辅助方法保存了绘图的几何信息并执行转换,这样绘制代码就再也不用接触原始算术运算了:

HotPDF:屏幕坐标原点在左上、Y 向下增长,而 PDF 坐标原点在左下、Y 向上增长;ValueToY 把数据值映射到该 Y 向上空间
PDF 颠倒了熟悉的屏幕坐标系:原点在左下、Y 向上增长,一个 ValueToY 表达式即可把每个数据值映射进该空间
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF 磅值,原点在左下角
    MaxValue: Single;                     // 数值刻度的顶端
  end;

// 把数据值映射为绘图区域内的 Y 坐标,Y 向上增长。
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

在 MaxValue 中隐藏着一个判断调用。如果您将其设置为精确的最大数据点,最高的条形将接触到绘图区域的上边缘,看起来像是被剪裁了。将其向上舍入为大于最大值的一个整洁的数字,例如 10 或 100 的下一个倍数,这样图表就有了头部空间,并且网格线标签会读作整数,而不是数据碰巧达到的峰值

条形图是对矩形的循环

一旦映射确定,条形图就自然而然地生成了。将绘图宽度划分为每个类别一个槽位,在条形之间留出间隙以免它们相互接触,然后将每个条形绘制为填充矩形,其高度来自 ValueToY。HotPDF 的 Rectangle 采用左下角坐标加上宽度和高度,这完全符合从基线向上生长的条形。先设置填充颜色,铺设路径,然后调用 Fill 进行绘制。类别标签放在基线下方,数值放在条形上方:

HotPDF:条形图解剖图:按绘图区宽度划分槽位并在条间留隙,每根条是从 Plot.Bottom 向上生长的填充 Rectangle,类别标签位于基线下方,数值标签随条高,轴刻度复用 ValueToY
每根柱是在其槽位内从 Plot.Bottom 向上生长的一个实心 Rectangle,四分之一槽宽的间隔随宽度缩放,坐标轴刻度复用 ValueToY,柱体与网格线因此对齐
procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // 每侧留出四分之一槽位的间隙
  BarW := SlotW - Gap;

  // 基线(X 轴)沿绘图区域底部绘制。
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarH := ValueToY(Plot, Values[I]) - Plot.Bottom;

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Width, Height
    Page.Fill;

    // 类别标签放在基线下方,数值放在条形上方。
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

有两个细节发挥了作用。Gap 是槽位的一部分,而不是固定的点数,因此无论您绘制四个类别还是四十个类别,条形都保持成比例的间距。而数值标签的定位使用了与条形相同的由 ValueToY 导出的高度,因此它始终刚好位于其自身的条形上方,而不是漂浮在猜测的偏移量处。如果您希望在条形后面有水平网格线,请在循环之前绘制它们:挑选三到四个整数值,对每个值运行 ValueToY,并在该 Y 坐标处横跨绘图区域绘制一条淡淡的线条。PDF 使用的画家模型堆叠方式中,首先绘制它们即可将它们置于条形后面

轴、刻度和标签不过是更多的线条和文本

直到读者能够分辨出条形的含义,图表才算完成,而这完全是轴的工作。垂直轴是沿着绘图区域左边缘向上绘制的一条实线,带有少许刻度标记及其数值。重用 ValueToY 以便刻度落在条形使用的相同比例上,否则条形与其网格线就会不一致,图表就会悄无声息地说谎:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
begin
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left, Plot.Bottom + Plot.Height);
  Page.Stroke;

  Page.SetFont('Arial', [], 8);
  for I := 0 to TickCount do
  begin
    TickV := (Plot.MaxValue / TickCount) * I;
    TickY := ValueToY(Plot, TickV);
    Page.MoveTo(Plot.Left - 4, TickY);   // 轴外侧的短刻度线
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

标签是图表在生产环境中最常出问题的地方,且失败原因始终一致:在屏幕上合适的文本在 PDF 中却超出了其空间。长类别名称会与其相邻的名称碰撞,像“septembre”或“Dezember”这样本地化的月份名称也比您测试用的英文“Sep”要宽。这里没有自动调整大小来拯救您,所以在基线下方留出真实的边距,对于密集的类别集将字体缩小一两磅,如果名称真的很长,就把它们旋转一下。TextOut 将角度作为其第三个参数,所以传递 90 会让标签竖立起来,从而在不重叠的情况下为您争取空间。在导出功能发布之前,请使用您预期最宽的标签而不是最短的标签来测试布局

折线图:穿过映射点的一条多段线

折线图重用了整个数值映射,仅仅改变了点的连接方式。不需要每个类别一个矩形,您只需遍历一次数据,使用 ValueToY 将每个数值转换为其 (X, Y) 坐标,然后用一个 MoveTo 后跟一系列 LineTo 调用将这些点缝合在一起,最后进行描边。第一个点开启路径;后面的每一个点都会延伸它:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // 在第一个点处打开路径
    else
      Page.LineTo(X, Y);       // 后续每个点都延伸这条路径
  end;
  Page.Stroke;                 // 一次 Stroke 就能画出整条折线
end;

注意间距的区别。条形图将宽度除以条形的数量,因为每个条形拥有一个槽位。折线图则除以间隔的数量,即 Count - 1,因为第一个点和最后一个点位于绘图区域边缘,折线跨越了它们之间的间隙。混淆这两者通常是折线图偏离其本来应覆盖的条形图半个槽位的原因。如果您想在每个数据点上加一个标记,请在多段线描边后,在每个 (X, Y) 处放置一个小 Circle 并 Fill

饼图:圆弧,或者如果保持简单则是楔形

饼图切片是唯一需要三角函数的形状,因为楔形是由两条半径和一段圆弧围成的。诚实的做法是沿着圆周步进微小的线段来扫过圆弧,这足以逼近曲线,没有读者能看出区别。每个切片的扫掠角度是其在总数中的份额,即 (Value / Total) * 2π,并且您在绕圈时累加运行角度:

HotPDF:扇形构造:从圆心的 MoveTo 开始,沿每个扇形扫过角的弧线以约两度间隔逐步画出小的 LineTo 段,再闭合回圆心,使 Fill 填充实心楔形
扇形从圆心 MoveTo 开始,沿弧段以约两度间隔走小步 LineTo,最后闭合,Fill 即可将其涂实
procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // 每段大约 2 度

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // 楔形顶点位于圆心
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // 闭合回到圆心
    Page.Fill;

    Start := Start + Sweep;                   // 推进到下一个切片
  end;
end;

由中心向外的路径,首先是顶点,然后是圆弧,再回到顶点,形成了一个闭合的楔形,Fill 可以将其涂成实心。线段数量用平滑度来换取路径大小:每步大约两度在任何合理的半径下看起来都是圆的,而不会产生庞大的内容流。如果您不需要一个正圆,您可以完全跳过三角函数,将相同的数据渲染为单个水平堆叠条形,每个片段的宽度与其份额成正比。反正这通常比饼图更易读,而且它只是将矩形首尾相连的条形图代码。只有当设计明确要求一个实心圆时,才需要使用圆弧版本

这一切都不依赖于安装任何图表库,这也是直接绘制图元的隐性优势。用于放置标志或签名框的相同的画布绘制调用同样能构建这些图表,为表单字段添加标签的同一个 TextOut 也可以用来标记坐标轴。将绘图的几何信息放入一条记录中,只需映射一次 Y 坐标值,条形图、折线图或饼图就成了一个包含 Rectangle、LineTo 和 Circle 的简短例程,您可以将其放入任何报告中。这里使用的 Rectangle、MoveTo、LineTo、Circle、Fill、Stroke 和 TextOut 调用都是适用于 Delphi 和 C++Builder 的 HotPDF 组件的一部分