技术文章

PDF渲染器什么都不画:四个隐蔽的Delphi缺陷

一个什么都不画的PDF渲染器,通常绘图代码本身根本没有bug。在面向Delphi和C++Builder的HotPDF Component中,有四个各自独立的缺陷让页面渲染成空白,而每一行日志却都干干净净:带前导斜杠的名称操作数、一个颠倒的cm矩阵拼接,以及一个读出零值的token索引。它们没有一个抛出异常,没有一个记录日志。内容流被正确分词,操作符分发器能识别每一个操作符,图像XObject被正确解码成一个合法的位图,然后页面输出的结果却是空的。这种组合——流水线每一步都报告成功,最终却什么可见的东西都没产生——正是一次查找或索引悄悄"未命中"而不是直接失败的典型特征。这是对这样一整个家族缺陷的事后剖析,也是对那套让它们潜伏了38个版本的测试规范的剖析

为什么一个PDF渲染器会彻底什么都不画?

因为在PDF渲染器中,一次失败的资源查找和一个空页面是没法区分开的。内容流中的名称操作数和资源字典中的键,是两个不同的字符串空间,而HotPDF在没有做归一化处理的情况下就跨这两个空间做比较。分词器读取/Im0时会保留斜杠,因为这个token本来就是这样的;而已加载的/Resources /XObject字典把键存成Im0,因为解析器在构建字典键时会把界定符去掉。所以每一次针对操作数名称的FindValue调用都返回-1。波及范围比图像要广得多。ISO 32000-1 §8.9涵盖Do,§8.4涵盖gs及其/ExtGState查找,§8.6涵盖csCS,§8.7.4.3涵盖sh。这五个操作符全都是用原始操作数作为键去查各自的资源子字典的,所以全都未命中。具名颜色空间会退回到DeviceGray,这就把1 scn变成了白纸上的白色墨水。图像XObject则干脆从未被绘制过——实际上,位图图像这条路径自打上线那天起就从未真正工作过。修复方式是在每一处以操作数为键的查找处都加上一个unit级别的辅助函数,这是唯一能防止这个约定再次跑偏的办法

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

还有第二处相关的疏漏,藏在再下一层。渲染器只为流和字典准备了带类型的解析器,所以一个指向顶层数组对象的间接引用——常见的形式是/CS0 5 0 R,另一端是[/Separation ...]——通过这两种解析器都会解析成nil,进而退回到未解析状态的链接。加上一个通用的对象解析器,一次性修好了具名颜色空间和function数组这两个问题。如果你正在接入着色(shading)字典,同样的解析规范也适用于轴向与径向着色路径,那里的/Function条目非常常见是间接引用

cm操作符,以及一次写反了的矩阵拼接

第二个缺陷把图像挪到了页面之外大约十万像素的地方,看起来和根本没画出来一模一样。ISO 32000-1 §8.3.4用行向量定义PDF的变换,cm操作符把它的操作数矩阵M以M × CTM的方式拼接到当前变换矩阵上——M先生效,现有的CTM随后生效。HotPDF通过HPDFMatMul(A, B)来做矩阵合成,它是先应用B、再应用A。所以正确的调用方式应该把旧的CTM作为A传入。而发布出去的代码把操作数矩阵作为A传入,结果算出的是CTM × M

顺序颠倒对单独一次cm调用来说没什么影响,但对标准的两步惯用写法来说是灾难性的。用1 0 0 1 x y cm放置一张图像,紧接着再来一次w 0 0 h 0 0 cm,正确的级联结果是先把单位正方形按(w, h)缩放,再按(x, y)平移。而在颠倒的级联下,平移先进去,缩放又把它乘了一遍,于是一张名义上位于(60, 400)、被缩放到200乘120的图像,最终落在了(12000, 48000)这个位置。位图绘制函数最前面的裁剪测试会把它拒之门外,绘制被跳过,而任何地方都不会报告出任何问题

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

这个案例的启发性在于,同一个源文件里其实早已经存在正确的写法。Form XObject的/Matrix条目也曾出现过同样颠倒的合成方式,但Type 3字形路径和嵌入字形轮廓路径从一开始就写对了,因为字形定位一旦顺序反了会明显地整体塌缩到原点,早就有人被迫把它修好过。同一个unit里两种写法并存了三十多个版本,各自在自己的函数里都是对的,却没有任何审阅者注意到,因为单独看每一处调用都不觉得有问题

当一个token索引差了一位时会发生什么?

你会得到十二个语法上被正常处理、语义上却已经失效的操作符。渲染器中的操作数访问器是NumAt(Back),它读取的是Tokens[OpIndex - Back],而OpIndex是操作符token本身的索引。所以一个单操作数的操作符,应该在back为1的位置找到它的数值。有十二个操作符被写成了NumAt(0),这样读到的是操作符token本身,未能通过ctOperandNumber类型检查,于是返回默认值零。这份清单包括ISO 32000-1 §9.3文本状态操作符中的TcTwTzTLTsTr,再加上§8.4.3图形状态操作符中的wJjMrii。字符间距和字间距全部变成了空操作,水平缩放从未生效,行距一直停留在零,导致T*从未真正换过行,文本上升量什么也没做,渲染模式永远是填充,而且不管声明的线宽是多少,每份文档中的每一笔描边最终都变成了1像素的细线。像mrgTm这样的多操作数操作符用的是NumAt(1..6),全都是对的,所以一个审阅这个函数的人看到的是一大片看起来合理的索引运算,其中埋着十二处错误的条目

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

为什么测试套件能连续38个版本一直是绿色的?

因为那些断言太弱,根本区分不出一个完整渲染出来的页面和一个只渲染了一部分的页面。渲染冒烟测试断言的是诸如"输出位图不是全黑的"、"页面不是空白的"、或者"图像摘要不是零"这类东西。而当文字能正常渲染、图像却不能渲染时,这些条件全都成立。文字画得好好的,所以帧缓冲从来不是纯色的,摘要也从来不是零,于是测试套件报告成功,而整条图像渲染流水线在实际上早已是一段死代码。对图形来说,弱断言之所以有诱惑力,恰恰是因为强断言看起来很脆弱。没有人希望一个测试会因为抗锯齿边缘偏移了一个像素就跑挂,于是自然的退让就是断言一些任何合理改动都不可能违反的东西——而这种退让最终落到的,是任何不合理的改动同样也违反不了的那些谓词上。一个分色(Separation)颜色空间的测试断言的是"输出与黑色可以区分开";灰色画在白底上能通过,白色画在白底上同样能通过。这个测试测的根本不是有没有画出正确的颜色,它测的只是画布上到底有没有发生过任何事情

如何写出一条真正会失败的渲染断言?

数一数目标颜色的像素有多少,数量是否符合预期,位置和大小的正确性让这个计数自然而然地反映出来。替换方案的规范是:手写一份最小化的PDF,每个文件只承载一个视觉事实,然后断言有多少像素落在某个特定RGB三元组的容差范围内。一张放在已知位置、200乘120的纯红图像,应该产生大约24000个红色像素。如果资源查找未命中,计数是0。如果cm级联顺序反了,计数是0。如果图像用了错误的颜色空间渲染,计数还是0。一个数字就能同时抓住这三种情况,而容差区间也吸收了让人一开始不敢用精确比较的那些抗锯齿噪声

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

照这个思路重写了四个冒烟测试——一个Type 4色调变换、一次图像Do放置、一个可选内容可见性的用例,以及一个Tr描边模式——这几个测试合在一起,把这整个家族的问题都暴露了出来。这才是真正的教训,而且它能推广到这个代码库之外:在一条渲染流水线里,断言必须指名颜色。任何比这更宽松的断言,检查的都只是渲染器有没有跑起来,而不是它有没有真的画出东西。如果你正在搭建自己的"页面转位图"测试框架,页面光栅化详解一文是把像素计数辅助函数嫁接到你第一个回归测试上的自然起点

诚实说明边界

有两点局限值得直说。文本裁剪渲染模式4到7目前是按其基础的填充或描边模式绘制的,因为渲染器并不对字形轮廓累积出来的裁剪路径建模;依赖文字形状裁剪效果的文档,渲染出来的会是文字本身,而不是被裁剪出来的下方美术内容。而这里描述的像素计数规范是一种冒烟测试技术,不是一套合规性测试套件——它证明的是某个具体的视觉事实确实到达了帧缓冲,这比证明输出结果与某个参考光栅化器完全一致,标准要低得多。不过,这恰好正是这四个bug在长达三年的多个版本发布中都没能跨过去的那道门槛

这里讨论的渲染器作为标准版HotPDF Component的一部分提供,适用于Delphi和C++Builder;产品页收录了完整的页面渲染API参考,包括位图缓存与后台预取入口