技術記事

Delphi の COLRv1 repeat/reflect グラデーション PDF 化

HotPDF Component は COLRv1 の repeat/reflect グラデーションを、グリフをラスタライズする代わりにベクターの PDF シェーディングとして描きます。ジオメトリはサイクル数がちょうど整数になるよう伸張され、各サイクルは Extend をオフにした固有の axial/radial シェーディングを受け取り、色はリニアライトの CalRGB で補間され、Porter-Duff の in/out 合成モードは luminosity のソフトマスクで表現されます

HotPDF が Delphi で COLR v1 と SVG カラー絵文字フォントを描く方法を取り上げたとき、ネイティブ経路が出力しないもののリストに repeat/reflect の拡張モードがあって、そのようなグリフはラスタフォールバックに回っていました。新しい HotPDF はこれらをベクターで描きます。そこに至るまでにはいくつかの見当違いがあり、その大半は、もっともらしく見える PDF に関する前提が、少なくとも 1 つの広く使われているビューアでは間違っていたせいでした。本記事はそれらの前提をたどります。CSS、SVG、OpenType のグラデーションを PDF に写す人は皆、同じものに会うからです

PDF ビューアはグラデーションのリピートを代行できないのか

PDF のシェーディングには repeat も reflect もないため、プロデューサがすべてのサイクルを自分で並べる必要があります。ISO 32000-1 §8.7.4.5.3 と §8.7.4.5.4 が axial と radial のシェーディングに /Extend を定めていますが、これは t0 か t1 の色を軸の端を越えて続けるだけです。それは COLRv1 の EXTEND_PAD であって、それ以外の何ものでもありません。OpenType COLR v1 はさらに EXTEND_REPEAT(パラメータが整数を越えるたびにカラーラインが最初からやり直す)と EXTEND_REFLECT(1 つおきのサイクルが逆向きに走る)を定義します

Delphi における HotPDF の COLRv1 から PDF シェーディングへのマッピング。EXTEND_PAD は axial と radial シェーディングの /Extend 継続フラグに一致するが、EXTEND_REPEAT と EXTEND_REFLECT には PDF の対応物がなく、サイクルごとのシェーディングジオメトリから構築するしかない
PDF のシェーディングは t0 と t1 で色をパディングするだけなので、COLRv1 の repeat/reflect グラデーションはビューアにスイッチで入れるものではなく、プロデューサがサイクルごとに並べるしかありません

HotPDF の最初の試みは、周期の仕事を関数にやらせようとしました。幅広のドメインを持つ 1 つの Type 0 サンプリング関数(§7.10.2)、25 サイクルのスパンでだいたい [-12.73, 12.73] を登録し、折り返したサンプルを流し込み、Extend をオンにしました。最初のサイクルの中では最小テストファイルは正しく描けました。外では、MuPDF は単一の平均色を塗り、Poppler も大差ありませんでした。どちらのビューアも関数をシェーディングパラメータに沿って評価し軸の外はパディングするだけで、拡張領域で周期的な関数を問い合わせ直しはしません。幅広ドメインも仕様の誤読でした。シェーディングパラメータ t はそのまま関数に渡され、§7.10.1 により関数のドメイン外の入力はクリップされるため、ドメインがシェーディングの /Domain(既定 [0 1])と一致しない関数は、サンプルの一部しか読まれません

機能する設計は 3 つの部分からなり、いずれも MuPDF と Poppler の描画で検証しました。シェーディングのジオメトリが整数 N サイクル分を物理的に覆うので、どのピクセルも Extend に依存しません。サンプリング関数は既定の [0 1] ドメインを保ち、ビューアが実際に渡すものと一致させます。そしてサンプル列そのものをサイクルごとに折り返し、サイクルあたり 256 区間とします。関数が repeat/reflect の色データを保持し、ビューアの機能に頼らないためです

カラーラインを Type 0 サンプルへ折り込む

折り込みとは、カラーラインをサイクルあたり 1 回サンプリングし、reflect モードでは奇数サイクルで列を鏡像することです。下のヘルパーは素の Delphi であって HotPDF の API ではなく、素朴版が間違える 2 つのことを正しくやります。格納された sRGB バイト上ではなくリニアライトで補間することと、ハードストップ(同じオフセットの 2 ストップ)を OpenType と CSS のセマンティクスどおり遠い側の色に解決することです

uses
  System.SysUtils, System.Math;

type
  TColorStop = record
    Offset: Single;  // 1 サイクル内の 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;

// カラーラインの 1 サイクルを 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 関数に書き、アルファを別の 16 ビットグレーサンプルとして格納します。次に示す公開ヘルパーが 8 ビット DeviceRGB トリプルを受け取るのは、そのためで、スケッチは sRGB バイトへ逆変換しています。リーダーがそのサンプルで何をするかの理論は、3D カラー LUT としての Type 0 サンプリング関数の記事が補間モデルを詳しく扱います

各サイクルに固有のシェーディングが要る理由

各サイクルに固有のシェーディングが要るのは、リーダーがサンプリング関数を正確に評価しないからです。リーダーはカラーテーブルを事前計算し、そのテーブルは 1 サイクルの最後の色から次のサイクルの最初の色への折り返しを跨いで補間します。MuPDF はシェーディングの 1 次元関数を 256 エントリのカラーテーブルとして格納します。11 サイクルを 1 つの 2816 サンプルの関数に詰め込んでも、保存されたサンプルはすべて正確であり得るのに、MuPDF は 12 から 14 段階のチャネル誤差を見せます。256 のテーブルエントリがスパン全体に広がり、硬いリピートの継ぎ目がランプへなめらかにされてしまうからです。同じファイルの Poppler と解析的補間はどちらも正しかったため、これはリーダーの精度限界であってファイルの誤りではなく、サンプルを増やしても効果はありません

Delphi における HotPDF COLRv1 リピートグラデーションの継ぎ目の図解。11 サイクルに跨る 1 つのサンプリング関数では MuPDF の 256 エントリのカラーテーブルがリピートの継ぎ目をすべてランプになめらかにするが、サイクルごとの axial シェーディングなら各テーブルが 1 サイクル内に収まり硬い色のジャンプが保たれる
リーダーはシェーディングごとに固定のカラーテーブルを事前計算するため、修正は幾何で行います。Extend オフのサイクルごとのシェーディングが、どのテーブルも跨いで補間しない境界へリピートの継ぎ目を移動します

修正は、すべてのリーダーテーブルを 1 サイクルの中に留めることです。HotPDF は周期的なリニアグラデーションを、元のサイクルごとの独立した axial シェーディングとして出力し、両方の Extend フラグを false にします。こうすると、終端から始端への色ジャンプが 1 つのテーブルの内側ではなく 2 つのシェーディングの境界の上に着地します。reflect では、奇数判定は元の符号付きサイクル番号を使い、カラーラインの開始より前の負のサイクルも含め、サンプルを再計算する代わりにシェーディングの向きを入れ替えます。全サイクルは 1 つのリニアライトの孤立サーフェスに描かれ、ペイントのアルファは最後に 1 回適用されるため、半透明のグラデーションが継ぎ目に沿って不透明度を二重化することはありません

公開メソッドの THotPDF.RegisterSampledAxialGradient と THotPDF.RegisterSampledRadialGradient は、サンプリングシェーディングの積み木を自分の描画に使えるようにします。どちらもジオメトリの後に (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer) を取り、PDF 1.3 が必要で、Samples が正確に SampleCount * 3 バイトでない場合は例外を上げ、THPDFPage.SetFillPattern 用のパターン名を返します。両端で Extend をオンにし、Cycles をビューアが無視するカスタムの /Cycles パターンキーとして記録します。1 サイクル 1 シェーディングの規則に従うには、サイクルごとに 1 パターンを登録し、帯矩形でクリップさせます

// サイクルごとに 1 シェーディングで作る水平の repeat/reflect グラデーション
// パターン座標は既定の 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));
    // ドメイン [0, 1]:シェーディングパラメータはそのまま関数へ届く
    PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
      Samples, SamplesPerCycle, 1);
    if PatternName = '' then
      Exit;  // 文書バージョンが PDF 1.3 未満に固定されている
    Pdf.CurrentPage.SetFillPattern(PatternName);
    // 帯がシェーディングをちょうど 1 サイクルにクリップするため、Extend は決して姿を見せない
    Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
    Pdf.CurrentPage.Fill;
  end;
end;

非常に長いスパンには別の妙技が要ります。1 サイクル 1 シェーディングは 1 サイクル 1 リソースを意味するからです。周期的なリニア COLRv1 グラデーションで、HotPDF は倍加方式に切り替えます。リーフのフォームが 1 サイクルの repeat か 2 サイクルの reflect のプリミティブを保持し、各 Form XObject レベルが前のレベルを幅だけずらして 2 回描きます。フォームの数はスパンに対して対数的に増え、ビューアは見えているサイクルをすべて描くので、位相合わせ後のカバーは 65536 サイクル単位で頭打ちです。プリミティブを Type 1 タイリングパターンに入れた初期版は落とされました。リーダーはタイルの間隔をデバイスグリッドに合わせて調整し、ピクセル数で整数にならないサイクルでは Poppler が位相からずれたのです

radial 円族のパラメータ範囲を解く

radial グラデーションがグリフを覆うのは、正しい円族に対してサイクル範囲が計算されたときだけで、COLRv1 の radial ペイントは数学の違う 3 つの族に分かれます。D = c1 - c0、dr = r1 - r0 とすると、厳密にネストする族は |dr| > |D|、ネストしない円錐は |D|² > dr²、接する族は |D| = |dr| です。HotPDF はパラメータを元の族の半径ゼロの点から測り、出力するすべての円の中心と半径を同じ元のパラメータに紐付け続けるため、再マップされた負の半径が独立にクランプされることはありません

Delphi における HotPDF COLRv1 radial グラデーションの決定木。dr と D の比較により、二分法で解くネストした円族、サイクルごとの短い radial シェーディングで解く円錐、線形根 q = |W|^2 / (2(W*V)) に帰着させる接する族に分かれる
|dr| と |D| の大小がサイクルの数学を決めます。ネストした族は最初に触れる円を二分法で求め、円錐はサイクルごとに 1 つの短いシェーディングを受け取り、接線は特異な帯を透明のまま残します
  • ネストした族はフィルボックスと単調に交わるため、パラメータ範囲は 4 つの角に加え、ボックスに触れる最初の円を二分法で求めて出ます。最大 64 サイクルまで、サイクルあたり 256 サンプル、上限 4096 サンプルで 1 つのシェーディングにサンプリングされます。それより深いスパンはサイクルごとのスライスに切り替わります
  • 円錐と等半径の帯は、元のサイクル境界で別々の短い radial シェーディングを使います。それぞれ 257 サンプルで Extend オフ、孤立した非 knockout グループの中で元のパラメータ順に合成され、円が重なる場所では最も大きい有効パラメータが勝ちます
  • 接する族は円の方程式を線形に帰着させます。共有される接点から見て、根は q = |W|² / (2·(W·V)) で、覆われる領域はその点の前方の開半平面です

接するケースには本物の特異点があります。接線がフィルボックスを横切るとき、その線の近くのピクセルに届くのに要するサイクル数は無界で、有限個のシェーディングでは覆えません。HotPDF は接点から 64 サイクルの予算を使い、残りの薄い帯は透明のままにします。ボックスの 4 つの角すべてが接点の背後にあるなら、ペイントは全体として透明で、グリフの残りは引き続き描かれます。有限円錐の経路は最大 4096 の必須サイクルを受け付け、カバーは 1024 スライスで頭打ち、その先は塗られません。1024 スライスのグリフは約 1.8 MB になります

リニアライトの色と小数 2 桁の罠

COLRv1 はリニアライトで補間と合成を行うため、HotPDF はすべての CPAL バイトを sRGB の逆伝達関数でデコードし、ソリッド、シェーディング、内部グループを、D65 白、sRGB 原色、/Gamma [1 1 1](§8.6.5.3)の 1 つの CalRGB 空間で描きます。完成したルートサーフェスだけが呼び出し元のブレンド空間へ変換されます。格納された sRGB バイトを補間すると中間点が目に見えて暗くなります。どのグラデーションエンジンも一度はやる古典的なガンマミスです

リニアライトへの移行で古いフォーマッタのバグが露出しました。暗い CPAL チャネル値 12 はリニアライトで約 0.0036765 になりますが、scn の背後にある共通数値フォーマッタは小数 2 桁しか残さず、その成分はどのビューアがファイルに触る前から 0 と書かれていました。症状はまるでリーダーの量子化に見え、最初は MuPDF の 8 ビットグループバッファのせいにしかけました。フィルとストロークの成分は今、小さい値を保持しプロセスのロケールを無視する、指数のない 10 進フォーマッタを通ります。教訓は一般化できます。暗い色が消えたら、ビューアを調べる前にコンテンツストリームをダンプして書かれたオペランドを確認することです

その修正の後でもビューアは重要です。MuPDF 1.28.2 は、明示的なブレンドカラースペースを運ぶ孤立トランスペアレンシーグループを合成するとき、ゼロ近傍のリニアチャネルを失いますし、Poppler 26.07.0 の Splash はグループのブレンドスペースをまったく無視します。オプトインの THotPDF.COLRv1DeviceSpaceGroupBlend プロパティ(既定 False)は、COLRv1 グループをブレンドカラースペースなしで出力するため、グループは継承されたデバイス空間で合成され、コンテンツの色、シェーディング、関数はリニアライトの 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 モードは 1 つのサーフェスともう 1 つのアルファの積で、これはまさにソフトマスクが計算するものです(§11.6.5)。生き残るオペランドは普通のフォームとして確保され、マスクされるオペランドはアルファパスで、1 つの孤立 DeviceGray グループ内のフラットなコンテンツとして確保され、/BC [0] 付きの /Luminosity マスクとして使われます。out モードはすべてのアルファに 1 − α を書き、マスクコンテンツの下に不透明な白のカバーを敷くため、もう 1 つのオペランドが決して描かなかった場所でも生き残りは見えています

残る 4 つは 2 つのサーフェスの前乗算加算が必要です。PDF の source-over モデルではバックドロップ項は常にソースの (1 − α) で減衰され、2 つのサーフェスを加算する、またはバックドロップをソースのカバレッジだけでスケールするプリミティブはないため、atop、xor、plus はベクターとして書けません。さらに 1 つの制限は素のテスト PDF から来ました。どちらのビューアも、コンテンツがフォーム Do をネストするソフトマスクグループを誤計算するため、マスクされるオペランドはフラットでなければなりません。ソリッドカラー、パッドグラデーション、それらを包む PaintGlyph ラッパーは条件を満たします。レイヤー、トランスフォーム、周期的グラデーションはすべて内部フォームを生むため、グリフはフォールバックへ回ります

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 の受け入れ記録はそう率直に述べています。サイクル境界の 1〜2 ステップ以内のサンプルはリーダー補間でなめらかにされるため、ピクセル検査は継ぎ目から離れてサンプリングし、関数ストリームはデコードして検証します。別々に格納されたストレート RGB とアルファのサンプルの補間は、低アルファ端点とハードストップの近くでは近似です。測定誤差はサイクルあたり 256 サンプルで二次ですが、正確ではありません。Poppler Splash はピクセルの中心ではなく左端でサンプリングするため、最も急なランプで小さな差として現れます

予算も現実の限界です。ネストした radial 族は 4096 サイクルを超えるスパンで、円錐と接する族は 4096 必須サイクルを超えると拒否し、特異線近くの接線帯は空のままです。可変ストップアルファは PDF 1.4 のトランスペアレンシーが必要なため、PDF/A-1、PDF/X-1a、PDF/X-3 では有効になりません。グリフがこれらの限界のどれかに外れると、DrawRegisteredColorGlyph はオペレータを書く前にグラフ全体を拒否します。以前と同じオール オア ノシングの挙動です

FAQ

HotPDF は COLRv1 の repeat/reflect グラデーションをラスタライズするのか

いいえ。新しい HotPDF は、サイクル全体を覆うベクターの axial/radial シェーディングとして出力します。OnColorGlyphRasterize へ行くのは、atop、xor、plus 合成のような、対応サブセットの外にあるグラフだけです

全サイクルに 1 つのサンプリング関数ではだめなのか

リーダーは関数からカラーテーブルを事前計算し、MuPDF のテーブルは 256 エントリです。多くのサイクルに広げると、すべてのリピート継ぎ目を跨いで補間します。サイクルごとのシェーディングが、各テーブルを 1 サイクル内に留めます

COLRv1DeviceSpaceGroupBlend はいつオンにすべきか

MuPDF で COLRv1 グリフの黒に近いチャネルがゼロに落ちるときにオンにします。グループカラースペースを尊重するビューアで、重なる半透明レイヤーをリニアライトでブレンドさせたい場合はオフのままにします

ネイティブの COLRv1 グラデーション、カラー絵文字、サンプリングシェーディング、トランスペアレンシーグループはすべて、Delphi と C++Builder のコードから直接 PDF 文書を生成する HotPDF Component for Delphi and C++Builder の一部です