Technical Article

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

大多数接触 PDF 的 Delphi 代码都将该格式视为两样东西的容器:文本段和一些放置的位图。学术看法在一定范围内是正确的,但它使该格式中最强大的部分处于闲置状态。PDF 页面是一个独立于分辨率的二维画布,建立在与 PostScript 相同的成像模型上。它可以绘制直线、曲线、填充区域、渐变和重复图案,所有这些都是矢量,可在任何缩放级别下保持清晰,并能以设备的完整分辨率进行打印。如果您正在绘制徽标、图表、水印或证书边框,矢量路径几乎总是正确的图元,它比许多程序所采用的栅格化图像更小、更清晰。

本文将介绍 ISO 32000-1 所定义的矢量模型,并展示配套的 PDFlibPas 调用。其目的是使规范具体化,因为 API 与其紧密对应,理解其中之一就能触类旁通。

页面是一个路径机器

ISO 32000-1 第 8.5 节分两个互不重叠的阶段描述了图形。首先您构建路径,这是纯粹的几何形状,没有任何可见的结果。然后,您通过单个操作绘制该路径,该操作会描边其轮廓、填充其内部,或者两者兼而有之。在构建期间页面上不会出现任何内容。路径是保存在图形状态中的点和段的抽象序列,直到绘制运算符消耗它,此时它被渲染并丢弃。

路径由一个或多个子路径组成。子路径从一个点开始,并通过追加段来增长:直线、三次贝塞尔曲线,以及在某些平台上作为其自身封闭子路径添加的完整矩形。在 PDFlibPas 中,您可以使用 StartPath 打开路径,这会设置起点,然后使用 AddLineToPathAddCurveToPath 对其进行扩展。每次调用都会推进一个隐式的当前点,因此下一个段会从上一个段结束的地方继续。ClosePath 绘制一条回到子路径起点的最终直段,这对于描边非常重要,因为它在闭合顶点处产生真正的线连接,而不是两个松散的端帽。

// 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,它接受两个贝塞尔控制点和一个终点:AddCurveToPath(CtAX, CtAY, CtBX, CtBY, EndX, EndY)。曲线从当前点运行到 (EndX, EndY),沿途拉向两个控制点。圆弧可通过 AddArcToPath(CenterX, CenterY, TotalAngle) 获得,其中半径取自当前点与中心之间的距离,引擎将弧作为贝塞尔段链输出。矩形有一个快捷方式 AddBoxToPath(Left, Top, Width, Height),它追加一个完整的封闭矩形作为其自身的子路径,而无需先前的 StartPath

两种填充规则,以及它们为何不一致

当您填充自相交或包含内部循环的路径时,渲染器需要一个规则来决定哪些区域位于形状内部,哪些区域是孔洞。ISO 32000-1 第 8.5.3.3 节定义了两种规则,它们可以用不同的方式绘制相同的几何图形。非零环绕数规则计算从测试点投射到无穷远处的射线的有符号交叉次数,为每个从左到右交叉的段加一,为每个相反方向交叉的段减一;当总数不为零时,该点位于内部。奇偶规则忽略方向,仅计算交叉次数,当次数为奇数时称该点在内部。

它们分歧的经典案例是带孔的形状,例如圆环或垫圈。绘制一个外部边界,并在其中绘制一个内部边界。在奇偶规则下,内部循环总是切出一个孔,因为两个边界之间的任何点都被交叉一次,而内部循环内部的任何点都被交叉两次。在非零环绕数规则下,只有当内部循环的缠绕方向与外部相反时,才会出现孔洞;如果将它们向同一方向缠绕,缠绕会加强而不是抵消,内部区域就会填满。绘制为单个自相交轮廓的五角星展示了相同的分裂:奇偶规则使中心五边形保持空白,而非零环绕数规则将其填满。

PDFlibPas 通过您用来绘制的调用而不是通过标志来选择规则。DrawPath 使用非零环绕数规则填充;DrawPathEvenOdd 使用奇偶规则填充。两者接受相同的整数模式:0 仅描边轮廓,1 仅填充,2 填充并描边。奇偶规则是打孔的更简单工具,恰恰是因为它不需要您管理子路径的方向。

// 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

轴向渐变沿直线改变颜色

扁平填充颜色在整个区域是一个值。渐变连续改变颜色,最简单的一种是轴向(或线性)渐变。ISO 32000-1 第 8.7.4.5 节将其指定为类型 2 轴向着色:您给出定义轴的两个点,第一个点处的起始颜色和第二个点处的结束颜色,渲染器沿该轴对颜色进行插值。填充区域中的每个点都采用其在轴上的垂直投影的颜色,因此渐变在垂直于两点之间连线的带状区域中运行。

在 PDFlibPas 中,渐变是您创建一次然后选择作为活动画笔的命名文档资源。NewRGBAxialShader 注册它。签名是 NewRGBAxialShader(ShaderName, StartX, StartY, StartRed, StartGreen, StartBlue, EndX, EndY, EndRed, EndGreen, EndBlue, Extend):两个轴终点、两端的 RGB 三元组(作为 0 到 1 范围内的值)以及 Extend 标志。当 Extend 设置为 1 时,起始和结束颜色在轴终点之外继续作为纯色填充,这通常是您想要的,这样轴外区域的角就不会不着色;为 0 时它们保持原样。着色器存在后,您可以使用 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

平铺图案重复单元格

当渐变平滑地改变单一颜色时,平铺图案在整个区域内重复一小块艺术作品。ISO 32000-1 第 8.7.3.1 节将平铺图案定义为图案单元格(一个独立的内容片段),渲染器在固定的网格上复制它以平铺正在绘制的区域。这就是您构建工程填充剖面线、页眉后重复的品牌主题或无论区域多大都保持矢量清晰且几乎没有任何大小开销的纹理背景的方法,因为单元格只需存储一次并随处引用。

PDFlibPas 从捕获的页面内容构建图案单元格。您使用 CapturePage 捕获页面或区域,使用 NewTilingPatternFromCapturedPage(PatternName, CaptureID) 将捕获转换为命名图案,然后使用 SetFillTilingPattern(PatternName) 将该图案选择为当前填充。从那时起,您填充的任何路径都会使用重复的单元格而不是扁平颜色进行绘制,这与着色器填充的工作方式完全相同,但以平铺单元格作为画笔源。该顺序比单个调用更复杂,因此如果捕获步骤不熟悉,请将图案视为两阶段操作:首先生成捕获的单元格,然后在绘制要平铺的区域之前通过名称将其绑定为填充。

将图元组合在一起

这些部件可以直接组合。填充的贝塞尔斑点是由 DrawPath 绘制的曲线路径。在添加内部循环后,用 DrawPathEvenOdd 绘制的相同轮廓会显示一个孔,而环绕填充本会封闭该孔。渐变填充的矩形是绑定到着色器的盒子。下面的示例按顺序绘制了所有这三个,以便在单页上可以看到两种填充规则之间的差异,然后在它们下方放置一个渐变面板。

// 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);

有两个细节值得记住。绘制调用决定了填充规则,因此 DrawPathDrawPathEvenOdd 之间的选择就是非零环绕数和奇偶规则之间的选择,对于带孔的形状,奇偶规则可以使您免于推导子路径的方向。此外,图形状态是在您绘制的那一刻采样的:在绘制调用之前设置颜色、线宽和着色器绑定,因为这是引擎读取的状态。先构建,然后配置状态,最后绘制,矢量模型每次都表现得符合预期。

从这里开始,自然的下一步是从现有文档中读回矢量和文本,这在我们关于文本、图像和字体提取的文章中进行介绍,以及将相同的绘制模型渲染到 Windows 设备上下文以便进行屏幕预览和打印,这在打印与预览演练中进行介绍。此处介绍的路径、着色器和图案调用作为 Delphi PDF 库的一部分提供,与本博客其他地方介绍的文本、图像、表单和签名 API 一起交付。