技术文章

PDF 中的彩色 Emoji 字体:COLR v1、SVG 与位图

用一次普通的文本调用把一张笑脸 emoji 写进 PDF,字体用 Segoe UI Emoji,出来的是一个纯黑的剪影:形状对,没有黄色,没有明暗。换成 CBDT emoji 字体,同一次调用可能什么都不画,因为那种字体把图片存成 PNG 文件,轮廓数据很少甚至没有。从阅读器里把那个黑形状拷贝出来,剪贴板里仍是 U+1F600:字符没错,只是颜色表从来没被读过

为什么彩色 emoji 在 PDF 里印成黑色剪影?

因为 PDF 字体程序没有“彩色字形”这个概念。ISO 32000-1 把每个字形都当成用当前填充色涂色的形状,而 OpenType 后来加入的颜色表(COLR/CPAL、SVG 、CBDT/CBLC 和 sbix)不属于 PDF 成像模型,所以任何阅读器都没有理由从嵌入字体里去读它们。emoji 有什么颜色,必须在写 PDF 的时候就被翻译成页面内容——在生成方还握着字体字节、确切知道自己要哪个字形的那一刻。这种翻译对每种格式各不相同,而真实的 emoji 字体四种都用:堆叠的矢量层、渐变绘制图、整份 SVG 文档和 PNG strike

HotPDF 在 THotPDF.DrawRegisteredColorGlyph 里做这个翻译。它把字形解析到的格式报告为 THPDFOpenTypeColorFormat,取值 otcfNone、otcfCOLRv0、otcfCOLRv1、otcfCBDT、otcfSVG 和 otcfSBIX,并按固定顺序探测字体:先 COLR,再 SVG,再 CBDT,最后 sbix。于是同时带矢量和位图的字体走矢量路径。对任何可能被放大或打印的东西,这个顺序都是对的

HotPDF 彩色字形探测图解:PDF 字体程序用当前颜色涂字形轮廓,所以 OpenType 彩色表 COLR、SVG、CBDT 和 sbix 必须在生成时翻译成页面内容;HotPDF 按固定优先级探测注册的字体——先 COLR、再 SVG、再 CBDT、再 sbix,并以 otcfCOLRv0 到 otcfSBIX 报告 THPDFOpenTypeColorFormat
字体同时带矢量和位图时矢量赢——在可能被放大或打印的文档里这正是你要的——而没有彩色路径的字形交给你的兜底处理

解析并绘制单个码点

THotPDF.GetRegisteredColorGlyphInfo 告诉你一个码点会走哪条路;DrawRegisteredColorGlyph 负责画。两者都在最近一次传给 RegisterUnicodeTTF 的字体的字符映射表里查这个码点,所以调用那一刻注册的 Unicode 字体必须是这个 emoji 字体。字形没有颜色数据、或没有任何东西能渲染它时,绘制函数返回 False,页面原样不动

const
  FormatNames: array[THPDFOpenTypeColorFormat] of string =
    ('none', 'COLR v0', 'COLR v1', 'CBDT', 'SVG', 'sbix');
var
  Pdf: THotPDF;
  Info: THPDFOpenTypeColorGlyphInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'emoji.pdf';
    // 调色板条目 0xFFFF(“文本前景色”)解析成这个颜色
    Pdf.ColorGlyphForegroundColor := clNavy;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\seguiemj.ttf');

    // U+1F600,CPAL 调色板 0,最接近 300 ppem 的位图 strike
    if Pdf.GetRegisteredColorGlyphInfo($1F600, 0, 300, Info) then
      Writeln(Format('GID %d via %s',
        [Info.GlyphID, FormatNames[Info.Format]]));

    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F600,
      72, 144, 'Segoe UI Emoji', 36, 0, 300) then
    begin
      // 没有彩色路径:回退到单色轮廓
      Pdf.CurrentPage.SetFont('Segoe UI Emoji', [], 36, DEFAULT_CHARSET);
      Pdf.CurrentPage.TextOut(72, 144, 0, WideString(#$D83D#$DE00));
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

PaletteIndex 选择一个 CPAL 调色板,于是一个自带深色背景调色板的字体可以不经字形改动直接切换。调色板条目 0xFFFF 在 OpenType 规范里很特殊:它的意思是“文本颜色是什么就用什么”,HotPDF 从 ColorGlyphForegroundColor 和 ColorGlyphForegroundAlpha(默认黑和 255)填它。emoji 字体很少用这个条目,图标字体常用——如果你的字体经它给字形着色,画之前把这个属性设成你的文本颜色

TargetPixelsPerEm 只对位图字体有意义,而它的默认值恰恰是最值得覆盖的参数。留 0 时它变成 Round(FontSize * 96 / 72),一个屏幕分辨率:36 pt 的 emoji 得 48 像素。对一个声明了 20、32、40、48、64、96、160 ppem 各档 sbix strike 的字体,这个默认会选中 48 像素那档,然后 600 dpi 的打印机得把它放大约六倍。要 300——36 pt 在 600 dpi 下需要的值——同一次调用就会拿 160 那档,字体里最大的那档

函数签名也钉死了作用范围:进去的是一个码点,经 cmap 映射。ZWJ 家庭组合、肤色修饰符和区域指示旗标是 GSUB 连字,得先整形成字形才行——这类活在OpenType GSUB 备选字形一文里讲

COLR 层与绘制图变成 PDF 矢量

COLR v0 是简单格式。每个基础字形列出一叠层字形,各带一个 CPAL 颜色,HotPDF 按表序填充每层的轮廓。CPAL 颜色 alpha 低于 255 的层有自己的 /ca,半透明层于是与下层混合,和字体设计者的意图完全一致。普通文档里,整叠随后被捕获进一个孤立的透明度组(ISO 32000-1 §11.4)。这个组关心的是字形外面发生的事:你在 emoji 周围设的不透明度、混合模式或软蒙版,只对画完的字形应用一次,而不是每层重复一遍、在层与层重叠的地方翻倍

COLR v1 是另一种动物:一张由绘制记录构成的有向无环图,带渐变、变换、嵌套的字形裁剪和合成模式,节点可以经 PaintColrLayers 和 PaintColrGlyph 共享。HotPDF 先把它解析成一个有界图——至多 4096 个绘制节点、96 层深度、1024 个颜色停靠点——并在访问每个节点时做跟踪,于是指回当前路径的引用(敌意字体用层的复用造出来的环)会被拒绝,而不是被递归进去

偏移是手写 COLR v1 解析器通常栽跟头的地方。BaseGlyphPaintRecord 的偏移相对于 BaseGlyphList 的起点,LayerList 的绘制偏移相对于 LayerList,绘制表内部的每个 Offset24 相对于那张绘制表自己。拿同一个基准去解析这三者,完全合法的字形也会过不了边界检查——看起来活像字体损坏了。图建好之后,到 PDF 的映射大多直接:

  • PaintGlyph 把字形轮廓变成裁剪路径,其子绘制填充被裁剪的区域
  • 线性渐变变成多停靠点的轴向 shading,径向渐变变成径向 shading(§8.7.4.5)
  • 扫掠渐变没有对应的 PDF shading 类型,于是画成围绕中心的 96 块纯色扇形,每块从颜色线上采样
  • 变换变成 cm 运算符,平移量按 FontSize / UnitsPerEm 缩放
  • PaintComposite 模式 13 到 27 映射到同名的 PDF 混合模式——/Multiply、/Screen、/Luminosity 等(§11.3.5)——经由一个 ExtGState 的 /BM 条目

带半透明停靠点的渐变还藏着一个陷阱。COLR v1 按预乘 alpha 的颜色做插值,PDF 期待的是非预乘颜色、自己应用 alpha,于是把预乘值直接交给 PDF 的转换器会让每一段半透明带被压暗两次。颜色必须在插值之后、写出之前先去预乘

HotPDF COLR v1 转换图解:绘制图先解析成有界图——至多 4096 个节点、96 层深度、1024 个颜色停靠点并拒绝环——随后 PaintGlyph 变成轮廓裁剪路径,线性与径向渐变变成轴向与径向 shading,扫掠渐变变成 96 块扇形,PaintComposite 模式 13 到 27 变成 PDF 混合模式
整张图在写出第一个运算符之前就对照受支持子集检查完毕,不受支持的字形让页面原样不动、转去光栅兜底,而不是留下半张画

PDF/A-1 或 PDF/X-1a 下,COLR v1 的合成模式会怎样?

PaintComposite 里的 Porter-Duff 模式(5 到 12:src_in、dest_out、src_atop、xor、plus 及其同类)没有同名的 PDF 混合模式,而对 PDF 规范最直白的读法是:它们表达不了。这个读法过头了。in/out 类模式就是一个操作数上的 alpha 软蒙版;atop、xor 和 plus 可以用孤立形态、组 alpha、亮度蒙版和混合算术拼出来。HotPDF 就是这么构建的,这些字形因此保持矢量

代价是这些构造大多需要 PDF 透明度,而 PDF/A-1、PDF/X-1a 和 PDF/X-3 禁止透明度。PDFACompliance 是 PDF/A-1 值、或 PDFXCompliance 是 'X-1a' 或 'X-3' 时,HotPDF 在绘制前检查字形,把没有透明度就画不出来的情形改道到光栅事件:atop、xor 和 plus 合成,被蒙版操作数不只是简单不透明绘制的 in/out 合成,带部分 alpha 的实心绘制,带半透明停靠点的渐变,以及 alpha 低于 255 的 COLR v0 层。被蒙版一侧是普通纯色或渐变时——可选用字形裁剪或变换包一层——in 和 out 模式保持矢量,因为这种情形只需要一个不透明的灰度蒙版。StrictVersionLock 把文件钉在 PDF 1.4 以下时,COLR v0 同理

Pdf.PDFACompliance := 'B';   // PDF/A-1b:不允许透明度
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF(EmojiFontPath);

if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $1F308,
  72, 200, EmojiFontName, 36, 0, 300) then
  // 没有 OnColorGlyphRasterize 处理器时,半透明
  // 或合成的字形在 PDF/A-1 下就止步于此
  LogMissingColorGlyph($1F308);   // 你自己的日志例程

如果归档必须彩色显示 emoji,请瞄准 PDF/A-2b 而不是 PDF/A-1b。第 2 部分允许透明度,每种 COLR 构造都保持矢量,兜底处理器退居安全网,而不是每个半透明 emoji 的主力渲染器。按名称要求 PDF/A-1 的工作流,继续用 PDF/A-1

这里还挂着一个阅读侧的设置。HotPDF 在线性光混合空间里合成 COLR v1 组;一些渲染器会丢掉这类组里非常暗的通道,一些干脆无视组的混合空间,于是 COLRv1DeviceSpaceGroupBlend(默认 False)让这些组改在普通设备空间里合成。取舍在COLR v1 重复与反射渐变一文里有解释;如果用户实际在用的阅读器里暗色半透明色调消失了,就把它打开

SVG 字形与位图 strike

SVG 字形走的是 HotPDF 为导入 SVG 文件准备的同一个安全构建器,结果注册为一个 Form XObject(§8.10),SVG 到 Form XObject 一文讲的就是这件事。SVG 表里的文档可能是 gzip 压缩的。压缩输入上限 8 MB,解压按 8 KB 分块进行,展开大小一过 32 MB 就停。脚本、foreignObject、javascript: URL 和 @import 样式表会让构建器直接拒收文档。Form 会被缩放到较长边等于字号。上线前有一件事值得试:有些字体把许多字形塞进一份共享的 SVG 文档,构建器为每个字形收到的都是整份文档

位图字体归结为选 strike 和摆放。对 CBDT,HotPDF 选垂直 ppem 最接近 TargetPixelsPerEm 的 CBLC 尺寸,接受图像格式 17、18 和 19,格式 19 的度量从 CBLC 索引子表读——因为该格式自己什么都不存。对 sbix,真正包含该字形的最近 strike 胜出;strike 偏移相对于表,字形偏移相对于 strike。dupe 记录复用另一个字形的图像但保留自己的原点偏移,解析器若让查表覆盖外层原点,图像就会被横着挪动。PNG 和 JPEG 载荷内部解码,按 FontSize / PixelsPerEmY 缩放而不是拉伸到字号,任一像素不完全不透明时以软蒙版(§11.6.5.3)写出。sbix 里的 TIFF 载荷交给事件

编写光栅兜底处理器

原生路径都走不通时,HotPDF 触发 OnColorGlyphRasterize,并摆放你的处理器返回的 RGBA 位图。没有处理器、或 Handled 留着 False,DrawRegisteredColorGlyph 返回 False,什么都不画。处理器收到格式、字体字节、提取出的资产(SVG 文档——可能仍是 gzip 的——或位图字节;COLR 时为空)、字形 ID、调色板和目标像素尺寸

HotPDF 光栅兜底图解:OnColorGlyphRasterize 对受支持子集之外的 COLR v1 图、安全构建器拒绝的 SVG 文档或解码器读不了的位图负载触发,传入格式、字体字节、资产、GlyphID、PaletteIndex 和 PixelSize;返回的 RGBA 缓冲只有在其长度恰好为 Width 乘 Height 乘 4 时才被接受
零尺寸、错误的缓冲长度或溢出的尺寸在触碰页面之前就被拒绝;没有处理器或 Handled 为 false 时,调用返回 False,页面保持不变
type
  TEmojiFallback = class
  public
    procedure Rasterize(Sender: TObject;
      Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
      const AssetData: TBytes; GlyphID: Word;
      PaletteIndex, PixelSize: Integer;
      out Width, Height: Integer; out RGBA: TBytes;
      out Handled: Boolean);
  end;

procedure TEmojiFallback.Rasterize(Sender: TObject;
  Format: THPDFOpenTypeColorFormat; const FontBytes: TBytes;
  const AssetData: TBytes; GlyphID: Word;
  PaletteIndex, PixelSize: Integer;
  out Width, Height: Integer; out RGBA: TBytes;
  out Handled: Boolean);
begin
  Width := 0;
  Height := 0;
  RGBA := nil;
  // RenderWithOwnEngine 是你自己的光栅化器,不是 HotPDF 的 API。
  // 它必须返回恰好 Width * Height * 4 字节的 RGBA,
  // alpha 为非预乘(straight)
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// Wiring
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF 在页面见到结果之前先验证:零尺寸、长度不恰好是 Width * Height * 4 的缓冲、或大到溢出的尺寸都会被拒绝,调用返回 False。可变彩色字体下,处理器拿到的是注册轴值上的静态实例;如果哪次收到的是原始可变字体,对 Sender(转型为 THotPDF)调 GetRegisteredVariableFontAxes 会返回应该用来渲染的坐标。PixelSize 就是那个 TargetPixelsPerEm 值,前面关于打印分辨率的建议在这里同样适用

打印任意用户文本的管线需要知道两种损失:字体里从来没有的字形,和丢了颜色的字形。来自缺失字形跟踪一文的绘制时覆盖率统计报告前者;DrawRegisteredColorGlyph 返回的 False 报告后者

COLR、SVG 和位图 emoji 渲染、OpenType 整形以及安全 SVG 构建器,都是 HotPDF component for Delphi and C++Builder 的一部分