HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,它计算三种由公式而非样本网格构成的 PDF 函数类型:Type 2 指数插值、Type 3 拼接函数和 Type 4 PostScript 计算器函数,分别对应 ISO 32000-1 §7.10.3、§7.10.4 和 §7.10.5。Type 2 沿曲线在两个输出向量之间进行混合,Type 3 在一个输入域上串联多个子函数,Type 4 运行受限的 PostScript 程序,可以分支、比较并根据输入计算内容流所需的几乎任何结果。三者中任何一个实现得稍有错误,故障都不会主动宣布自己是 bug,而会表现为带有死平带的渐变、渲染为纯黑的专色,或在测试套件恰好没有覆盖的输入处始终差一的计算器函数
这三种函数旁边还有第四种 Type 0,它存储采样网格而不是公式,相关内容另见Type 0 颜色查找表配套文章。这两个家族解决的是同一个问题,即把输入映射为输出,但 Type 0 是在其他地方计算好并写入文件的数据,而 Type 2、3 和 4 是阅读器在每次调用时计算的代码。四种类型都共享 HotPDF 渲染器中的一个分发点,该分发点由函数字典的 /FunctionType 条目决定,因此着色、色调变换或半色调专色函数无需在请求颜色前知道自己收到的是四种类型中的哪一种
PDF Type 2 指数函数如何工作
PDF Type 2 函数计算一个公式,即 y = C0 + x^N × (C1 − C0),并逐分量应用该公式,其中 x 是函数的单个输入,在公式运行前会根据 /Domain 进行归一化(ISO 32000-1 §7.10.3)。/C0 和 /C1 是该范围两端的输出向量,每个输出分量对应一个数字,/N 则是控制两者之间曲线形状的指数:N = 1 产生大多数渐变色标和双色调转换背后的直线斜坡,N 大于 1 会将曲线拉向 C0,N 介于 0 和 1 之间则会将曲线推向 C1。RegisterExponentialFunction 根据五个参数构造该字典,并返回一个函数对象,可直接用于着色、半色调专色函数或规范接受 /Function 键的其他位置
C0 和 C1 的分量数量关系在两个阶段都很重要:创建 Type 2 函数时重要,HotPDF 渲染一个并非由自己创建的函数时同样重要。在创建阶段,RegisterExponentialFunction 会相互检查 C0 和 C1,不一致时抛出异常,因此到达 BeginDoc 的调用已经拥有自洽的函数对象。但在渲染阶段,求值器必须信任源文件实际声明的 /C0 和 /C1 数组,例如打开用于预览的印刷厂文件,或向用户显示已签名的文档,而 2.376.0 以前的版本会将这些数组读入按四个分量分配的缓冲区,也就是 CMYK 情况。带有一个或三个元素 /C0 和 /C1 的 DeviceGray 或 DeviceRGB 指数色调在读取时会静默失败,并让两个数组保持为零,于是色调被绘制成纯黑而不是预期颜色。2.376.0 版本将读取器调整为函数实际声明的输出数量,不再使用固定缓冲区,这正是只有非 CMYK 测试用例才能暴露的 bug,因为原有测试全程使用 CMYK,四个分量写入四个分量始终不会越界
var
EaseIn: THPDFDictionaryObject;
begin
// Type 2: one input, N > 1 biases the ramp toward C0 (an ease-in curve)
EaseIn := Pdf.RegisterExponentialFunction(
[0,1], // Domain: single input, clamped to [0,1]
[0, 0, 0], // C0: output at x = 0
[0.8, 0, 0], // C1: output at x = 1
3, // N: exponent, 1 = linear, > 1 eases toward C0
[]); // Range omitted: defaults to a [0,1] clamp per output
end;
Type 3 拼接:通过 Bounds 数组串联子函数
PDF Type 3 函数会将 k 个子函数拼接成一个在单个输入 /Domain 上进行分段映射的函数,使其生效的是两个数组 /Bounds 和 /Encode(ISO 32000-1 §7.10.4)。/Bounds 包含 k − 1 个内部分割点,将 /Domain 划分为 k 个连续区间;求值器会选择上限大于输入的第一个区间,或者在输入达到最终边界后选择最后一个区间,然后将处理交给该区间的子函数。/Encode 接着把输入从它在该区间中的位置重新映射到所选子函数自身期望的输入范围,通常是 [0, 1],如果子函数是另一个指数分段,之后才会继续深入调用该子函数自己的 /Domain 和 /Range
HotPDF 的拼接求值器过去只处理恰好两个子函数,其 /Bounds 读取器还要求完整的八元素数组,因此两段渐变实际需要的单个分割点,即 /Bounds 中的一个数字,总是解析失败,函数也就不返回任何结果。/Encode 当时完全没有应用。2.376.0 版本按照规范描述的通用 k 子函数搜索重写了选择逻辑,并开始根据 /Bounds 的实际声明长度读取它,因此由三个、四个或五个指数分段拼接而成的三色标、四色标或五色标渐变,现在可以像两段渐变原本宣称的那样解析。下面的示例构建了一条黑色经过红色再到白色的两段渐变,这正是轴向或径向着色文章在单条指数曲线无法承载设计所需的每个色标时会使用的形状
var
ToRed, ToWhite, Ramp: THPDFDictionaryObject;
begin
// Two linear segments: black->red over [0, 0.5], red->white over [0.5, 1]
ToRed := Pdf.RegisterExponentialFunction([0,1], [0, 0, 0], [0.8, 0, 0], 1, []);
ToWhite := Pdf.RegisterExponentialFunction([0,1], [0.8, 0, 0], [1, 1, 1], 1, []);
Ramp := Pdf.RegisterStitchingFunction(
[0,1], // Domain: the stitched function's own input range
[ToRed, ToWhite], // Functions: k = 2 sub-functions
[0.5], // Bounds: k - 1 = 1 split point
[0,1, 0,1], // Encode: 2 numbers per sub-function
[]); // Range omitted: inherited from each sub-function
end;
Type 4 PostScript 计算器函数能做什么,而 Type 2 和 Type 3 做不到
PDF Type 4 函数运行一个真正但有意受限的程序:PostScript 计算器会把输入压入操作数栈,执行算术、比较、栈操作和布尔运算符以及 if/ifelse 条件语句,并在结束时把输出留在栈上(ISO 32000-1 §7.10.5,表 42)。它没有循环结构,也没有命名变量存储,只有栈,这让符合规范的程序更容易推理,但在这个受限的运算符集合内,Type 4 可以表达 Type 2 和 Type 3 无法表达的内容,例如用于 DeviceN 分色的真正多油墨混合公式,或带条件阈值的半色调专色函数。HotPDF 的求值器 HPDFEvalPostScriptCalculator 会先将程序标记化一次,包括数字、运算符和 { } 过程块,然后使用一个 100 项操作数栈逐步执行,这是 ISO 32000-1 §7.10.5 所要求的深度,同时设置最多计算 50,000 个运算符的硬上限,作为防御异常或手写程序的安全后备
roll 运算符:方向很容易弄反
roll 是第一次实现时最容易写反的运算符,因为它的参数顺序和旋转方向都与英语描述的直觉相反。n j roll 会弹出计数 n 和旋转量 j,然后将栈顶 n 个条目按 j 个位置循环移动,落到一端的项目会绕回另一端;规范中的经典示例是 a b c 3 1 roll 产生 c a b,栈顶项目会移动到该组底部,而不是相反,其他每个项目都会向上移动一位来腾出位置。HotPDF 的求值器将栈条目 i 的新位置计算为 (i + j) mod n,这与示例完全一致,但这个两行循环同样很容易把旋转写反,而镜像 roll 仍然会产生看起来合理的颜色,只是它并不是文件作者要求的颜色
const
Prog = '{ 3 1 roll }'; // (a b c) -> (c a b): the third input moves to the front
var
Reorder: THPDFStreamObject;
begin
// Type 4: 3 inputs, 3 outputs, no extra clamping beyond Domain/Range
Reorder := Pdf.RegisterPostScriptFunction(
[0,1, 0,1, 0,1], // Domain: 2 numbers per input
[0,1, 0,1, 0,1], // Range: 2 numbers per output (required for Type 4)
Prog);
end;
round 不是 Delphi 的 Round:四舍五入与银行家舍入
PostScript 的 round 运算符每次都会将 .5 平局向较大的整数处理,而 Delphi 内置的 Round 函数并非如此:它采用半偶数舍入,即银行家舍入约定,会交替决定 .5 平局的方向,从而避免重复舍入累积偏差。两者几乎处处一致,却恰好在这里重要的边界上不一致:Delphi 的 Round(0.5) 返回 0,Round(2.5) 返回 2,而 PDF 规范的 round 对相同输入要求返回 1 和 3,因此这种不匹配会在随意测试中隐藏起来,直到计算器程序的中间计算结果恰好落在半整数上,并在每个位置稳定地表现为差一。ISO 32000-1 §7.10.5 表 42 明确说明 round 会将小数 .5 推向较大的整数,因此 HotPDF 使用 Floor(x + 0.5) 实现该运算符,而不是调用 Delphi 的 Round,任何手动重新实现或抽查 Type 4 程序算术的代码也需要进行同样的替换
function PostScriptRound(const X: Double): Double;
begin
// ISO 32000-1 7.10.5 Table 42: round pushes .5 toward the greater
// integer. Delphi's Round() is banker's rounding and disagrees here:
// Round(0.5) = 0, Round(2.5) = 2 - both one short of the spec value.
Result := Floor(X + 0.5);
end;
注册时验证可以尽早捕获错误的计算器程序
格式错误的 Type 4 程序在创建时很容易捕获,在其他任何时候捕获都代价更高,因此 RegisterPostScriptFunction 不只是存储源文本:它会在函数对象写入文档之前,以声明的 /Domain 中点对程序进行一次试求值。不平衡的 { } 代码块、无法识别的运算符、栈下溢或与 /Range 不匹配的输出数量都会导致试运行失败并立即抛出异常,调用栈会指向 RegisterPostScriptFunction 调用,而不是指向文件已经交付后在质量检查期间才发现的渲染伪影。中点试运行并不能证明程序在整个 /Domain 上都正确,因为只在输入范围某一边缘出错的条件分支仍可能躲过单个采样点,但它可以排除整类结构上损坏而非仅在某个角落出错的程序
渐变和专色如何使用这些函数
在真实 PDF 中,Type 2、3 和 4 很少单独出现,它们会出现在规范接受 /Function 键的任何位置,最常见的两个使用者是着色和专色色调变换。轴向或径向渐变的 sh 运算符(ISO 32000-1 §8.7.4.5)会沿渐变轴的每个位置计算一次 /Function,这正是 Type 3 拼接函数存在的多色标场景。Separation 或 DeviceN 色彩空间的色调变换是这三种类型的另一个常见归宿,也是 Type 4 发挥作用的地方:单个专色油墨通常可以简化为 Type 2 或 Type 0 曲线,但多个油墨的 DeviceN 混合如果包含真实的陷印和叠印行为,通常需要只有 PostScript 计算器才能表达的条件逻辑,相关情况见渲染 Separation 和 DeviceN 专色的文章。RegisterSeparationFunc 是创建端的配对调用:它接收颜色名称、替代色彩空间以及 Register*Function 系列返回的任意对象,并将该色调变换接入 Separation 色彩空间资源,使页面其余部分可以通过 scn/SCN 选择它
Type 0 的采样网格与这三种公式驱动的类型结合起来,覆盖了 PDF 可以声明的所有 /Function,而选择哪一种主要取决于已有内容:在其他地方计算好的查找表使用 Type 0,两端点混合使用 Type 2,跨域串联多个混合使用 Type 3,包含真正条件逻辑的内容使用 Type 4。RegisterExponentialFunction、RegisterStitchingFunction 和 RegisterPostScriptFunction 都属于面向 Delphi 和 C++Builder 的标准 HotPDF Component,与其他 ISO 32000-1 函数和着色 API 一起提供