技术文章

HotPDF 在 Delphi 中渲染 PDF 平铺图案

剖面线渲染成一整块平坦灰色,是平铺图案最经典的失败形态。面向 Delphi 和 C++Builder 的原生 VCL PDF 组件 HotPDF,绘制 PatternType 1 的方式是把当前路径变成一个临时裁剪区域,然后对每一个可见图块重放一次图案内容流,图案的选中状态保存在图形状态里,由 qQ 负责恢复

症状有两种形态,在你搞清楚原因之前,看起来毫不相关。一份 CAD 图纸丢失了剖面剖面线,变成了实心填充,因为渲染器把图案解析成了一个平均颜色,直接把那个颜色画了上去。或者剖面线"逃逸"了:一个本该是纯白的标题栏,从两条路径之前的一个详图里沾上了斜线。这两种都是图案状态问题,而且只有其中一种真正和绘制图块本身有关

为什么平铺图案会渗到下一条路径上

因为选中的图案名称是图形状态的一部分,而不是使用它的那个操作符自身的属性。ISO 32000-1 §8.6.6.2 把 Pattern 颜色空间定义为一种颜色值本身是提供给 scnSCN 的图案名称的颜色空间,而颜色状态的其他每一个分量都由 q 保存、由 Q 恢复。图案名称必须遵循同样的规则。HotPDF 把它保存在状态记录里,字段名是 FillPatternNameStrokePatternName,和填充、描边的颜色空间族放在一起,所以一次 Q 会把之前的选择原样放回去,就像它放回之前的 CTM 一样

要是把这个名字改存到操作符分发器里的一个局部变量中,它就会在流里的每一次 Q 之后照样存活。失败随后会出现在意想不到的地方:一个在带图案路径之后绘制的 Form XObject,会继承一个它自己的内容流从未设置过的图案选择,它的填充就会带出剖面线。嵌套的 form 会让情况更糟,因为每一层嵌套都会压栈和出栈状态,而这个游离变量对此毫无感知。用 csCS 设置一个非图案的颜色空间,或者直接发出一个普通的 g / rg / k,也必须同时清掉图案名称,否则那个陈旧的选择会活得比赋予它意义的那个颜色空间还久

q
  /Pattern cs              % pattern colour space, ISO 32000-1 8.6.6.2
  /P1 scn                  % coloured tiling pattern, PaintType 1
  10 10 200 120 re f       % this rectangle is hatched
Q
0 0 300 200 re f           % must be black again, not hatched

q
  /Cs2 cs                  % [/Pattern /DeviceCMYK] array
  0 0.6 1 0 /P2 scn        % uncoloured pattern plus its underlying colour
  20 20 160 90 re f*
Q

图案是通过一次裁剪来绘制的,从来不是作为一次填充

正确的模型是减法式的:把设备裁剪区域限制到正在绘制的形状上,再在这个区域内部运行图案内容。HotPDF 从不先画一个纯色近似值再往上覆盖,因为那个中间的纯色会从图块之间的缝隙里透出来,还会和图块内容里的任何透明度打架。§8.7.3.2 把平铺图案描述为一个按固定水平和垂直间隔重复的内容流,而重复只有在裁剪区域已经具备正确形状的前提下才有意义。对于填充,转换是直接的:HPDFSelectFillPathClip 会为 f*B*b* 把多边形填充模式设为 ALTERNATE,为非零版本设为 WINDING,构建 GDI 路径,再用 SelectClipPath 把它并入裁剪区域。正是这一行代码,让一次奇偶规则的图案填充留下的空洞和一次奇偶规则的纯色填充完全一致,而这恰恰是一个环形剖面线区域所需要的效果

描边是最容易出错的部分。一条被描边的路径没有内部区域,所以直接把路径本身并入裁剪区域,得到的是一个空区域,什么都画不出来。因此 HPDFSelectStrokePathClip 会先从当前状态构建一支几何画笔,用 PS_GEOMETRIC,端点样式取自 J,连接样式取自 j,斜接限制取自 M,虚线数组生效时再加上 PS_USERSTYLE,然后调用 WidenPath 把描边轮廓转换成一个可填充的区域,再做裁剪。这样一来,端点、连接、斜接和虚线在带图案描边的路径上的表现,就是通过构造和普通描边保持一致的,而不是靠再实现一遍。这里有两处诚实的限制:小于一个设备单位的线宽会被钳制到一个像素,虚线数组会被截断到十六个条目,这是 ExtCreatePen 所能接受的上限

到底哪些图块是真正可见的

可见范围是靠把变换反着跑一遍算出来的。图块的放置发生在图案空间里,但唯一知道页面上到底有多大区域被触及的,是设备空间里的设备裁剪框。HotPDF 组合出 BaseMatrix := CTM * PatternMatrix,对它求逆,再用这个逆矩阵把 GDI 裁剪框的四个角映射回去。这四个映射后角点的轴对齐边界,给出了可能被覆盖到的那块图案空间矩形,再用 XStepYStep、对照图案的 BBox 去除这块矩形,就得到了封闭的索引范围。每个单元格随后用 CTM * PatternMatrix * Translate(i * XStep, j * YStep) 作为 CTM 来渲染,并且会再被裁剪第二次,裁到它自己变换后的 BBox 多边形范围内。当 XStep 小于边界框宽度时,这第二次裁剪就很关键了,这正是表达重叠图块设计的方式;没有它,相邻单元格就会在各自声明的范围之外相互覆盖。如果某个单元格的裁剪结果是 NULLREGION,这个单元格就会被跳过,不做任何词法分析或执行

// Map the device clip box back into pattern space through the inverse of
// CTM * PatternMatrix, then convert those bounds into tile index ranges.
BaseMatrix := HPDFMatMul(FGSStack.State.CTM, PatternMatrix);
if not HPDFMatInvert(BaseMatrix, InverseMatrix) then Exit;   // singular: refuse
if GetClipBox(FDC, ClipRect) = ERROR then Exit;

// MinX..MaxY are the axis-aligned bounds of the four mapped clip corners.
I0 := Floor((MinX - BBox[2]) / StepXAbs);
I1 := Ceil ((MaxX - BBox[0]) / StepXAbs);
J0 := Floor((MinY - BBox[3]) / StepYAbs);
J1 := Ceil ((MaxY - BBox[1]) / StepYAbs);

PlannedTiles := Int64(I1 - I0 + 1) * Int64(J1 - J0 + 1);
if (PlannedTiles <= 0) or (PlannedTiles > FPatternTilesRemaining) then Exit;
Dec(FPatternTilesRemaining, Integer(PlannedTiles));

无色图案,以及从外部而来的颜色

一个 PaintType 2 图案只带有形状,不带颜色,颜色是随图案名称一起传进来的。§8.7.3.2 规定,无色图案只能配合声明了底层颜色空间的 Pattern 颜色空间使用,所以 scn 先接收颜色分量值,最后才接收图案名称。HotPDF 通过存放在图案颜色空间条目上的底层空间来解析这些分量,这意味着一处无色剖面线可以像任何其他填充一样,用一个 Separation 油墨或一个 DeviceN 组合来上色;这个解析过程的机制在渲染 Separation 与 DeviceN 专色一文里有讲。在图块内部,两种绘制类型的差异很大。对于 PaintType 2,渲染器会在该图块持续期间设置一个颜色操作符抑制标志,所以图案内容里任何 grgkscn 都会被忽略,每一笔标记都采用外部提供的颜色。对于 PaintType 1,情况恰恰相反:填充和描边状态会被重置为 PDF 默认值,也就是采用单位颜色空间的 DeviceGray 黑色,图块自己给自己上色。跳过这次重置,会让恰好在 f 操作符处生效的当前颜色,渗漏进一个本该自成一体的图案里

为什么每个图块之后都必须恢复图形状态栈的深度

因为图案内容流可以是不平衡的,而且这个损害会随着单元格数量累积。一个内容流里含有三个 q 操作符、两个 Q 操作符的图块,会让栈比一开始深一层。如果单元格之间只恢复当前状态记录,深度就会不断增长,于是第二百个单元格会从属于第一百九十九个单元格的那个栈帧上执行,带着那个栈帧当时的 CTM 和裁剪区域。因此 HotPDF 会在图块循环开始前对状态记录和栈深度拍一份快照,并在每次迭代开头调用 RestoreSnapshot,一步把栈截回保存时的长度、并重新装入保存的状态。页面 Resources 字典和颜色操作符抑制标志也在同一个边界上恢复,因为一个图块可能引用了自己的资源,绝不能把它们传给下一个图块。GDI 裁剪状态也得到同样的处理,靠在每个单元格周围配一对 SaveDC / RestoreDC,这样一个安装了自己的 W n 裁剪的图块,就不会缩小下一个图块可用的区域

预算、拒绝,以及渲染器不会画的东西

平铺图案是 PDF 里最容易被用来编写拒绝服务文件的地方,所以这里的限制是硬性数字,而不是启发式判断。图案嵌套上限是深度 4,和 Form XObject 递归用的是同一道防线,用来阻止一个通过自己的资源字典引用自身的图案。单次路径绘制最多可以执行 16,384 个图块,这个数字在嵌套图案之间递减计数,只有在最外层图案开始绘制时才会重置。如果一张图块网格计划要用的单元格数量超过这份预算的剩余额度,整个绘制会被直接拒绝,一个单元格都不会执行

退化几何直接拒绝,不做近似处理。缺失或零面积的 BBox、量级小于 1e-6 的 XStepYStep、一个求不出逆的 CTM * PatternMatrix 乘积、超过 1e9 的映射裁剪坐标,或者超过一百万的索引量级,都会导致图案绘制直接返回而不绘制任何东西。结果是一片没被画出来的区域,而不是一个挂死的渲染线程,这正是批处理转换器想要的那种取舍。性能上的关键决定只有一个:图案流每次绘制只用 HPDFTokenizeContentStream 做一次词法分析,得到的令牌数组会在每一个可见单元格之间重复使用,所以图块数量只会让执行成本翻倍,绝不会让词法分析成本翻倍

从 Delphi 渲染一个带图案的页面

图案支持完全不会改变调用代码。加载文档,请求某一页,平铺工作会发生在页面转位图渲染已经在驱动的那个内容流解释器内部。同一个解释器同时驱动位图、图元文件和打印机设备上下文,所以一份在预览缩略图里看起来正确的带剖面线图纸,打印出来的图块几何形状也会一致。PatternType 2 阴影图案走的是另一条分支,它和裸 sh 操作符共享同一套求值路径,详情见轴向与径向渐变阴影渲染一文

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('assembly-drawing.pdf') > 0 then
    begin
      // Section hatching that previously flattened to a solid block now
      // replays the tile content once per visible cell.
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 200);
      if Assigned(Bmp) then
      try
        Bmp.SaveToFile('sheet1.bmp');
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

当一处带图案的区域看起来仍然不对时,按顺序检查这三类失败。一片完全空白的区域通常意味着一次拒绝:检查 XStepYStepBBox 是否有退化值,或者把这张网格需要的图块数量和 16,384 的上限对比一下。一片被画成单一纯色的区域,说明图案名称从未传到绘制操作符那里,该去查流里 csscn 的先后顺序。一个出现在不该出现的地方的图案,说明是状态恢复的问题,该去看的是继承了它的那个 form 或路径周围的 q / Q 处理

平铺图案是那种在需要它的文件落进你收件箱之前一直隐形、一旦出现就是全部工作量的 PDF 特性之一。如果你正在 Delphi 或 C++Builder 上构建图纸查看器、工程文档转换器或报表渲染器,完整的组件及其渲染 API 都记录在 HotPDF Delphi PDF 组件页面上