HotPDF 通过 HPDFResolveColorSpace 解析颜色空间,通过 HPDFEvalTintTransform 评估色调变换函数,并将结果通过替代空间转换为屏幕所需的 RGB,从而在加载的 PDF 页面上渲染 Separation 和 DeviceN 专色(Spot colors);自 v2.375.0 起,该流水线可以评估所有四种 PDF 函数类型,包括 Type 4 PostScript 计算器,使得带有 Pantone 油墨的印刷就绪文件能显示其实际颜色而不是占位符;本文介绍该流水线的工作原理,同样实用的是,介绍它在构建时是如何失败的
触发的情况总是相同的;客户从印刷厂收到一份 PDF:标题使用命名的专色油墨设置,包装插图使用双油墨的 DeviceN 混合,而正文是纯黑的;在 Acrobat 中它看起来很完美;在您的 Delphi 应用程序中,标题渲染为黑色,或者更糟糕的是,根本不进行渲染,客户向您的软件而不是该文件提交了一个 bug;文件没有问题,只是渲染器不会说文件所使用的颜色空间
为什么在 PDF 查看器中专色会渲染为黑色?
当渲染器只实现设备颜色操作符(rg、g、k)而忽略通用操作符时,专色就会渲染为黑色或消失;ISO 32000-1 §8.6 定义了三组颜色空间:设备空间(DeviceGray、DeviceRGB、DeviceCMYK)、基于 CIE 的空间(CalGray、CalRGB、Lab、ICCBased)以及特殊空间(Indexed、Separation、DeviceN、Pattern);设备组之外的所有内容都使用通用操作符进行选择: cs 和 CS 从页面资源字典中按名称选择空间,然后 sc、SC、scn 和 SCN 提供分量值;跳过这些操作符的渲染器会保留上次设置的任何颜色,对于以专色标题开头的页面,这正是初始的 DeviceGray 黑色
HotPDF 在 v2.333.0 中将通用操作符集添加到了其页面渲染器中,并提供了一个统一的解析路径:每个 /ColorSpace 资源条目(无论是裸名称、内联数组还是间接引用)都会被解析为一个 THPDFColorSpace 记录,并且每个填充或描边颜色请求都汇集通过单个 HPDFResolveColor 调用;系列枚举一目了然地显示了覆盖范围:
type
THPDFColorSpaceFamily = (csfDeviceGray, csfDeviceRGB, csfDeviceCMYK,
csfIndexed, csfCalGray, csfCalRGB, csfLab,
csfICCBased, csfSeparation, csfDeviceN,
csfUnsupported);
function HPDFResolveColorSpace(Obj: THPDFObject): THPDFColorSpace;
function HPDFResolveColor(const CS: THPDFColorSpace;
const Comps: THPDFColorComps; CompCount: Integer): THPDFRenderColor;
一个设计决策一再地证明了其价值:csfUnsupported 是一个一等公民系列,而不是一个错误;渲染器无法解释的空间会降级为定义的备用方案,而不是中止页面,这与主流查看器的行为相匹配,并可以防止单个奇异的填充使原本可渲染的文档变为空白
色调变换如何将一个油墨值转换为实际颜色?
Separation 空间携带三部分信息:油墨名称、替代颜色空间和色调变换函数;数组 [/Separation /PANTONE485 /DeviceCMYK f] 表示:当内容流写入 0.8 scn 时,将色调值 0.8 输入函数 f 并绘制生成的 CMYK 四元组;DeviceN 将此泛化到具有 N 个输入函数的 N 个油墨;油墨名称本身在屏幕上仅具有建议性,色调变换是整个渲染语义,因此解析了空间但跳过了函数的渲染器还没有做任何有用的事
HPDFEvalTintTransform 是该步骤背后的函数引擎;它于 v2.334.0 落地,评估 Type 2 指数函数(带有 Domain 和 Range 夹紧的 C0 + x^N * (C1 - C0))、Type 3 缝合函数(带 Encode 重映射的 Bounds 选择子函数递归)以及带有 8、16 和 32 位样本的 Type 0 采样函数;Type 0 与我们在创作端关于构建 Type 0 颜色 LUT 的文章中介绍的查找表机制相同,渲染侧反向走相同的结构:从解码的样本字节返回到分量值
Type 4 PostScript 计算器函数是最后一个坚守者;在 v2.375.0 之前,它们会优雅地降级为中性的占位符,自 v2.375.0 起, HPDFEvalPostScriptCalculator 在有界的操作数栈上执行完整的 ISO 32000-1 §7.10.5 操作符集:算术、比较、布尔和按位操作符、包括 roll 在内的栈操作以及 if/ifelse 条件判断;角落里的语义比看起来更为严格:PostScript round 将一半向更大值舍入,因此不能使用 Delphi 的银行家舍入 Round;三角操作符以度为单位工作, atan 返回 [0, 360) 范围内的值, exp 是双操作数幂,而不是自然指数;相同的评估器也驱动着基于函数的渐变填充,这就是为什么轴向和径向渐变渲染在同一个版本中拾取了计算器驱动的渐变斜坡的原因
// Evaluate a Separation/DeviceN tint transform (Type 0/2/3/4).
function HPDFEvalTintTransform(FuncObj: THPDFObject;
const Inputs: THPDFColorComps; InputCount: Integer;
out AltComps: THPDFColorComps): Boolean;
// Type 4 PostScript calculator, ISO 32000-1 7.10.5 operator set.
function HPDFEvalPostScriptCalculator(const Prog: TBytes;
const Inputs: THPDFColorComps; InputCount: Integer;
var Outputs: THPDFColorComps; OutCount: Integer): Boolean;
关于精度的坦诚:在双精度下评估的软件计算器不会与 RIP 逐位匹配,并且夹在 Range 边界处的数值可能与另一种实现相差一个最低有效位;对于屏幕显示和回归渲染,这是无关紧要的;如果您正在构建颜色管理的打样,色调变换仅仅是第一阶段,您仍然需要在下游配备真正的 CMM
没有 ICC 引擎下的 CalGray、CalRGB、Lab 和 ICCBased
基于 CIE 的系列走相同解析器的另一个分支;HotPDF 通过标准的 Lab 到 XYZ 到 sRGB 链(包括反向变换函数中的 6/29 断点立方)转换 Lab 值,并处理带有每通道 Gamma 值加 3x3 线性矩阵的 CalRGB,以及带单个 Gamma 值的 CalGray;与性能相关的细节是白点处理:XYZ 到 sRGB 的转换矩阵对色空间中声明的白点进行了 Bradford 适应,并缓存于解析后的 THPDFColorSpace 记录中,因此无论声明的光源多么奇特,每个像素的工作都保持为单个 3x3 乘法
基于 ICC 的空间得到了刻意务实的处理;PDF 规范要求每个 ICCBased 流声明 /Alternate 空间,或通过其分量计数 /N 暗示一个,这正是为了让没有颜色管理引擎的查看器仍能合理地渲染;HotPDF 通过该替代空间解析 ICCBased,或者在条目缺失时通过 /N 为 1、3 或 4 分别猜测 DeviceGray、DeviceRGB 或 DeviceCMYK,并且从不解析配置文件字节;这意味着没有 lcms 依赖,也没有配置文件查找成本,代价是色度精度:如果 ICCBased 空间的配置文件与其替代空间发散强烈,则将显示替代空间的渲染;对于屏幕查看和缩略图,这是每个轻量级查看器都会做的相同交易,也是在您自己的文档中需要明确说明的边界
导致每个命名色空间查找都未命中的 bug
v2.333.0 操作符管道在出厂时带有一个缺陷,该缺陷在 42 个版本中都未被发现: cs 和 CS 处理程序对照没有斜杠(CS0)存储的资源字典键,查找了其带有斜杠的前导操作数(/CS0);每个命名的颜色空间查找都百分之百地未命中,代码默默地退回到了默认的 DeviceGray;可见的症状以最坏的方式显得微妙: 1 scn 的 Separation 填充变成了 DeviceGray 1.0,这绘制的是白色,而白色页面上的白油墨并不是任何人都会截图的渲染错误;在 v2.375.0 中的修复,是应用在每个操作数到资源查找处的共享名称规范化助手
在同一次调查中发现了两个同胞缺陷;首先,对数组值对象的间接引用在返回时未被解析:渲染器的文档访问仅针对流和字典配备了类型解析器,因此指向独立的 [/Separation ...] 数组的 /CS0 5 0 R 作为原始引用返回,空间解析为 unsupported;其次,HPDFReadNumericArray 执行了严格的长度语义,要求 PDF 数组至少与提供的缓冲区一样长;因此,将 Type 2 函数的 /C0 and /C1 读取到四元素缓冲区中对于一分量和三分量的替代方案失败了,导致两个数组都归零,每个非 CMYK 指数色调都渲染为黑色,直到 v2.376.0 引入了宽松的 HPDFReadNumericArrayUpTo 读取器;可借鉴的教训是:任何记录为持有可变长度数字数组的 PDF 键都必须用“填充已存在”的读取器进行读取,因为带严格匹配的固定大小缓冲区会将有效文件变为静默的零
如何测试专色渲染而不自欺欺人?
一个令人不舒服的问题是,为什么测试套件在这一切中都保持着绿色;最初的回归测试 SeparationRendersDistinguishable 仅断言渲染后的位图不是完全黑色的;将每个专色降级为 DeviceGray 的流水线产生了灰色和白色的输出,这并不是黑色的,因此断言通过了,而整个功能却是死掉的;“非空白”、“非全黑”或“摘要非零”形式的弱断言无法将工作的渲染器与损坏的渲染器区分开,因为几乎任何失效模式仍会产生一些像素
实际捕捉到这些失效的断言样式锁定了期望的颜色:渲染一个手工构建的最小 PDF,其 Separation 油墨解析为已知的色调,然后计算在该色调中占主导地位的像素;渲染到位图进行检查,使用的是页面到位图渲染指南中描述的相同 RenderLoadedPageToBitmap 入口点:
var
Pdf: THotPDF;
Bmp: TBitmap;
X, Y, RedHits: Integer;
Px: TColor;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('spot-red-fixture.pdf', '') > 0 then
begin
Bmp := Pdf.RenderLoadedPageToBitmap(0, 96);
try
RedHits := 0;
for Y := 0 to Bmp.Height - 1 do
for X := 0 to Bmp.Width - 1 do
begin
Px := Bmp.Canvas.Pixels[X, Y];
if (GetRValue(Px) > 180) and (GetGValue(Px) < 100) and
(GetBValue(Px) < 100) then
Inc(RedHits);
end;
// Lock the expected ink: demand a real area of red-dominant
// pixels, never settle for "not all black".
Assert(RedHits > 500);
finally
Bmp.Free;
end;
end;
finally
Pdf.Free;
end;
end;
测试夹具和断言同样重要;一个手工构建的、只有几百字节、带有一个 Separation 填充而没有其他内容的 PDF,对于期望的输出是什么,没有留下一丝模糊;现实世界的文件运行了更多的代码,但无法告诉您哪个阶段失败了;我们现在将期望颜色像素计数作为任何渲染冒烟测试的最低门槛,因为它是唯一将这些缺陷逼入公开状态的断言样式
专色和 CIE 色空间渲染是适用于 Delphi 和 C++Builder 的 HotPDF Component 中已加载文档流水线的一部分,同时也提供了上述的函数引擎、渐变色渲染和位图导出路径