技术文章

COLRv1 repeat/reflect 渐变如何变成 PDF shading

HotPDF Component 把 COLRv1 的 repeat 和 reflect 渐变画成矢量 PDF shading,而不是把字形光栅化。几何被拉伸到覆盖整数个色线周期,每个周期拿到自己的 axial 或 radial shading 且 Extend 关闭,颜色在线性光 CalRGB 里插值,Porter-Duff 的 in/out 合成模式用 luminosity 软蒙版表达

我们写 HotPDF 如何在 Delphi 里绘制 COLR v1 与 SVG 彩色 emoji 字体时,repeat 和 reflect 扩展模式还在原生路径不输出的清单上,这类字形走光栅兜底。更新的 HotPDF 版本把它们画成矢量。走到这一步经历了几次错误的起点,多数源于一个看起来很合理、却在至少一个广泛使用的阅读器里被证伪的 PDF 假设。本文把这些假设过一遍,因为任何把 CSS、SVG 或 OpenType 渐变映射到 PDF 的人都会遇到同样的问题

为什么 PDF 阅读器不能替你重复渐变?

PDF shading 没有 repeat 或 reflect 模式,生产者只能自己铺每一个周期。ISO 32000-1 §8.7.4.5.3 和 §8.7.4.5.4 为 axial 与 radial shading 定义了 /Extend,它做的只是把 t0 或 t1 处的颜色延续到轴线端点之外。那是 COLRv1 的 EXTEND_PAD,仅此而已。OpenType COLR v1 还定义了 EXTEND_REPEAT——参数每过一个整数色线就重来一遍——和 EXTEND_REFLECT——隔一个周期倒着走

HotPDF 在 Delphi 里把 COLRv1 映射到 PDF shading:EXTEND_PAD 对应 axial 与 radial shading 的 /Extend 延续标志,EXTEND_REPEAT 和 EXTEND_REFLECT 没有 PDF 对应物,必须用逐周期的 shading 几何搭出来
PDF shading 只会把 t0 和 t1 处的颜色向外延续,所以 COLRv1 的 repeat 或 reflect 渐变要由生产者逐周期铺开,而不是给阅读器开个开关

HotPDF 的第一次尝试想让函数自己干周期性的活。它注册了一个宽定义域的 Type 0 采样函数(§7.10.2),25 个周期的跨度大致取 [-12.73, 12.73],填入折叠后的样本,并打开 Extend。第一个周期之内,最小测试文件渲染正确;周期之外,MuPDF 涂出单一平均色,Poppler 也好不到哪去。两个阅读器都是沿 shading 参数求值函数、在轴外做 pad,谁都不会回到扩展区域去查询周期函数。宽定义域也是对规范的误读:shading 参数 t 原样传给函数,按 §7.10.1,定义域之外的输入被裁剪,所以定义域与 shading 的 /Domain(默认 [0 1])不匹配的函数只有部分样本会被读到

行得通的设计有三部分,全部对着 MuPDF 和 Poppler 的渲染验证过。shading 几何物理上覆盖整数 N 个周期,没有任何像素依赖 Extend。采样函数保留默认 [0 1] 定义域,与阅读器实际传入的一致。样本序列本身按周期折叠,每周期 256 个区间,函数里装的就是 repeat 或 reflect 的颜色数据,不指望阅读器的任何特性

把色线折叠成 Type 0 样本

折叠就是每个周期采样一次色线,reflect 模式下奇数周期把序列镜像。下面的辅助函数是普通 Delphi,不是 HotPDF API,它做对了两件朴素版本会做错的事:在线性光里插值而不是在存储的 sRGB 字节上插值;把硬停靠点(同一偏移上两个停靠点)解析到远侧的颜色,这是 OpenType 与 CSS 的语义

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 单个周期内 0..1,升序
    R, G, B: Byte;   // CPAL 风格的 sRGB 字节
  end;

function SRGBToLinear(V: Byte): Double;
var
  C: Double;
begin
  C := V / 255;
  if C <= 0.04045 then
    Result := C / 12.92
  else
    Result := Power((C + 0.055) / 1.055, 2.4);
end;

function LinearToSRGB(L: Double): Byte;
var
  C: Double;
begin
  L := EnsureRange(L, 0, 1);
  if L <= 0.0031308 then
    C := L * 12.92
  else
    C := 1.055 * Power(L, 1 / 2.4) - 0.055;
  Result := Round(C * 255);
end;

// 色线的一个周期,8 位 RGB 三元组。Reverse = True
// 镜像该周期,即 reflect 模式在奇数周期做的事
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
  Reverse: Boolean): TBytes;
var
  I, S: Integer;
  T, F, Span: Double;
begin
  SetLength(Result, Count * 3);
  for I := 0 to Count - 1 do
  begin
    T := I / (Count - 1);
    if Reverse then
      T := 1 - T;
    S := 0;
    // >= 取硬停靠点的尾侧
    while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
      Inc(S);
    Span := Stops[S + 1].Offset - Stops[S].Offset;
    if Span > 0 then
      F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
    else
      F := 1;
    Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
      (SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
    Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
      (SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
    Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
      (SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
  end;
end;

HotPDF 内部的 COLRv1 渲染器比这个示意走得更远。它把大端 16 位线性样本写进 CalRGB 函数,alpha 作为独立的 16 位灰度样本存储,而接下来展示的公开辅助函数收 8 位 DeviceRGB 三元组——这就是示意代码要转换回 sRGB 字节的原因。想了解阅读器拿这些样本做什么的理论,作为 3D 色彩 LUT 的 Type 0 采样函数一文详细讲了插值模型

为什么每个周期需要自己的 shading?

每个周期需要自己的 shading,是因为阅读器不会精确求值采样函数;它们预计算一张颜色表,而表会在回卷处从上一周期的末色插值到下一周期的首色。MuPDF 把 shading 的一维函数存成 256 条目的颜色表。把 11 个周期塞进一个 2816 样本的函数,每个保存的样本都可以精确,MuPDF 却仍显示 12 到 14 级的通道误差,因为 256 个表条目摊在整个跨度上,生硬的 repeat 接缝被抹成斜坡。Poppler 和同一文件的解析插值都正确,所以这是阅读器的精度上限,不是文件错误,加样本也无济于事

HotPDF 在 Delphi 里的 COLRv1 repeat 渐变接缝图解:一个横跨 11 个周期的采样函数让 MuPDF 的 256 条目颜色表把每个 repeat 接缝抹成斜坡,而每周期一个 axial shading 让每张表待在单个周期内,保住生硬的颜色跳变
阅读器为每个 shading 预计算固定的颜色表,所以修法是几何性的:每周期一个 shading、关闭 Extend,把 repeat 接缝挪到任何表都不会跨越插值的边界上

修法是让每张阅读器表都待在一个周期里。HotPDF 把周期性线性渐变输出为每个原周期一个独立的 axial shading,两个 Extend 标志均为 false,于是末色到首色的跳变落在两个 shading 的边界上,而不是一张表内部。reflect 的奇偶判断用原始的带符号周期号——包括色线起点之前的负周期——方向翻转而不是重算样本。所有周期画进一个线性光的 isolated surface,paint alpha 最后一次性施加,半透明渐变不会沿着接缝把不透明度翻倍

公开的 THotPDF.RegisterSampledAxialGradient 和 THotPDF.RegisterSampledRadialGradient 方法把采样 shading 构件暴露给你自己的绘图。两者在几何参数之后收 (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer),要求 PDF 1.3,Samples 不是恰好 SampleCount * 3 字节就抛异常,返回供 THPDFPage.SetFillPattern 使用的 pattern 名。它们把 Extend 设在两端,并把 Cycles 记作阅读器会忽略的自定义 /Cycles pattern 键。要遵守一周期一 shading 的规则,就每个周期注册一个 pattern,再用一条带状矩形去裁剪

// 每周期一个 shading 组成的水平 repeat 或 reflect 渐变。
// Pattern 坐标在默认 PDF 空间;水平轴让这个例子
// 与页面自上而下的 Y 约定无关
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
  X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
  SamplesPerCycle = 257;  // 每周期 256 个区间
var
  Cycle, CycleCount: Integer;
  X0, X1: Single;
  Samples: TBytes;
  PatternName: AnsiString;
begin
  CycleCount := Ceil(Width / CycleLength);
  for Cycle := 0 to CycleCount - 1 do
  begin
    X0 := X + Cycle * CycleLength;
    X1 := X0 + CycleLength;
    Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
    // Domain [0, 1]:shading 参数原样到达函数
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // 文档版本锁在 PDF 1.3 以下
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // 带状矩形把 shading 裁到恰好一个周期,Extend 永远不露面
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

很长的跨度需要另一招,因为一周期一 shading 意味着一周期一个资源。对周期性线性 COLRv1 渐变,HotPDF 换成倍增方案:叶 form 持有一个单周期 repeat 或双周期 reflect 的原语,每层 Form XObject 把上一层画两次、按宽度错开。form 数量随跨度对数增长,阅读器仍然绘制每个可见周期,相位对齐后的覆盖率封顶 65536 个周期单位。更早的版本曾把原语放进 Type 1 平铺 pattern,后来放弃了——阅读器会把瓦片间距对齐到设备网格,Poppler 在非整数像素周期上漂移出了相位

解出 radial 圆族给的参数范围

radial 渐变只有在对正确的圆族算出周期范围时才能盖住字形,而 COLRv1 的 radial paint 分三种族,数学各不相同。记 D = c1 - c0、dr = r1 - r0:严格嵌套族满足 |dr| > |D|,非嵌套锥满足 |D|² > dr²,相切族满足 |D| = |dr|。HotPDF 从原始族的零半径点度量参数,并让每个输出圆的圆心与半径都系在同一原始参数上,所以重映射出的负半径从不会被单独钳制

HotPDF 在 Delphi 里的 COLRv1 radial 渐变决策树:比较 dr 与 D 区分出用二分求解的嵌套圆族、用短逐周期 radial shading 求解的锥,以及约简为线性根 q = |W|^2 / (2(W*V)) 的相切族
|dr| 与 |D| 之间的大小关系决定周期数学:嵌套族二分找第一个触到填充框的圆,锥每周期一个短 shading,相切线让一条奇异带保持透明
  • 嵌套族单调地与填充框相交,参数范围来自四个角加一次二分(找第一个触框的圆)。最多 64 个周期以每周期 256 样本采进一个 shading,封顶 4096 样本;更深的跨度改用逐周期切片
  • 锥与等半径条带在原始周期边界上使用独立的短 radial shading,每个 257 样本、Extend 关闭,在一个 isolated、非 knockout 的组里按原始参数顺序合成,圆重叠处最大有效参数胜出
  • 相切族把圆方程约简成线性方程。相对共享切点,根是 q = |W|² / (2·(W·V)),覆盖区域是该点前方的开半平面

相切情形有真正的奇点。切线穿过填充框时,到达切线附近像素所需的周期数无界,再多的 shading 也盖不住。HotPDF 从切点花掉 64 个周期的预算,剩下的细条带保持透明。四个角都在切点后方时,paint 整体透明,字形其余部分照常绘制。有限锥路径最多接受 4096 个必需周期,覆盖率封顶 1024 个切片,超出封顶的部分不上色;一个 1024 切片的字形大约 1.8 MB

线性光颜色与两位小数陷阱

COLRv1 在线性光里插值与合成,所以 HotPDF 用 sRGB 逆传递函数解码每个 CPAL 字节,把实心、shading 和内部组都画进同一个 CalRGB 空间——D65 白点、sRGB 原色、/Gamma [1 1 1](§8.6.5.3)。只有完成的根表面转换到调用者的混合空间。改在存储的 sRGB 字节上插值会产生肉眼可见的偏暗中点,那是每个渐变引擎都会犯一次的经典 gamma 错误

转向线性光暴露了一个老格式化 bug。CPAL 的暗通道值 12 在线性光里约为 0.0036765,而 scn 背后共用的数字格式化器只留两位小数,于是这个分量在任何阅读器碰文件之前就被写成了 0。症状看起来与阅读器量化一模一样,第一反应是怪 MuPDF 的 8 位组缓冲。现在填充与描边分量都经过一个无指数十进制格式化器,保住小值、无视进程 locale。教训可以推广:深色凭空消失时,先转储内容流、检查写出的操作数,再去查阅读器

那次修复之后阅读器仍然要紧。MuPDF 1.28.2 在合成携带显式混合色彩空间的 isolated 透明组时会丢掉近零的线性通道,Poppler 26.07.0 的 Splash 干脆无视组混合空间。可选的 THotPDF.COLRv1DeviceSpaceGroupBlend 属性(默认 False)让 COLRv1 组不带混合色彩空间输出,组在继承的设备空间里合成,而内容颜色、shading 和函数保持线性光 CalRGB。代价是:在真正尊重组色彩空间的阅读器里,重叠的半透明层会改在设备空间里混合

哪些 COLRv1 合成模式 PDF 能精确表达?

HotPDF 原生渲染 COLRv1 合成模式 5 到 8(SRC_IN、DEST_IN、SRC_OUT、DEST_OUT),模式 9 到 12(SRC_ATOP、DEST_ATOP、XOR、PLUS)交给 OnColorGlyphRasterize。in 与 out 模式是一个表面与另一个表面 alpha 的乘积,这恰是软蒙版计算的东西(§11.6.5)。存活的操作数被捕获成普通 form;被蒙版的操作数在 alpha 遍里捕获为一个 isolated DeviceGray 组内的平面内容,用作带 /BC [0] 的 /Luminosity 蒙版。out 模式对所有 alpha 写 1 − α,并在蒙版内容下垫一层不透明白,于是对方操作数从未画过的地方,存活者仍然可见

另外四个需要对两个表面做预乘加法。在 PDF 的 source-over 模型里,背景项总被源的 (1 − α) 衰减,也没有任何原语能把两个表面相加、或单独按源的覆盖率缩放背景,所以 atop、xor 和 plus 写不成矢量。还有一条限制来自裸测试 PDF:两个阅读器都会算错内容里嵌套了 form Do 的软蒙版组,所以被蒙版的操作数必须是平面的。实心色、pad 渐变和包着它们的 PaintGlyph 合格;图层、变换和周期渐变都会产生内部 form,把字形送去兜底

var
  Pdf: THotPDF;
  Fallback: TEmojiFallback;  // 处理器类,事件签名见下方注释
begin
  Pdf := THotPDF.Create(nil);
  Fallback := TEmojiFallback.Create;
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'periodic-icons.pdf';
    // 可选开关:面向 MuPDF 与 Poppler 的设备空间组合成
    Pdf.COLRv1DeviceSpaceGroupBlend := True;
    // 接收 atop/xor/plus 字形以及子集之外的一切:
    // (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
    //  PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
    //  out RGBA: TBytes; out Handled: Boolean)
    Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
    Pdf.BeginDoc;
    Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
    if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
      72, 144, 'Gradient Icons', 48) then
      Writeln('U+E001 was neither drawn natively nor rasterized');
    Pdf.EndDoc;
  finally
    Fallback.Free;
    Pdf.Free;
  end;
end;

矢量路径仍够不到的地方

矢量输出不等于每个阅读器里像素完全一致,HotPDF 的验收记录把这点写得很直白。周期边界一两步之内的样本会被阅读器插值抹平,所以像素检查避开接缝采样,函数流改用解码来验证。分开存储的直 RGB 与 alpha 样本做插值,在低 alpha 端点和硬停靠点附近是近似;实测误差在每周期 256 样本下是二阶的,但不是精确。Poppler Splash 在像素左边缘而不是中心采样,在最陡的斜坡上表现为细小差异

预算也是真实上限。嵌套 radial 族在 4096 周期跨度之外拒绝,锥与相切族在 4096 个必需周期之外拒绝,奇线附近的相切条带留空。变化的停靠点 alpha 需要 PDF 1.4 透明度,所以在 PDF/A-1、PDF/X-1a 或 PDF/X-3 下不启用。字形超出任何一条限制时,DrawRegisteredColorGlyph 在写下第一个操作符之前就拒绝整张图——与之前一样的全有或全无

FAQ

HotPDF 会把 COLRv1 的 repeat 和 reflect 渐变光栅化吗?

不会。更新的 HotPDF 版本把它们输出为覆盖完整周期的矢量 axial 与 radial shading。只有受支持子集之外的图——比如 atop、xor 和 plus 合成——才交给 OnColorGlyphRasterize

为什么不给所有周期用一个采样函数?

阅读器会从函数预计算颜色表,MuPDF 的表只有 256 个条目。摊到多个周期上,它会在每个 repeat 接缝处插值。一周期一 shading 让每张表待在单个周期内

什么时候该打开 COLRv1DeviceSpaceGroupBlend?

COLRv1 字形里的近黑通道在 MuPDF 里掉成零时打开。需要在尊重组色彩空间的阅读器里让重叠的半透明层按线性光混合时保持关闭

原生 COLRv1 渐变、彩色 emoji、采样 shading 和透明组都是 HotPDF Component for Delphi and C++Builder 的一部分,它直接从 Delphi 与 C++Builder 代码生成 PDF 文档