技术文章

Delphi 中的 PDF 矢量图形:路径与渐变

大多数接触 PDF 的 Delphi 代码,通常把这种格式视为两样东西的容器:几段文本和少量放置好的位图。这种看法并不算错,但也把 PDF 最强的那一部分闲置了。PDF 页面本质上是一个与分辨率无关的二维画布,建立在与 PostScript 相同的成像模型之上。它能绘制线条、曲线、填充区域、渐变和重复图案,而且全部都以矢量方式存在,因此无论放大多少倍都能保持清晰,打印时也会以输出设备的完整分辨率落地。如果你画的是 logo、图表、水印或证书边框,矢量路径几乎总是更正确的基础图元,也往往比很多程序习惯采用的栅格图像更小、更利落

本文按照 ISO 32000-1 对矢量模型的定义逐步展开,并给出对应的 PDFlibPas 调用。目标是把规范讲得足够具体,因为 API 和规范之间的映射非常贴近,理解其中一边,另一边也会自然清晰起来

页面本质上是一台路径机器

ISO 32000-1 第 8.5 节把图形分成两个永不重叠的阶段。第一步是构建路径,这一步只有几何意义,不会立刻产生任何可见结果。第二步是一次性绘制这条路径,也就是描边它的轮廓、填充它的内部,或者两者同时进行。构建阶段页面上不会出现任何内容。路径只是被保存在 graphics state 里的一个抽象点段序列,直到某个 painting operator 消耗它,它才会被渲染出来并随后丢弃

路径由一个或多个 subpath 组成。subpath 从某个点开始,并通过追加 segment 不断增长,这些 segment 可以是直线、三次 Bezier 曲线,在某些平台上还可以是作为独立闭合 subpath 加进去的完整矩形。在 PDFlibPas 里,你用 StartPath 打开一条路径,它会设置起点;接着用 AddLineToPathAddCurveToPath 去延展它。每次调用都会推进一个隐式的当前点,因此下一个 segment 会自动从上一个结束的地方继续。ClosePath 会补上一条回到 subpath 起点的直线,这对描边尤其重要,因为闭合顶点此时会形成真实的 line join,而不是两个松散的端点帽

// A closed quadrilateral, stroked then filled
PDF.SetLineColor(0, 0, 0);
PDF.SetFillColor(0.6, 0.8, 1.0);
PDF.SetLineWidth(1.5);

PDF.StartPath(150, 100);           // open the path at the first vertex
PDF.AddLineToPath(220, 140);
PDF.AddLineToPath(180, 210);
PDF.AddLineToPath(110, 170);
PDF.ClosePath;                     // straight segment back to (150, 100)
PDF.DrawPath(2);                   // 2 = fill and stroke; path is consumed

曲线通过 AddCurveToPath 添加,它接收两个 Bezier 控制点和一个终点,也就是 AddCurveToPath(CtAX, CtAY, CtBX, CtBY, EndX, EndY)。曲线会从当前点走向 (EndX, EndY),并在途中受到那两个控制点的牵引。圆弧则可以通过 AddArcToPath(CenterX, CenterY, TotalAngle) 获得,半径来自当前点到圆心的距离,而引擎会把这段弧拆成一串 Bezier segment 输出。矩形有快捷方式,也就是 AddBoxToPath(Left, Top, Width, Height),它会在不需要预先调用 StartPath 的前提下,直接追加一个完整闭合矩形作为自己的 subpath

两种填充规则,以及它们为何会给出不同结果

当你填充一条自相交路径,或者一条内部还有内环的路径时,渲染器必须有一套规则来判断哪些区域属于图形内部,哪些区域应该被当成孔洞。ISO 32000-1 第 8.5.3.3 节定义了两种规则,而且同一套几何形状在这两种规则下可能产生完全不同的结果。nonzero winding rule 会从测试点向无穷远发射一条射线,统计这条射线与路径相交时的有符号穿越次数,从左到右的 segment 记加一,反方向的记减一,只要结果不为零,就认为该点在内部。even-odd rule 则完全不关心方向,只数穿越次数,只要次数是奇数,就把该点视为内部

最典型的分歧场景,就是一个带孔的形状,比如圆环或者垫圈。先画一个外边界,再在里面画一个内边界。对于 even-odd rule,内环总会挖出一个洞,因为在两层边界之间的点会被射线穿越一次,而在内环之内的点会被穿越两次。对于 nonzero winding rule,只有当内环的缠绕方向和外环相反时,那个洞才会出现;如果两者方向一致,缠绕值会互相加强而不是相互抵消,内层区域最终也会被填满。把一个五角星作为单一自相交轮廓来画时,也会看到同样的分裂:even-odd 会让中心五边形留空,而 nonzero winding 则会把它填实

在 PDFlibPas 里,具体采用哪条规则,不是通过一个单独标志来指定,而是取决于你用哪一个绘制调用。DrawPath 使用 nonzero winding rule 来填充,而 DrawPathEvenOdd 使用 even-odd rule。两者都接受同样的整数模式:0 表示只描边,1 表示只填充,2 表示填充并描边。对于需要精确打洞的形状,even-odd 往往更容易使用,因为它不要求你仔细管理 subpath 的方向

// Same two boxes, two fill rules, two different results.
// Nonzero winding: both boxes wind the same way, so the inner one
// does NOT cut a hole and the whole outer box fills solid.
PDF.SetFillColor(0.2, 0.4, 0.8);
PDF.AddBoxToPath(100, 100, 200, 120);   // outer
PDF.AddBoxToPath(140, 130, 120,  60);   // inner
PDF.DrawPath(1);                         // 1 = fill, nonzero winding

// Even-odd: the inner box is crossed an even number of times,
// so it punches a clean rectangular hole through the outer box.
PDF.SetFillColor(0.2, 0.4, 0.8);
PDF.AddBoxToPath(100, 300, 200, 120);   // outer
PDF.AddBoxToPath(140, 330, 120,  60);   // inner cut-out
PDF.DrawPathEvenOdd(1);                  // 1 = fill, even-odd

轴向渐变沿着一条线改变颜色

平面填充颜色在整个区域中只有一个值,而渐变会让颜色连续变化,其中最简单的一种就是 axial,也就是线性渐变。ISO 32000-1 第 8.7.4.5 节把它定义为 Type 2 axial shading:你给出定义轴线的两个点,在第一个点给出起始颜色,在第二个点给出结束颜色,然后渲染器沿着这条轴线做插值。填充区域中的每个点,都会取它垂直投影到轴线上的那个颜色,因此渐变会以垂直于这条连线的色带形式展开

在 PDFlibPas 中,渐变是一个你创建一次、之后按名字反复选择的文档资源。它通过 NewRGBAxialShader 注册。函数签名是 NewRGBAxialShader(ShaderName, StartX, StartY, StartRed, StartGreen, StartBlue, EndX, EndY, EndRed, EndGreen, EndBlue, Extend),也就是两个轴端点、两端的 RGB 三元组,取值范围都是 0 到 1,再加一个 Extend 标志。若 Extend1,则轴线端点之外仍会继续以端点颜色实填,这通常正是你想要的效果,因为这样轴线之外的角落不会出现未着色区域;如果设成 0,那些区域就会保持未填充。创建好 shader 后,你可以用 SetFillShader 把它绑定为填充,用 SetLineShader 把它绑定为描边,也可以用 SetTextShader 让文本也使用这个渐变。绑定会持续影响随后的绘制调用,因此接下来绘制的路径就会使用这个渐变,而不是某个固定颜色

// Define a vertical gradient once: blue at the bottom to white at the top.
PDF.NewRGBAxialShader('panelGrad',
  0, 100,   0.10, 0.25, 0.55,    // start point and start RGB
  0, 260,   1.00, 1.00, 1.00,    // end point and end RGB
  1);                            // 1 = extend ends as solid color

// Select the gradient as the fill, then paint a rectangle with it.
PDF.SetFillShader('panelGrad');
PDF.AddBoxToPath(80, 100, 300, 160);
PDF.DrawPath(1);                 // 1 = fill, now filled by the shader

这里的轴线是竖直的,也就是在固定 x 坐标上,从 y=100 延伸到 y=260,因此色带会水平展开,矩形底部是蓝色,顶部渐变到白色。因为 shader 是通过名字索引的,一个定义好的渐变可以在页面上用于任意多个图形,而要切回纯色填充,只需要在下一个路径绘制前再调用一次 SetFillColor 即可

平铺图案会重复一个单元格

渐变是在整块区域里平滑改变单一颜色,而 tiling pattern 则会把一小块图稿重复铺满整个区域。ISO 32000-1 第 8.7.3.1 节把 tiling pattern 定义为一个 pattern cell,也就是一段独立内容,渲染器会把它复制到固定网格上,从而平铺你正在绘制的区域。工程类填充里的斜线底纹、页眉后方的重复品牌图形,或者那些不管面积多大都能保持矢量锐利且几乎不增加体积的纹理背景,都是这样构建的,因为单元格只需存储一次,然后在任意地方被反复引用

PDFlibPas 会从捕获到的页面内容中构建 pattern cell。你先用 CapturePage 抓取整页或某个区域,再用 NewTilingPatternFromCapturedPage(PatternName, CaptureID) 把这份捕获内容变成具名图案,最后通过 SetFillTilingPattern(PatternName) 把它绑定为当前填充。从这一刻开始,你之后填充的任何路径都会使用这个重复单元格,而不是纯色,工作方式和 shader 填充几乎完全相同,只不过油漆来源从渐变改成了平铺单元。这个序列比单一函数调用更复杂,所以如果你对 capture 步骤还不熟,可以把 pattern 理解成两阶段流程:先生成被捕获的单元格,再在绘制目标区域之前按名字把它绑定为填充

把这些图元组合起来

这些部件本身就能直接组合。一个被填充的 Bezier 斑块,就是一条由曲线组成并通过 DrawPath 绘制的路径。把同样的轮廓再加上一层内环,并改用 DrawPathEvenOdd 去填充,就会出现一个 nonzero winding 不会留下的孔洞。一个带渐变填充的矩形,本质上就是一个先绑定 shader 再绘制的 box。下面的示例把这三种情况依次画在一页上,让两种填充规则的差异直观看得见,然后在它们下方铺上一块渐变面板

// 1. A filled Bezier shape (nonzero winding).
PDF.SetFillColor(0.85, 0.30, 0.25);
PDF.StartPath(120, 480);
PDF.AddCurveToPath(160, 560, 240, 560, 280, 480);   // top lobe
PDF.AddCurveToPath(240, 420, 160, 420, 120, 480);   // bottom lobe
PDF.ClosePath;
PDF.DrawPath(1);                                     // 1 = fill

// 2. The same outline, plus an inner loop, filled even-odd to show a hole.
PDF.SetFillColor(0.85, 0.30, 0.25);
PDF.StartPath(120, 300);
PDF.AddCurveToPath(160, 380, 240, 380, 280, 300);
PDF.AddCurveToPath(240, 240, 160, 240, 120, 300);
PDF.ClosePath;
PDF.MovePath(180, 300);                              // new subpath: the hole
PDF.AddArcToPath(200, 300, 360);                     // a full circle
PDF.ClosePath;
PDF.DrawPathEvenOdd(1);                              // hole is punched out

// 3. A rectangle filled with an axial gradient.
PDF.NewRGBAxialShader('footerGrad',
  60, 100,  0.95, 0.55, 0.10,
  60, 200,  0.20, 0.10, 0.40,
  1);
PDF.SetFillShader('footerGrad');
PDF.AddBoxToPath(60, 100, 340, 100);
PDF.DrawPath(1);

这里有两个细节特别值得记住。第一,真正决定 fill rule 的是绘制调用本身,因此在 DrawPathDrawPathEvenOdd 之间做选择,本质上就是在 nonzero winding 和 even-odd 之间做选择;对于有孔形状,even-odd 能让你省去对子路径方向的推理。第二,graphics state 读取发生在真正绘制的那一刻,所以颜色、线宽和 shader 绑定都必须在绘制调用之前完成,因为引擎读取的正是那一刻的状态。先构建路径,再配置状态,最后绘制,矢量模型每次都会按预期工作

从这里继续往下,最自然的两步是:一是在现有文档里把矢量和文本再读取出来,这部分已经在我们关于文本、图像与字体提取的文章中介绍;二是把同一套绘图模型渲染到 Windows device context 上,用于屏幕预览和打印,这部分在打印与预览讲解中展开。本文介绍的路径、shader 和 pattern 调用,都作为 Delphi PDF Library 的一部分提供,并与本博客其他文章涉及的文本、图像、表单和签名 API 一起交付