技術記事

PDFのカラーエモジフォント:COLR v1、SVG、ビットマップをDelphiで

Segoe UI Emoji をフォントにして、普通のテキスト呼び出しでニコニコ顔のエモジをPDFに書き込むと、平たい黒いシルエットになります。形は合っているのに、黄色も陰影もありません。CBDTエモジフォントに差し替えると、同じ呼び出しが何も描かないことすらあります。このフォントは画像をPNGファイルとして保持し、アウトラインデータをほとんど、あるいはまったく同梱していないからです。ビューアーから黒い形をコピーすると、クリップボードにはやはり U+1F600 が入っています。文字は正しい。読まれていないのはカラーテーブルだけです

カラーエモジがPDFで黒いシルエットとして印字されるのはなぜか

PDFのフォントプログラムには、色付きグリフという概念がないからです。ISO 32000-1はすべてのグリフを、カレントの塗り色で塗られる形として扱います。OpenTypeが後に追加したカラーテーブル(COLR/CPAL、SVG 、CBDT/CBLC、sbix)はPDFイメージングモデルの一部ではないため、ビューアーが埋め込みフォントからそれらを読み出す理由がどこにもありません。エモジが持つどんな色も、PDFが書かれているあいだにページコンテンツへ変換されなければなりません。プロデューサーがまだフォントバイトを保持し、どのグリフが欲しいのかを正確に知っている、その時点で。この変換はフォーマットごとに異なり、実在のエモジフォントは4つすべてを使います。積み重ねたベクターレイヤー、グラデーションのpaintグラフ、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 を報告する
フォントが両方を運ぶときはビットマップよりベクターデータが勝ちます。拡大や印刷があり得る文書ではそちらが望ましく、カラーパスを持たないグリフはフォールバックに委ねられます

1つのコードポイントの解決と描画

THotPDF.GetRegisteredColorGlyphInfo がコードポイントの取る経路を答え、DrawRegisteredColorGlyph がそれを描きます。どちらも、直近で RegisterUnicodeTTF に渡されたフォントの文字マップでコードポイントを引くため、呼び出しの時点でエモジフォントが登録済みUnicodeフォントである必要があります。グリフにカラーデータがないか、どれでも描けないとき、描画関数は 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)から埋めます。エモジフォントがこのエントリーを使うことはまれです。アイコンフォントは使い、自分のフォントがこれを通してグリフを着色するなら、描画の前にプロパティをテキストの色へ設定してください

TargetPixelsPerEm はビットマップフォントにのみ関わり、その既定値は上書きする価値が最も大きいパラメーターです。ゼロのままでは Round(FontSize * 96 / 72)、つまり画面解像度になり、36ポイントのエモジは48ピクセルになります。sbixのstrikeを20、32、40、48、64、96、160 ppemで宣言しているフォントでは、この既定が48ピクセルのstrikeを選び、600 dpiのプリンターはそれを約6倍に拡大しなければなりません。300を要求すれば、600 dpiで36ポイントに必要な値であり、同じ呼び出しがフォント最大の160のstrikeを取ります

シグネチャーは適用範囲も固定します。入るのはコードポイント1つで、cmap を通してマップされます。ZWJファミリー列、肌色トーン修飾子、地域指示子フラグはGSUB合字なので、先にグリフへshapingしなければなりません。そうした仕事はOpenType GSUB代替グリフの記事が扱っています

PDFベクターとしてのCOLRレイヤーとpaintグラフ

COLR v0は単純なフォーマットです。各ベースグリフはレイヤーグリフのスタックを列挙し、それぞれにCPALの色が付いています。HotPDFはテーブル順に各レイヤーのアウトラインを塗ります。CPAL色のアルファが255未満のレイヤーは固有の /ca を受け取るため、半透明レイヤーはフォントデザイナーが意図したとおり、下のレイヤーとブレンドされます。通常の文書では、スタック全体がその後、1つの分離された透明グループ(ISO 32000-1 §11.4)へ取り込まれます。グループが関心を持つのはグリフの外側で起こることです。エモジの周りに設定した不透明度、ブレンドモード、ソフトマスクは、レイヤーごとに繰り返して重なる部分で二重にかかるのではなく、完成したグリフに1回だけ適用されます

COLR v1は別物です。グラデーション、変換、入れ子のグリフクリップ、合成モードを備えたpaintレコードの有向非巡回グラフで、ノードは PaintColrLayers と PaintColrGlyph を通じて共有され得ます。HotPDFはまずこれを有界なグラフへ解析します。上限はpaintノード4096、深さ96レベル、カラーストップ1024です。さらに訪問中の各ノードを追跡し、現在の経路への逆参照(敵意あるフォントがレイヤー再利用で組み立てられる循環)を、再帰に落とす代わりに拒否します

オフセットは、手書きのCOLR v1パーサーがたいてい壊れる場所です。BaseGlyphPaintRecord のオフセットは BaseGlyphList の先頭起点、LayerList のpaintオフセットは LayerList 起点、paintテーブル内のすべての Offset24 はそのpaintテーブル自身起点です。3つすべてを1つの基準に対して解決すると、完全に合法なグリフまで境界チェックに落ちます。見た目はまるで壊れたフォントです。グラフが組み上がれば、PDFへのマッピングは概ね素直です:

  • PaintGlyph はグリフアウトラインをクリッピングパスに変え、子のpaintがクリップされた領域を塗ります
  • 線形グラデーションはマルチストップのaxialシェーディングに、放射グラデーションはradialシェーディングになります(§8.7.4.5)
  • スイープグラデーションにPDFのシェーディングタイプはないため、中心の周りに96個の単色ウェッジとして描かれ、それぞれカラーラインからサンプリングされます
  • 変換は cm オペレーターになり、並進は FontSize / UnitsPerEm でスケールされます
  • PaintComposite のモード13から27は、同名のPDFブレンドモード、/Multiply、/Screen、/Luminosity など(§11.3.5)へ、ExtGStateの /BM エントリーを通してマップされます

半透明ストップを含むグラデーションには、もう1つ罠が隠れています。COLR v1はアルファで前乗算した色を補間し、PDFはストレート(非前乗算)の色を期待して自分でアルファを適用します。前乗算済みの値をPDFへ渡すコンバーターは、半透明の帯をすべて二重に暗くすることになります。色は補間の後、書き出しの前に、前乗算を解除しなければなりません

HotPDFのCOLR v1変換の図。paintグラフは循環拒否付きで、ノード4096、深さ96レベル、カラーストップ1024を上限とする有界グラフへ解析され、PaintGlyph はアウトラインのクリッピングパスに、線形と放射のグラデーションはaxialとradialのシェーディングに、スイープグラデーションは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モードは1つのオペランドに掛けるアルファのソフトマスクであり、atop、xor、plusは分離フォーム、グループアルファ、ルミノシティマスク、ブレンド演算から組み立てられます。HotPDFはそのように構築するため、これらのグリフはベクターのままです

代償は、こうした構成の大部分がPDF透過性を必要とするのに、PDF/A-1、PDF/X-1a、PDF/X-3はそれを禁じることです。PDFACompliance がPDF/A-1の値、または PDFXCompliance が 'X-1a' か 'X-3' のとき、HotPDFは描画の前にグリフを検査し、透過なしでは描けないケースをラスタイベントへ回します。atop、xor、plusの合成、マスクされるオペランドが単純な不透明paintを超えるin/out合成、部分アルファを持つソリッドpaint、半透明ストップを含むグラデーション、そしてアルファが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);   // 自前のロギングルーチン

アーカイブがエモジをカラーで見せる必要があるなら、PDF/A-1bではなくPDF/A-2bを目標にしてください。Part 2は透過性を許し、すべてのCOLR構成がベクターのまま残り、フォールバックハンドラーは、半透明エモジごとの主レンダラーではなく安全網に戻ります。名前でPDF/A-1を要求するワークフローには、PDF/A-1を維持してください

読み手側の設定も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 スタイルシートがあると、ビルダーは文書を丸ごと拒否します。フォームは、長い辺がフォントサイズと等しくなるようスケールされます。実運用前に知っておきたい点が1つあります。いくつかのフォントは多数のグリフを1つの共有SVG文書へ詰め込んでおり、ビルダーはグリフごとにその文書全体を受け取るのです

ビットマップフォントは、strikeの選択と配置に帰着します。CBDTでは、HotPDFは垂直ppemが TargetPixelsPerEm に最も近いCBLCサイズを選び、画像フォーマット17、18、19を受け入れ、フォーマット19のメトリクスはCBLCのインデックスサブテーブルから読みます。このフォーマットは自分のメトリクスを一切保持しないからです。sbixでは、グリフを実際に含む最も近いstrikeが勝ちます。strikeのオフセットはテーブル起点、グリフのオフセットはstrike起点です。dupe レコードは別のグリフの画像を再利用しつつ、自分自身の原点オフセットを保ちます。参照が外側の原点を上書きするパーサーは、画像を横へずらすことになります。PNGとJPEGのペイロードは内部でデコードされ、フォントサイズへ引き伸ばされるのではなく FontSize / PixelsPerEmY でスケールされ、完全不透明でないピクセルが1つでもあればソフトマスク(§11.6.5.3)付きで書き出されます。sbixのTIFFペイロードはイベントへ回ります

ラスタフォールバックハンドラーを書く

ネイティブの経路がどれも使えないとき、HotPDFは OnColorGlyphRasterize を上げ、ハンドラーが返したRGBAビットマップを配置します。ハンドラーがないか、Handled が False のままなら、DrawRegisteredColorGlyph は False を返し、何も描かれません。ハンドラーが受け取るのは、フォーマット、フォントバイト、取り出されたアセット(SVG文書、場合によりまだgzipのまま、あるいはビットマップバイト。COLRでは空)、グリフID、パレット、目標ピクセルサイズです

HotPDFのラスタフォールバックの図。対応済みサブセット外のCOLR v1グラフ、安全なビルダーが拒否したSVG文書、デコーダーが読めないビットマップペイロードに対して OnColorGlyphRasterize が発火し、フォーマット、フォントバイト、アセット、GlyphID、PaletteIndex、PixelSize を渡す。返されたRGBAバッファーは、長さがきっかり Width × Height × 4 のときだけ受け入れられる
ゼロのサイズ、間違ったバッファー長、あふれる寸法は、ページに触れる前に拒否されます。ハンドラーなしや Handled が偽のときは、呼び出しは 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 を返します。可変カラーフォントでは、ハンドラーは登録済みの軸値における静的インスタンスを受け取ります。もし代わりに元の可変フォントが届いたら、Sender(THotPDF にキャスト)に対して GetRegisteredVariableFontAxes を呼べば、描画すべき座標が返ります。PixelSize は同じ TargetPixelsPerEm の値なので、上記の印刷解像度の助言がここでも当てはまります

任意のユーザーテキストを印字するパイプラインは、2種類の欠落を知る必要があります。フォントが最初から持っていなかったグリフと、色を失ったグリフです。前者は未解決グリフ追跡の記事にある描画時カバレッジ追跡が報告し、後者は DrawRegisteredColorGlyph からの False が報告します

COLR、SVG、ビットマップのエモジレンダリング、OpenTypeのshaping、安全なSVGビルダーは、すべてDelphiとC++Builder向けHotPDFコンポーネントの一部です