技术文章

Delphi 中的软遮罩与透明度扁平化

PDF Library for Delphi 从 Delphi 中编写两种 PDF 软遮罩。SetImageSoftMaskEx 把一张遮罩图像附加到目标图像上,还能写出预乘图形所需要的 /Matte 条目。NewSoftMaskGraphicsState 根据捕获的内容构建一个 alpha 遮罩或亮度遮罩,并返回一个图形状态,供你应用到后续的绘制上。而当某个消费端完全无法处理透明度时,FlattenTransparency 会渲染出视觉结果,并用它替换每一页

软遮罩正是 PDF 用来表达渐变淡出、暗角、照片融入背景,以及其他任何不透明度随位置变化的效果的方式。恒定的透明度只是图形状态中的一个数字;而软遮罩是一整个关于位置的函数,在你能编写软遮罩之前,整整一类设计效果都无法被再现

两种遮罩类型,各自读取什么

alpha 遮罩从遮罩内容的 alpha 通道中取值。亮度遮罩从遮罩内容的亮度中取值,因此一片白色区域是完全不透明的,一片黑色区域是完全透明的。亮度遮罩是设计师常用的那一种,因为绘制一个灰阶渐变是描述淡出效果最自然的方式

form 软遮罩由捕获的内容构建而成,其中若干细节是固定的,而不是可选的。捕获的内容会成为一个 DeviceRGB 色彩空间下的独立透明度组,因此遮罩会在一个已知的色彩空间中求值。背景色因此必须有三个分量,与该透明度组相匹配。传递函数接受 /Identity 或一个有效的函数对象,让你能够在不重新绘制图形的前提下重塑遮罩曲线

示意图对比亮度软遮罩与 alpha 软遮罩输入 NewSoftMaskGraphicsState 的过程,并提醒该图形状态会一直持续,直到被清除为止
亮度遮罩读取的是亮度,alpha 遮罩读取的是覆盖度,NewSoftMaskGraphicsState 把两者中的任意一种包装进一个会持续存在的图形状态
uses
  PDFlibrary;

var
  Lib: TPDFlib;
  MaskCapture, MaskGS, PhotoID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('brochure.pdf', '');

    // 第 4 页绘制了一个用作遮罩的灰阶渐变
    MaskCapture := Lib.CapturePage(4);

    MaskGS := Lib.NewSoftMaskGraphicsState(MaskCapture,
      PDF_SOFT_MASK_LUMINOSITY, '0 0 0', '/Identity');

    Lib.SelectPage(1);
    Lib.SetGraphicsState(MaskGS);
    PhotoID := Lib.AddImageFromFile('hero.jpg', 0);
    Lib.SelectImage(PhotoID);
    Lib.DrawImage(40, 700, 515, 260);        // 在遮罩下淡出

    // ExtGState 设置会持续存在:需显式结束这个作用域
    Lib.SetGraphicsState(Lib.NewSoftMaskGraphicsState(0,
      PDF_SOFT_MASK_NONE, '', ''));

    Lib.SaveToFile('brochure-masked.pdf');
  finally
    Lib.Free;
  end;
end;

最后那一步不是装饰性的代码。图形状态参数会一直持续,直到被改变为止,而之后应用一个普通的图形状态并不会清除一个仍处于激活状态的软遮罩。如果没有显式设置 /SMask /None,被遮罩图形之后绘制的一切内容都会继承这个遮罩,这是视觉上最令人费解的 bug 之一

图像遮罩与 Matte 条目

对于图像级别的遮罩,遮罩图像会先被归一化为 DeviceGray,因此无论以何种色彩空间提供的遮罩,其行为都是可预测的。可选的 /Matte 条目——依据图像色彩空间的不同,有一到四个分量——告诉消费端这份图像数据是已经按该颜色预乘过的,这可以防止把预乘图形当作直接 alpha 合成时出现的那种暗色光晕

示意图展示缺失 Matte 条目会产生暗色光晕,而声明了 Matte 则能干净地合成,旁边列出 PDF 1.4、PDF/A 和 PDF/X 的护栏规则
声明的 /Matte 能防止预乘图形出现暗色光晕,而 PDF/A-1 和 PDF/X-1 文件会在任何对象被修改之前就被拒绝

这里有两条护栏值得注意。PDF/A-1 和 PDF/X-1 禁止透明度,这个调用会在修改任何对象之前就直接拒绝,而不是产出一个之后校验失败的文件。而一次成功的软遮罩要求 PDF 1.4,本库会自动设置这一点——透明度在那个版本之前并不存在,一份包含软遮罩的文件却带着 1.3 的文件头,就是彻头彻尾的谎言

重复的状态会被复用:相同的捕获 form、遮罩类型、背景色和传递函数签名,会产出同一个图形状态对象,而不是每次调用都新建一个。在一份把一百张图片都以相同方式遮罩的文档上,这意味着九十九个对象根本不会被写出来

为什么要扁平化,扁平化实际做了什么

透明度至今仍是"屏幕上看着对、印出来却不对"这类问题最常见的成因。较老的 RIP、一些监管申报系统,以及 PDF/X-1a,都要求文件完全不含任何活的透明度

这里的扁平化是刻意做到字面意义上的彻底:请求范围内的每一页都会按选定的 DPI 在一个不透明背景上渲染出来,页面内容和资源则被替换成这一张图像。因为渲染器产出的是最终合成结果,这一步能一次性覆盖所有情况——透明度组、混合模式、软遮罩、图案和嵌套 form——不需要针对每种构造分别写一套容易出错的近似逻辑。栅格化的内存峰值也被限定在一页以内

FlattenTransparency 流水线示意图:以 300 DPI 在不透明背景上渲染每一个请求的页面,并保存为一份适合印刷的扁平 PDF 1.3 文件
每一个请求的页面都以选定的 DPI 渲染一次,返回为一张单独的不透明图像,保存进一份如实标注的 PDF 1.3 文件
// 把第 1 到第 8 页以 300 DPI 在白色背景上扁平化,再存为 PDF 1.3
Lib.FlattenTransparency('1-8', 300, $FFFFFF, 0);
Lib.SaveToFile('press-ready.pdf');

代价同样彻底:输出的是一张图像,因此文本不再可选中或可搜索,矢量边缘也会按选定的 DPI 重新采样。扁平化是一个交付步骤,不是一个编辑步骤。请保留带透明度的原始文件,只对副本做扁平化

旋转的页面、用户单位与页面框

一个旋转过的页面需要一份朴素实现常常忽略的细心处理。渲染之后,视觉宽度和高度会被写回 MediaBox,用户单位会被折算进有效 DPI 和新的页面尺寸,而 /Rotate 条目、用户单位以及辅助页面框都会被移除

跳过这一步会产出这样一份文件:扁平化后的图像本身是正确的,但查看器又把它旋转了一次,或者按一个已经不再适用的用户单位对它做了缩放。移除这些框而不是重新计算它们,是正确的选择,因为一个描述着扁平化之前几何形状的 TrimBox,比根本没有 TrimBox 更糟

默认情况下,注释和表单字段会被渲染进这张静态图像,交互对象则会被移除,这正是一份适合印刷交付的成品所需要的。如果你选择保留交互对象,渲染器的注释渲染步骤则会被关闭,以免同一内容被绘制两次

会扫描一切内容的版本检查

要另存为 PDF 1.3,要求文件中任何地方都不再残留透明度,保存前的检查会检视每一个间接对象,而不仅仅是从页面可达的那些对象。这比乍看起来更严格,也是正确的:一次早先编辑遗留下来的孤立软遮罩,依然留在文件里,而一个按对象而不是按页面遍历的消费端会发现它

为了让降级切实可行,默认情况下会在每个目标页面都成功替换之后运行一次标记清除,释放那些不可达的对象。需要提前规划应对的后果是:在这次清除之前获取到的图像、捕获页面和图形状态标识符,在此之后可能不再有效。要么在继续编辑之前重新获取所需的资源标识符,要么禁用这次清除,并接受版本号此后无法被降低这一事实

还有两块相关内容能补全这幅图景。使用着色和渐变而不是遮罩的矢量图形,见 矢量图形、着色器与渐变,当效果可以用着色表达时,这往往是更好的路径。而如果你做扁平化的原因是颜色要求而不是透明度问题,把文档重新着色到另一色彩空间 中的转换路径能保留活文本,而扁平化做不到这一点

软遮罩编写、扁平化及其背后的渲染引擎,都打包在同一个面向 Delphi、C++Builder 和 Free Pascal 的库中;完整功能列表见 PDF Library for Delphi 页面