技术文章

在 Delphi 里把 PDF 转换为同一种色彩空间

用 1 0 0 rg 填充的纯红标题,经灰度转换回来变成 0.299 g。纯蓝落在 0.114 g,与黑足够接近,深蓝 logo 和正文从此难以分辨。这些数字不是舍入的意外:它们是 BT.601 亮度权重,阅读器在灰设备上显示 RGB 页面时用的正是同一套权重,也由它们决定一本彩色宣传册变成灰度时还能保住多少对比度

RecolorDocument 对每种颜色算什么

PDF Library for Delphi 把这个转换暴露为 TPDFlib 上的 RecolorDocument(TargetSpace),1 是灰度、2 是 RGB、3 是 CMYK。它返回转换了多少处内容,几何、文本位置与图像摆放原地不动。只有颜色值和围绕它们的色彩空间名会变

每种颜色在通往目标的路上都经过 RGB。灰度是亮度加权和 0.299 R + 0.587 G + 0.114 B。CMYK 到 RGB 用的是 ISO 32000-1 §10.3 那条朴素的设备规则:每个通道等于一减去自身油墨与黑的和,再钳到零。RGB 到 CMYK 则把最大分量抽进黑:K = 1 - max(R, G, B),其余油墨按剩下的量缩放。送印之前有几个结果值得知道:

  • 灰度 0.4 g 转 CMYK 得到 0 0 0 0.6 k,只有黑墨,这正是印刷机想要的文字与线条形态
  • RGB 1 0.5 0(橙色)变成 0 0.5 1 0 k,一点黑都没有
  • 0.6 0.4 0.4 1 k 这样的 rich black 在转灰度的路上变成 0 g,多余的油墨直接消失
  • 纯青 1 0 0 0 k 变成 0.701 g,比多数人对饱和油墨的预期更浅

转换出的操作数最多写三位小数,且一律用句点作小数分隔符,不管跑任务的机器是什么 locale。ISO 32000-1 §7.3.3 不允许内容流里出现别的东西;按系统 locale 格式化数字的转换器,在德语工作站上会写出 0,5 g,阅读器会把它解析成数字 0 跟着一个未知操作符

var
  Lib: TPDFlib;
  Converted: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('brochure.pdf', '') <> 1 then
      raise Exception.Create('Could not open brochure.pdf');
    Converted := Lib.RecolorDocument(1);   // 1 = 灰度,2 = RGB,3 = CMYK
    WriteLn(Format('%d colors, images and gradients converted', [Converted]));
    Lib.SaveToFile('brochure-gray.pdf');
  finally
    Lib.Free;
  end;
end;

颜色已在目标空间里的页面保留原始内容字节。RecolorDocument 只统计真正跨空间转换的次数,所以已经是灰度的文档报 0,其中什么都不重写。大文件上这很重要:输入输出的 diff 能精确显示哪些页面变了

为什么 RecolorPage 留下彩色封面照不管?

RecolorPage 只转换所选页面上的颜色操作符,到此为止。图像和渐变是文档级对象,常常被几页共享,只有 RecolorDocument 才转换它们。于是“只转封面”的直觉做法——选中第 1 页调 RecolorPage(3)——给你的是 CMYK 标题和线条压在一张 RGB 照片上

这个切分是刻意的。封面画的一张图像 XObject 可能第 12 页也在画,为某一页转换它就等于为两页都转换。页面范围的调用若悄悄伸进共享资源,改的就是调用者从没选过的页面。那是两种意外里更糟的一种,所以按页调用只碰属于页面自己的东西

// 只转换封面上的操作符;图像和渐变不在此转换
Lib.SelectPage(1);
Changed := Lib.RecolorPage(3);
if Changed = 0 then
  WriteLn('Cover already uses CMYK operators (or the target was out of range)');
Lib.SaveToFile('cover-cmyk-operators.pdf');

// 整份文档必须统一空间的场合,全量转换
Lib.RecolorDocument(3);
Lib.SaveToFile('all-cmyk.pdf');

上面那段代码里要盯住返回值。目标超出 1 到 3 时两个调用都返回 0,而无需修改的页面同样返回 0,所以目标参数打错一个字,看起来跟“文档本来就没问题”一模一样。调用前自己校验参数。RecolorDocument 会在结束时恢复它运行前选中的页面,所以逐页作业中途调用它,之后不必重新 SelectPage

还有一个作用域细节。操作符这一趟读的是每页自己的内容流——页面本体列出的绘制指令——并转换 g、rg、k、它们的描边形式,以及当前空间是设备空间时的 sc / scn。当 /DeviceRGB cs 出现在被转换的 sc 之前,空间名也会改写成目标空间。手写的转换器若漏掉这个改名,阅读器就会在一个灰度值的位置找三个 RGB 分量

图像与调色板:哪些变、哪些字节原样保留

图像和操作符一样要紧。一本只转了操作符的宣传册,灰度页面上依然印着彩色照片,观感比原版和全量转换都糟糕。RecolorDocument 转换一张图像的条件是:每分量八位、设备色彩空间,且要么未压缩、要么是无 decode 参数的普通 Flate

索引图像走另一条路。它的样本是颜色表的索引,所以被转换的是表本身,每个样本保留原有的索引值。存成 256 色调色板图像的截图或图表,无论样本怎么打包、怎么压缩都按这条路转换,因为样本从不解码。调色板的基底必须是设备空间;架在 ICC profile 上的调色板保留原表

示意图:展示 Delphi 中 RecolorDocument 会重写 PDF 的哪些部分,而 JPEG 图像和软掩码等部分则逐字节原样通过
RecolorDocument 一轮处理会改写操作符、符合条件的图片、索引调色板和渐变对,而 JPEG 字节、蒙版和绑定 profile 的颜色原样保留

JPEG 或 fax 编码的图像字节原样保留。改它的颜色意味着解码、转换、重压缩,会添上第二代 JPEG 伪影,还以谁都没要求的方式改变文件大小。任务真需要每张照片都是灰度时,重编码是一个单独的决定,有自己的质量参数——我们宁可让调用者显式做这个决定,也不让一次颜色转换替他们做

软掩模和模板掩模原地不动,理由则不同。软掩模通常是 DeviceGray 图像,但它的值表示的是覆盖度。把它转成三分量 RGB,阅读器就不再把它当透明度处理;它会被当作灰色画作涂在它本该塑形的内容上面。RecolorDocument 在转换任何图像之前,先收集经 /SMask 或 /Mask 引用的每一个对象,而带 /ImageMask true 的图像本来就没有颜色可转

操作符层面经 ICCBased、Separation、Indexed 或 Pattern 空间设置的颜色也按原样保留。不读 profile 就把专色或带 profile 标记的值转成设备灰,页面观感会变,而一趟以保持外观为本职的转换不能开始瞎猜 profile 的含义

渐变:色彩空间与色标一起动

shading 只声明一次色彩空间,把每个色标交给一个函数。函数还在返回三个数的时候把 /ColorSpace 从 DeviceRGB 改成 DeviceGray,阅读器就取其中第一个——红通道——当灰度。一张蓝到白的渐变从此渲染成一条从黑开始的色带。RecolorDocument 先以只读方式走完整棵函数树,树里每个节点都可转换才动手;然后才重写色标、改名空间

它重写的函数是指数型——两个端色 /C0 与 /C1 是普通数组——以及由它们构建的 stitching 函数。/Background 颜色随同转换。由采样表或 PostScript 程序定义的 shading 保留原定义,mesh 类型 4 到 7 也是,它们把颜色存在流里而不是函数里。重写这两者属于重新生成,与转换颜色是两回事

PDF Library for Delphi 示意图:渐变转换将着色颜色空间与其渐变函数在独立对象、图案和页面着色资源间配对,而采样式、PostScript 和网格着色保持各自定义
转换渐变意味着无论着色位于何处,其色彩空间与其后的色标都要一起移动;网格或采样函数着色则保留自己的定义

找到渐变本身就是个坑。设计工具很少给 shading 单独的对象;它通常内联在绘制它的 pattern 字典里,或页面 /Shading 资源里。我们做渐变这一趟时,扫顶层对象几乎一无所获。现在搜索沿着对象图走,深度以十二层为界、每个对象号只访问一次,于是每页共享的资源只转换一次,把对象指向自身的文件也无法成环

重着色在送印前的合适位置

把 RecolorDocument 当作归一化来看。它在设备空间之间转换数值;不套用 ICC profile、不理会 rendering intent、也不模拟印刷条件。要匹配合同打样的活,profile 驱动的工作流才是对的工具,重着色这一趟至多是让它跑起来之前把所有东西归进一个空间的步骤

它真正的用武之地是日常生产场景:必须灰度交付却带着 RGB logo 的来稿、不许彩色打印的内部报告、在 preflight 步骤放行前每个文件都得统一空间的批次。凡是出门的文件都应该过 RecolorDocument,绝不能只跑 RecolorPage,因为半吊子转换的文件能过快速目检,然后死在打印机上。判定也在同一个作业里跑:PDF/A 与 PDF/UA preflight 和合规与签名工作台的笔记演示了怎么做

// IsValidRecolorTarget 是你自己加的辅助函数;库里没有这个调用
function IsValidRecolorTarget(Space: Integer): Boolean;
begin
  Result := (Space >= 1) and (Space <= 3);
end;

procedure NormalizeFile(const Src, Dst: string; Space: Integer);
var
  Lib: TPDFlib;
begin
  if not IsValidRecolorTarget(Space) then
    raise EArgumentException.CreateFmt('Bad recolor target %d', [Space]);
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Src, '') <> 1 then
      raise Exception.CreateFmt('Could not open %s', [Src]);
    if Lib.RecolorDocument(Space) = 0 then
      WriteLn(Src, ': already in the target space, copied unchanged');
    Lib.SaveToFile(Dst);
  finally
    Lib.Free;
  end;
end;

对输出可以跑一个快速检查:灰度转换之后,随便用什么 PDF 检查器打开转换过的页面内容。页面本体的内容里不应再有任何 rg、RG、k 或 K 操作符。这趟过后幸存的颜色来自上面列举的情形:JPEG、带 profile 标记或专色、采样或 mesh 渐变,或者画在 form XObject 里的图——它的指令住在自己的流里,不在页面内容中

如果你不只是转换、还要亲手构建矢量图稿,矢量图形、着色器与渐变一文演示 shading 和 pattern 最初是怎么创建的,读过之后上面的渐变规则就更容易预判

颜色这一趟在进程内跑:PDF Library for Delphi 是面向 Delphi、C++Builder 和 Lazarus 的原生 Pascal 库,服务器上归一化一批来稿不需要外部转换器或命令行工具。PDF Library for Delphi 产品页列有其余文档处理 API