HotPDF 通过在当前页面构建路径,然后再请求把它绘制出来,从而生成矢量图形,中间不存在任何位图步骤。你用 MoveTo 和 LineTo 画出的线,最终会成为内容流里的 PDF 路径操作符,因此它会一直保持真正的矢量特性:50% 缩放时清晰,1600% 缩放时仍然清晰,而体积却只是光栅化版本的一小部分。对于示意图、表格线、图表坐标轴和表单装饰,这正是你想要的效果,而且背后的 API 足够小,一次坐下来就能学会
整个绘图表面都挂在 THotPDF.CurrentPage 上。介于 BeginDoc 和 EndDoc 之间,你会在这个页面对象上设置颜色和线宽,铺设几何形状,再调用一个绘制操作符把它提交出去。最常用的四类基础操作,是用于任意路径的 MoveTo 和 LineTo、用于矩形框的 Rectangle、用于圆形的 Circle,以及两个绘制操作符 Stroke 和 Fill
坐标系原点位于左下角
这是所有从 VCL 转过来的人最容易踩的一点。你平时用来绘制控件的 TCanvas 把原点放在左上角,并且 Y 轴向下增长。PDF 正好相反。HotPDF 以页面左下角为原点,用磅作为单位来测量坐标,1 磅等于 1/72 英寸,Y 值越大表示位置越靠上。Y := 720 会落在一张高 792 磅的 US Letter 页面顶部附近,而 Y := 50 则接近底部。如果你第一次绘图时发现图形上下颠倒,原因基本就在这里:从屏幕图形移植过来的代码默认了错误的坐标方向,于是一路跑出了下边界
同样的约定也支配着 TextOut,所以一旦你把这个模型吃透,文字和形状就会共享同一套空间理解。做布局时先决定每个元素的底边落在哪里,而不是它的顶边,剩下的事情就会顺下来
路径:MoveTo、LineTo、Stroke
一个描边路径可以想象成抬笔、落笔、拖动。MoveTo 负责抬起画笔并设定起点,但不会留下任何痕迹。每个 LineTo 都会把当前路径延展到新的点。直到你调用 Stroke 之前,页面上都不会出现任何内容;Stroke 会使用当前描边颜色和线宽,把累积起来的路径画出来,然后清空当前路径,这样下一个 MoveTo 就能从干净状态重新开始
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'DrawPaths.pdf';
Pdf.BeginDoc;
// Line width is in points and applies until you change it.
Pdf.CurrentPage.SetLineWidth(1.5);
Pdf.CurrentPage.SetRGBStrokeColor(clBlack);
// A horizontal rule near the top of the page (Y measured from bottom).
Pdf.CurrentPage.MoveTo(72, 720);
Pdf.CurrentPage.LineTo(523, 720);
Pdf.CurrentPage.Stroke; // commit the path; nothing drew before this
// A thicker connected polyline: three segments in one path.
Pdf.CurrentPage.SetLineWidth(3);
Pdf.CurrentPage.SetRGBStrokeColor(RGB(30, 90, 200));
Pdf.CurrentPage.MoveTo(72, 640);
Pdf.CurrentPage.LineTo(172, 690);
Pdf.CurrentPage.LineTo(272, 620);
Pdf.CurrentPage.LineTo(372, 680);
Pdf.CurrentPage.Stroke;
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
有两个细节能节省很多真实调试时间。线宽是一种状态,而不是参数:SetLineWidth 设置一次,后续每个 Stroke 都会沿用这个值,直到你再次修改它,所以前面的折线才会比那条横线更粗。第二,路径会在每次 Stroke 后重置,因此一旦忘了调用 Stroke,你辛苦排好的几何图形就根本不会渲染出来。如果输出里少了某个形状,绘制调用永远是最先该检查的地方
坐标单位是磅,而磅可以带小数。MoveTo 和 LineTo 接受 Single 值,所以 0.5 磅的发丝线,或者 72.25 这样的坐标位置,都是合法且有意义的,不会被强行四舍五入到整数。这种精度会在两个方向上产生影响。线宽低于大约 0.5 时,可能会渲染成依赖设备的最细线,在屏幕上消失、打印时又重新出现,因此想得到可见的线条,就应当显式设定宽度,而不是依赖默认值。反过来,把表格线和网格线对齐到整点坐标,则可以避免密集网格因为相邻线条舍入方式不同而显得轻微不均匀。先把网格间距按磅定好,剩余布局都会跟着受益
填充的形状与颜色
封闭图形可以被填充,而不只是描出轮廓。Rectangle 接收位置和尺寸,Circle 接收圆心和半径,它们都可以通过 Fill 提交,把内部涂成当前填充颜色,或者通过 Stroke 只画轮廓。填充颜色和描边颜色是两块独立状态,分别通过 SetRGBFillColor 与 SetRGBStrokeColor 设定,而它们都接收一个 TColor。这意味着你可以直接复用 Delphi 的颜色常量和 RGB 辅助函数
// Rectangle(X, Y, Width, Height): X and Y are the lower-left corner.
Pdf.CurrentPage.SetRGBFillColor(RGB(220, 60, 60));
Pdf.CurrentPage.Rectangle(72, 500, 160, 90);
Pdf.CurrentPage.Fill;
// Circle(X, Y, Radius): X and Y are the center.
Pdf.CurrentPage.SetRGBFillColor(clNavy);
Pdf.CurrentPage.Circle(420, 545, 45);
Pdf.CurrentPage.Fill;
// Outline only: set a stroke color and a width, then Stroke.
Pdf.CurrentPage.SetLineWidth(2);
Pdf.CurrentPage.SetRGBStrokeColor(clBlack);
Pdf.CurrentPage.Rectangle(72, 400, 160, 60);
Pdf.CurrentPage.Stroke;
需要特别注意 Rectangle 的参数形状。它是位置加尺寸,也就是 X, Y, Width, Height,不是两个对角点。Delphi 开发者熟悉的 TCanvas.Rectangle 接收的是 (Left, Top, Right, Bottom),因此肌肉记忆很容易把第二个角传给 HotPDF,而 HotPDF 期待的其实是宽度和高度,于是矩形尺寸就会完全错误。这里的 (X, Y) 是左下角,和页面原点规则一致。对于圆来说,(X, Y) 是圆心,第三个参数则是以磅为单位的半径
原始示例中选错的一个颜色
旧版示例曾经对每个图形都使用 Random($FFFFFF) 随机生成颜色。看上去很活泼,但对生成式文档来说,这其实是错误直觉。通过代码构建的 PDF 往往也是你需要测试的对象,随机填充色会让每次运行的输出都无法比较:与一份已知正确的文件做逐字节 diff 会次次失败,而且完全没有价值。请使用显式颜色。如果你想让一系列形状具备变化,就从数据或固定调色板数组中驱动它,这样同一份输入始终产生同一份文件。工件一旦进入发布流水线,确定性永远比新鲜感更重要
把这些基础图元拼起来:一个标注框
每个基础图元单看都很简单,真正的价值出现在你把它们组合成报表里真正需要的东西时。标注框,也就是指向某个图形并附上说明的注释框,会把前面讲过的内容全部串起来:一个带边框的填充矩形、一条描边的指示线、一个作为锚点的圆点,以及用与图形相同的左下角坐标系排进去的文字。FillAndStroke 在这里就很有价值,它能一次提交同时绘制内部和轮廓,而不用把矩形构建两遍
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'Callout.pdf';
Pdf.BeginDoc;
// 1. The box: pale fill plus a visible border, one path, one commit.
// Rectangle is lower-left corner plus size, Y measured from the bottom
Pdf.CurrentPage.SetRGBFillColor(RGB(255, 244, 214)); // pale amber panel
Pdf.CurrentPage.SetRGBStrokeColor(RGB(180, 130, 40)); // darker rim
Pdf.CurrentPage.SetLineWidth(1);
Pdf.CurrentPage.Rectangle(90, 600, 240, 70);
Pdf.CurrentPage.FillAndStroke;
// 2. The pointer: one stroked segment from the box edge down
// toward the thing being annotated
Pdf.CurrentPage.SetLineWidth(1.5);
Pdf.CurrentPage.MoveTo(90, 615); // left edge of the box
Pdf.CurrentPage.LineTo(66, 546);
Pdf.CurrentPage.Stroke;
// 3. A filled dot anchors the pointer at its target
Pdf.CurrentPage.SetRGBFillColor(RGB(180, 130, 40));
Pdf.CurrentPage.Circle(64, 542, 3);
Pdf.CurrentPage.Fill;
// 4. The label, positioned relative to the box's lower-left corner.
// Text and shapes share one coordinate system, so the offsets
// are plain arithmetic against (90, 600)
Pdf.CurrentPage.SetFont('Arial', [fsBold], 10);
Pdf.CurrentPage.TextOut(102, 645, 0, 'Check this total');
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(102, 628, 0, 'The rounding rule changed in the');
Pdf.CurrentPage.TextOut(102, 616, 0, 'June release; verify against v2.1');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
注意,这个组合图形所需的状态管理其实非常少。填充色、描边色和线宽,都在各自图形真正使用之前立刻设置,因此每一段绘制代码都像一个自包含单元,可以轻松调换顺序,或抽出去做成辅助过程,而不会拖着隐藏状态一起走。把它包装成一个接收锚点和文案字符串的过程,你只花四十行左右代码,就能得到一个可复用的图示标注能力
矢量绘图什么时候最有价值,什么时候不值得
当几何图形是程序生成出来的,就该优先使用这些路径和形状调用:图表的网格线和柱体、发票表格的横线、示意图上的标注框、用少量路径勾出的 Logo 标记。它们都能无损缩放,而且几乎不会增加文件体积,因为一个矩形本质上只是几个数字,而不是成千上万个像素。当然反面情况也一样明确。如果你手里真正拥有的是照片或截图,就应该改用 AddImage 和 ShowImage 把它当图片插入;用矢量调用去描摹位图不会带来任何收益。上面这些直线、矩形和圆形已经覆盖了大部分真实报表需求,而开发者下一步最常问的三项增强,曲线、虚线和透明度,也都还是挂在同一个页面对象上
简述曲线、虚线与透明度
自由曲线只是对现有路径机制的进一步扩展。CurveToC(X1, Y1, X2, Y2, X3, Y3) 会从当前点追加一段三次贝塞尔曲线,终点是 (X3, Y3),并朝两个控制点弯曲;简写形式 CurveToV 和 CurveToY 则覆盖了其中一个控制点与端点重合的情况。一个路径可以在单次 Stroke 或 Fill 提交前,自由混合 LineTo 和 CurveToC 段,这正是圆角和顺滑折线图的构建方式
虚线描边和线宽一样,也是状态。SetDash([3, 3], 0) 会把之后所有描边切换为“画 3 磅、停 3 磅”的模式,第一个参数数组定义开关长度,第二个参数控制循环从哪里开始;NoDash 则把画笔恢复为实线。记得在需要的网格线上启用它,并在后面的实线规则前恢复,否则虚线样式会悄悄污染后续所有绘制
透明度则不是颜色参数,而是通过命名图形状态来传递,因为 PDF 里的 alpha 属于图形状态字典的属性。你需要在文档上调用 RegisterExtGState 注册一个状态,传入 0 到 1 之间的填充透明度和描边透明度,再通过返回的名字调用 CurrentPage.SetGraphicsState。从那一刻起,后续的填充和描边就会按注册的不透明度绘制。这比颜色设置器稍微重一些,但当你第一次需要让高亮条压在文字上方、又不遮挡文字时,就会发现它很值得
最后一个值得保留的习惯是验证。程序生成的几何图形可能在你的机器上正常,在客户机器上却出问题,最常见的原因是你混入的文本发生了字体替换,或者代码假设了某个并不成立的页面尺寸。把生成好的文件在几个缩放级别下打开,确认边缘依旧干净,并检查每个形状都落在你预期的页边距区域内。只要采用确定性的配色方案,这项检查甚至可以对照参考 PDF 自动化完成,而不只是靠肉眼看
这里展示的 MoveTo、LineTo、Stroke、Fill 与颜色相关调用,都属于适用于 Delphi 和 C++Builder 的 HotPDF Component