技术文章

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

HotPDF 经由 THotPDF.DrawRegisteredColorGlyph 把彩色 emoji 画进 PDF:它读取用 RegisterUnicodeTTF 注册的字体的彩色数据,并以原生 PDF 图形输出——COLR v0 的分层输出为填充的字形轮廓,COLR v1 的 paint 图输出为裁剪、shading 和混合模式,SVG 字形输出为 Form XObject,CBDT 或 sbix 位图输出为图像。凡是不能原生映射的,都转给 OnColorGlyphRasterize 事件,而不是无声地变成一个黑色形状

最后这半句是这段代码存在的全部理由。按普通方式嵌入一枚 emoji 字体,阅读器拿到的是 glyf 或 CFF 的轮廓,用当前填充色一填了事。笑脸到手成了一坨黑色,国旗成了一个矩形,而管线里没有任何东西抱怨

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

PDF 字体程序没有彩色字形的概念。ISO 32000-1 把字形当作用当前颜色涂色的形状,而 OpenType 后来加入的彩色表——COLR/CPAL、SVG 、CBDT/CBLC 和 sbix——不属于 PDF 成像模型,所以没有任何阅读器有义务从嵌入字体里读它们。颜色必须在生成时翻译成页面内容,趁生产者手里还有字体字节、还知道自己要哪个字形。翻译方式因格式而异,而野外的 emoji 字体把所有格式都用上了:分层矢量、渐变 paint 图、内嵌 SVG 文档和 PNG strike。HotPDF 以 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 字体。字形没有彩色数据或没有路径能渲染它时,draw 函数返回 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';
    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 调色板,所以自带深色背景调色板的字体不用碰字形就能切换。TargetPixelsPerEm 只对位图字体有意义;留 0 时默认 Round(FontSize * 96 / 72)——一个屏幕分辨率,这就是示例为打印输出要 300 的原因。诚实的边界写在签名里:这个调用只接收一个码点、只经 cmap 映射。ZWJ 序列、肤色修饰符和区域指示符旗帜是 GSUB 连字,组装它们属于 shaping 问题——OpenType GSUB 备选字形一文讲的是那一类——不是这个入口替你做的事

COLR v0:带调色板颜色的堆叠字形层

COLR v0 是简单情形,HotPDF 直接渲染:每个基础字形列出若干层字形加各自的 CPAL 颜色条目,每层变成一次带自己填充色的普通文本显示操作,按表顺序堆叠。alpha 低于 255 的层拿到一个带匹配 /ca 和 /CA 的 graphics state 参数字典(ISO 32000-1 §8.4.5),每层字形都被标为已使用,于是子集化器会保留它的轮廓,即使没有码点直接映射到它。有一个细节让人意外:调色板条目索引 0xFFFF 在 OpenType 规范里表示「使用文本前景色」,而 HotPDF 把它解析成黑色而不是当前页面填充色。对 emoji 字体这很少要紧;对依赖前景条目给字形着色的图标字体,先检查输出,再假定它会跟随你的文字颜色

HotPDF 怎么把 COLR v1 paint 图变成 PDF 操作符?

先把 paint 表解析成一个扁平、有界的图,然后才把每个节点映射成 PDF 构件。COLR v1 字形不是层列表,而是一张 paint 记录的有向无环图,节点可以经 PaintColrLayers 和 PaintColrGlyph 共享。解析器把它限制在 4096 个 paint 节点、64 层深度和 1024 个色标之内,并把每个节点记为活跃或已完成,于是指回活跃节点的引用——恶意字体用层复用构造出的环——会被拒绝而不是被递归进去。偏移基准是第一版实现最容易错的地方。BaseGlyphPaintRecord 的偏移相对 BaseGlyphList 起点,LayerList 的 paint 偏移相对 LayerList,而 paint 表内的每个 Offset24 相对那个 paint 表自己。把三者对着同一个基准解析,完全合法的字形就会过不了边界检查,看起来跟字体损坏一模一样。图建好之后,映射是直接的:

  • PaintGlyph 把字形轮廓设为裁剪、文本渲染模式 7(ISO 32000-1 §9.3.6),然后在其中绘制它的孩子
  • 纯色 paint 填充一个被裁剪的矩形;线性渐变变成多色标 axial shading,径向渐变变成双色 radial shading(§8.7.4.5)
  • sweep 渐变没有 PDF 对应物,HotPDF 用 96 个纯色楔形近似,每个从色线上采样
  • 变换以 cm 输出,围绕字形基线原点共轭,平移按 FontSize / UnitsPerEm 缩放
  • PaintComposite 模式 13 到 27 映射到可分离与不可分离的 PDF 混合模式,如 /Multiply、/Screen 和 /Luminosity(§11.3.5),经 ExtGState 的 /BM 条目设置

边界是显式的。Porter-Duff 模式 5 到 12(src_in、xor、plus 等)没有 PDF 混合模式对应物,线性与径向渐变的 repeat 和 reflect 扩展模式不输出,各色标 alpha 不同的渐变也不会用单个透明度来伪造。超过两个色标的径向渐变只保留首尾两个颜色。HotPDF 在写下第一个操作符之前就对照这个受支持子集检查整张图,所以不受支持的字形让页面保持原样、转去光栅兜底,而不是留下半幅画

HotPDF COLR v1 转换图解:paint 图被解析成有界图——上限 4096 节点、64 层深度、1024 色标并拒绝环——然后 PaintGlyph 变成模式 7 裁剪,线性与径向渐变变成 axial 与 radial shading,sweep 渐变变成 96 个楔形,PaintComposite 模式 13 到 27 变成 PDF 混合模式
写下第一个操作符之前,整张图先对照受支持子集检查,所以不受支持的字形让页面保持原样、转去光栅兜底,而不是留下半幅画

SVG 字形与位图 strike

SVG 字形走 HotPDF 为导入 SVG 文件准备的同一个有界构建器,结果注册成 Form XObject(§8.10),与SVG 到 Form XObject 一文描述的完全一样。SVG 表里的文档可能是 gzip 压缩的;解压按 8 KB 分块进行,展开尺寸一旦要超过 32 MB 就立即停止,而不是先膨胀完再检查,压缩输入本身也封顶 8 MB。profile 刻意收紧:脚本、内嵌图像、外部 URL、data: URI 和非本地引用一律失败关闭。Form 会被缩放到较长边等于字号、锚定在基线上,把 y 向下的 SVG 坐标系映射到 y 向上的 PDF 坐标系。注意构建器收到的是该字形的整份 SVG 文档、不会挑选 glyphNNN 元素,所以把许多字形打包进一份共享文档的字体,值得先测一测再依赖

位图字体是 strike 选择与摆放的问题。CBDT 的情形,HotPDF 挑垂直 ppem 最接近 TargetPixelsPerEm 的 CBLC 尺寸,接受图像格式 17、18 和 19,格式 19 的度量从 CBLC 索引子表读取,因为那个格式自己什么都不存。sbix 的情形,strike 偏移相对表、字形偏移相对 strike,dupe 记录复用另一个字形的图形、但保留自己的原点偏移;让递归覆盖外层原点会挪动图像。PNG 和 JPEG 负载在内部解码,按 FontSize / PixelsPerEmY 缩放而不是拉伸到字号,只要任何像素不完全不透明就配一个软掩模(§11.6.5.3)。sbix 的 TIFF 负载不解码,直接转给事件

字形无法原生绘制时会发生什么?

HotPDF 触发 OnColorGlyphRasterize 并放置你的处理器返回的任何 RGBA 位图;没有挂处理器、或处理器让 Handled 保持 false 时,DrawRegisteredColorGlyph 返回 False,页面保持不变。事件对三种情况触发:受支持子集之外的 COLR v1 图、安全构建器拒绝的 SVG 文档、内部解码器读不了的位图负载。处理器拿到格式、原始字体字节、提取出的资产(SVG 文档——可能仍是 gzip 的——或位图字节;COLR v1 为空)、字形 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。
  Handled := RenderWithOwnEngine(Format, FontBytes, AssetData,
    GlyphID, PaletteIndex, PixelSize, Width, Height, RGBA);
end;

// 接线
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;

HotPDF 在触碰页面前先校验处理器输出:零尺寸、长度不是恰好 Width * Height * 4 的缓冲、或大到溢出的尺寸都被拒绝,调用返回 False。光栅兜底仍然是光栅,这样渲染的 emoji 失去矢量锐度;请求一个与输出分辨率匹配的 PixelSize。把彩色路径与缺失字形跟踪一文的绘制期覆盖检查配对,处理任意用户文本的管线就能同时报告缺失字形和丢失颜色的字形

彩色字形渲染器、OpenType shaping 栈和安全 SVG 构建器都随面向 Delphi 与 C++Builder 的 HotPDF Delphi PDF component 发布