技术文章

Delphi EPS 导入:gsave 路径与 imagemask 极性

放到 PDF 页面上的一枚 EPS 徽标,可能轮廓缺失、单色标志印成自己的负片,或者 Æ 变成 á。每个症状都标记一处 PostScript 与 PDF 图形语义的分歧,所以 Delphi 的 PDFlibPas EPS 导入解释 PostScript 程序,把每处差异写成原生 PDF 算子

解释程序,还是原样透传

PostScript 能以两种方式待在 PDF 里,只有其中一种会在屏幕上显示点什么。ISO 32000-1 §8.8.2 定义了 PostScript XObject——一种 /Subtype /PS 流,其字节不经解释、只在输出设备是 PostScript 打印机时才复制进打印作业;在其他一切场合它毫无效果,§8.8 概览也说不应使用 PostScript XObject。PDF/A 干脆排除它们。TPDFlib.NewPostScriptXObject 与 DrawPostScriptXObject 写出的是那种透传形态,而 ImportEPSFromFile 与 DrawPostScript 让程序过一遍解释器、发出普通的路径、行内图像和文字。插画走解释器;PS XObject 留给专门面向某个输出中心 RIP——印刷机或生产打印机之前的光栅图像处理器——的片段。解释 EPS 也不同于EMF 矢量导入,后者的输入是一串待重放的 GDI 记录;EPS 文件是一个程序

procedure AddPrintRoomHook(PDF: TPDFlib);
var
  PSRef: Integer;
begin
  // 以 /Subtype /PS 流原样存储,用 Do 引用
  // 只在 PostScript 打印机上执行;阅读器什么都不画
  PSRef := PDF.NewPostScriptXObject(
    'userdict /PrintRoomMark known { PrintRoomMark } if');
  if PSRef <> 0 then
    PDF.DrawPostScriptXObject(PSRef);
end;
PostScript 执行模型与 PDF 分道扬镳之处,以及逐算子直译的产物
PostScript 行为PDF 行为直译的结果
gsave / grestore 保存并恢复当前路径与当前点q / Q 不碰路径,绘制一次路径即告终结(§8.5.1)grestore 之后的轮廓或续接线段消失
clip 为后续绘制保住当前路径W 后面跟着绘制算子——通常是 n——路径就此终结裁剪之后的描边无物可画
ImageMatrix 把用户空间映射到图像空间每张图像填满单位正方形,首行朝顶(§8.9.4)自底向上或旋转的图像落地时翻转或错位
true imagemask 画的是 1 位蒙版用默认 Decode [0 1] 画的是 0 位(§8.9.6.2)单色徽标印成自己的负片
Roman 类 Standard-14 字体默认用 StandardEncodingStandardEncoding 这个名字没有任何特殊含义(§9.6.6.1)重音、引号与连字换错字母
%%BoundingBox 与 %%HiResBoundingBox 注释界定画面区域摆放全看写出器发出什么矩阵画面移位或缩放出预定框之外

grestore 之后轮廓去了哪里?

轮廓消失是因为 PDF 从没保存过路径。ISO 32000-1 §8.5.1 一句话说清:“与 PostScript 不同,PDF 的当前路径不属于图形状态”,路径一经绘制就不存在当前路径,直到 m 或 re 开启新路径。PostScript 产出方依赖的恰是相反的规则。画一个带轮廓的填充形状,惯用手法是 gsave fill grestore stroke:填充消耗路径的一份保存副本,grestore 把路径带回来,描边画的还是同一副几何。把 gsave 映射成 q、fill 映射成 f、grestore 映射成 Q,描边到达时手里是空路径——形状填上了,轮廓没了。续接被恢复路径的 lineto——跟在 grestore 之后——同样失败,表现为一段缺失的线段

Delphi 的 PDFlibPas EPS 导入中 PostScript gsave fill grestore stroke 与直译 q f Q S 的时间线对比
直译的 q f Q 映射在填充处终结路径,所以 PDFlibPas 另存一份私有 PostScript 路径,grestore 之后的描边仍找得到自己的几何
uses
  SysUtils, PDFlibrary;

const
  // 带轮廓的填充三角形:填充在 gsave/grestore 之内进行,
  // grestore 之后的描边复用被恢复的路径
  TriangleEPS =
    '%!PS-Adobe-3.0 EPSF-3.0'#10 +
    '%%BoundingBox: 0 0 200 120'#10 +
    'newpath 20 20 moveto 180 20 lineto 100 100 lineto closepath'#10 +
    'gsave 0.85 setgray fill grestore'#10 +
    '3 setlinewidth 0 setgray stroke'#10;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);   // Left 与 Top 从左上角量起
    // Width 与 Height 为 0 保持 200 x 120 的边界框尺寸
    if PDF.DrawPostScript(72, 72, 0, 0, TriangleEPS) = 1 then
      PDF.SaveToFile('triangle.pdf')
    else
      Writeln('DrawPostScript failed, code ', PDF.LastErrorCode);
    // 预期:浅灰三角形带 3 单位黑色轮廓
    // 直译 q/f/Q/S 会填满三角形、丢掉轮廓
  finally
    PDF.Free;
  end;
end.

PDFlibPas 保留自己的 PostScript 路径,独立于 PDF 内容流此刻装着什么,只在有东西要绘制或裁剪时才写出 PDF 路径。每个点都按 moveto 或 lineto 执行当时生效的变换矩阵固定下来,与 PostScript 一致——在那边,后来的 scale 或 concat 从不挪动已在路径上的点。currentpoint 把存下的点经当前矩阵映射回去,以当下的用户空间作答。一次 gsave 记录路径链末端的引用,外加当前点、矩阵、字体和颜色空间,嵌套保存因此从不复制整条路径

裁剪是两种路径模型再次分岔的地方,导入在这里也跟随 PostScript:clip 与 eoclip 为 PDF 侧发出 W n,却为紧随其后的描边把逻辑路径留在原地;rectclip 在 PostScript 里新起一条空路径,于是发出 re W n 并把路径清掉。路径存储上限 10 万节点,重放消耗共享的算子预算,恶意文件没法靠循环 gsave 与 stroke 把输出撑大

摆放图像:ImageMatrix 指的是另一个方向

把 ImageMatrix 当摆放矩阵用,EPS 图像落地就是翻的——因为它描述的是反方向的映射。PDF 侧,§8.9.4 把图像边界系在用户空间的单位正方形上、首样本行朝顶,隐含的图像到用户矩阵是 [1/w 0 0 -1/h 0 1]。PostScript 的 ImageMatrix 把用户空间映射进图像空间。所以 PDF 摆放矩阵是 [w 0 0 -h 0 h] 乘以 ImageMatrix 的逆。对常见的自顶向下矩阵 [w 0 0 -h 0 h],乘积是单位矩阵——天真的转换器在随手一用时看着没错,原因就在这。以 [w 0 0 h 0 0] 写出的自底向上扫描得到 [1 0 0 -1 0 1],单位正方形内的一次竖直翻转;旋转或斜切的矩阵无需特判。有限数据源提前耗尽时,PDFlibPas 只画完整的行、保留原 ImageMatrix、按实际到达的行数缩放,被截断的图像以正确比例填满其区域的顶部条带;把幸存的行拉伸到全高,会得到一幅看似完整实则变形的图

Delphi 的 PDFlibPas EPS 导入中从 PostScript ImageMatrix 惯例推导 PDF 摆放矩阵的示意图:正立与翻转两种结局,以及截断行的处理
摆放矩阵是 PDF 单位正方形映射乘以 ImageMatrix 的逆,自顶向下的画面正立落地,只有自底向上的扫描翻转

imagemask 画的是哪一位?

极性操作数为 true 时,imagemask 画的是 1 位,在 PDF 里就是 Decode [1 0]。ISO 32000-1 §8.9.6.2 把 [0 1] 定为图像蒙版的默认:0 样本以当前颜色落页,1 样本不动背景;[1 0] 正好反过来。PostScript 布尔值说的是哪个样本值落笔。把它读成“反转 PDF 默认”,徽标就印成负片——一块实心、标志被抠掉。PDFlibPas 把 true 映射为 [1 0]、把 false 映射为 [0 1],蒙版以带 /IM true 的行内图像发出,于是它以程序设置的颜色落笔;字典形式则只接受这两个 Decode 数组

Delphi 的 PDFlibPas EPS 导入中 PostScript imagemask 极性到 PDF Decode 数组的映射:true 画 1 位,false 印出负片
PostScript 布尔值点名的是落笔的样本值,把 true 映射到 Decode [1 0] 单色徽标就不会印成负片

12 位样本藏着一个小些的错位。PDF 没有 12 位图像;§8.9.5.1 允许每分量 1、2、4、8 位,PDF 1.5 起还有 16 位。每个样本左移四位塞进 16 位不丢任何东西,但最大值变成 65520 而非 65535,默认 Decode 下每个亮样本都暗约 0.023%。导入保留位移,并把 Decode 上端点拉伸 65535/65520,每个归一化值因此复原。12 位图像自动把输出版本抬到 PDF 1.5;PDF/A 或 PDF/X 模式下,或输出版本锁在 1.5 以下时,导入在动页面之前以错误 538 停下

Æ 为什么变成 á?

PostScript 里的 Roman 字体默认用 StandardEncoding,除非程序自己重编码,而 PDF 点不了那张表的名:ISO 32000-1 §9.6.6.1 不给这个名字任何特殊含义,预定义选项只剩 WinAnsiEncoding、MacRomanEncoding 和 MacExpertEncoding。附录 D.2 的表显示了代码原封不动交给 WinAnsi 会出什么事:八进制 341(0xE1)在 StandardEncoding 是 AE、在 WinAnsi 是 aacute,Æ 于是变成 á;0xAE 在这边是 fi 连字、在那边是 ®;0x27 在 StandardEncoding 是 quoteright、在 WinAnsi 是直引号 quotesingle,排版撇号就此退化成打字机引号。程序用到的每个 Standard-14 Roman 字体,PDFlibPas 都写一个私有 Type 1 字体资源,其 /Encoding 字典带 /BaseEncoding /WinAnsiEncoding 和一个按 StandardEncoding 顺序点名全部 256 个码位的 /Differences 数组,任何码位都不再取决于阅读器怎么填它的基表。宽度取自同一份编码,ToUnicode 映射让复制和搜索还能得到 Æ,Symbol 与 ZapfDingbats 保留内置码。导入之前你的代码选定的字体纹丝不动——为迁就 EPS 重编码缓存字体,会改掉已经用它排好的文字

这些资源点名字体却不嵌入,与要求嵌入的 profile 相抵触。为 PDF/A、PDF/X 或 PDF/UA 配置的文档里,或 SetEmbedAllFonts(1) 之后,EPS 文字以错误 538 失败、页面保持原样。归档输出的干净路线是导出时就已把文字转成轮廓的 EPS——任何模式下都作为普通路径导入;既有文件里已经没嵌入的字体,则需要为 PDF/A 嵌入缺失字体。EPS 文字按精确名称映射到 14 款标准字体;自带字体程序的插画一旦文字转成轮廓就能干净导入,也免得本地替换字体把每一行都挪位

边界框、DSC 注释与二进制 EPS 文件

摆放把画面的边界框缩放进你传入的矩形,所以框必须来自正确的地方。PDFlibPas 只从真正的 DSC 注释行读 %%BoundingBox 和 %%HiResBoundingBox——指顶层的、起始于行首的注释——有效的高分辨率框胜过整数框。朴素的子串搜索还会在字面字符串、过程体和二进制图像数据里命中这些关键字,EPS 文件三样都带。头部值 (atend) 把数字推迟到 trailer 里的注释,扫描器走到那儿才读;完全没有框的文件退到 612 × 792,空框或倒置的框以错误 124 失败。Width 与 Height 各自独立缩放框:两个都传 0 保持自然尺寸,或按框的纵横比两个都传——只传一个非零值会把画面沿一根轴拉长

二进制数据决定入口。DrawPostScript 接受 WideString,代码里拼出的 PostScript 用它没问题;但原始图像样本、经 currentfile 读的 Flate 数据和 DOS EPS 包装里都有过不了 Unicode 文本这一趟的字节。ImportEPSFromFile 按原始字节读文件,认出 C5 D0 D3 C6 预览头,检查 PostScript 段的偏移与长度落在文件之内,只执行那一段、无视 TIFF 或 WMF 预览。两个调用至多吃 4 MiB 源、要求 Options = 0、返回 1 或 0,要么追加整幅画、要么什么都不追加。失败原因在 LastErrorCode 里——读取即复位——所以要一次取走

uses
  SysUtils, PDFlibrary;

function PlaceEPS(PDF: TPDFlib; const FileName: string;
  Left, Top: Double): Boolean;
var
  Code: Integer;
begin
  // 0, 0:按 %%HiResBoundingBox 或 %%BoundingBox 的自然尺寸
  Result := PDF.ImportEPSFromFile(Left, Top, 0, 0, FileName) = 1;
  if Result then
    Exit;
  Code := PDF.LastErrorCode;   // 读取即清码,所以只读一次
  case Code of
    402:
      Writeln(FileName, ': file cannot be opened');
    PDFLIB_ERROR_POSTSCRIPT_INVALID:      // 124
      Writeln(FileName, ': malformed PostScript, bad bounding box or nothing painted');
    PDFLIB_ERROR_POSTSCRIPT_UNSUPPORTED:  // 538
      Writeln(FileName, ': construct the import does not handle, or Standard-14 text in an archival profile');
    PDFLIB_ERROR_POSTSCRIPT_BUDGET:       // 539
      Writeln(FileName, ': source over 4 MiB or a work limit reached');
  else
    Writeln(FileName, ': import failed with code ', Code);
  end;
end;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);
    if PlaceEPS(PDF, 'logo.eps', 40, 40) then
      PDF.SaveToFile('letterhead.pdf');
  finally
    PDF.Free;
  end;
end.

错误 538 点名某个算子时,回编辑器把画面重导出为文字已转轮廓的普通 EPS:这通常比追那个算子快,而且转成轮廓的画面放进用 PDFlibPas Delphi PDF Library 构建的 PDF/A 或 PDF/UA 文档时,也没有 Standard-14 字体冲突