技術記事

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

HotPDFはTHotPDF.DrawRegisteredColorGlyphを通してカラーエモジをPDFへ描きます。RegisterUnicodeTTFで登録されたフォントのカラーデータを読み、ネイティブなPDFグラフィックスとして出力します。COLR v0のレイヤーは塗りつぶされたグリフアウトラインに、COLR v1のpaintグラフはクリップとシェーディングとブレンドモードに、SVGグリフはForm XObjectに、CBDTやsbixのビットマップは画像になります。ネイティブにマップできないものは、黙って黒い形に変わる代わりに、OnColorGlyphRasterizeイベントへ行きます

この最後の一文こそ、このコードが存在する全部の理由です。普通のやり方でエモジフォントを埋め込むと、ビューアーはglyfかCFFのアウトラインを受け取り、たまたまカレントの塗り色だったもので満たします。笑顔の顔は黒い塊として届き、旗は長方形として届き、パイプラインのどこも文句を言いません

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

PDFのフォントプログラムにはカラーグリフという概念がありません。ISO 32000-1はグリフを、カレントの色で塗られる形として扱い、OpenTypeが後に追加したカラーテーブル、つまりCOLR/CPAL、SVG 、CBDT/CBLC、sbixは、PDFイメージングモデルの一部ではないので、どのビューアーも埋め込みフォントからそれらを読む義務はありません。色は生成時に、ページ内容へ翻訳されなければなりません。プロデューサーがまだフォントのバイト列を握っていて、どのグリフが欲しいか知っているその時に。その翻訳はフォーマットごとに違い、野外のエモジフォントは全部を使います。レイヤー化されたベクター、グラデーションの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を報告します
フォントが両方を運ぶときはビットマップよりベクターデータが勝ちます。拡大や印刷があり得る文書ではそちらが望ましく、カラーパスのないグリフはフォールバックに委ねられます

1つの呼び出し、5つのフォーマット:カラーグリフの解決と描画

コードポイントがどの経路を取るかは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';
    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;

2つのパラメータに注目の価値があります。PaletteIndexはCPALパレットを選ぶので、暗い背景用のパレットを同梱するフォントは、グリフに触れずに切り替えられます。TargetPixelsPerEmはビットマップフォントでだけ意味があり、ゼロのままだとRound(FontSize * 96 / 72)、つまりスクリーン解像度が既定になります。だから例が印刷出力のために300を要求しているのです。正直な限界はシグネチャーにあります。この呼び出しは1つのコードポイントを受け取り、cmapだけを通してマップします。ZWJシーケンス、肌の色トーンのモディファイア、regional-indicatorの旗はGSUBリガチャーなので、それらを組み立てることはOpenType GSUB代替字形の記事が扱う種類のshaping問題であり、このエントリーポイントが代わりにやってくれることではありません

COLR v0:パレット色のスタックされたグリフレイヤー

COLR v0は単純なケースで、HotPDFは直接描画します。各ベースグリフはCPALの色エントリー付きでレイヤーグリフを列挙し、各レイヤーは自分の塗り色を持つ、普通のテキスト表示操作1つになり、テーブル順にスタックされます。アルファが255未満のレイヤーは、一致する/caと/CAを持つgraphics state parameter辞書を得ます(ISO 32000-1 §8.4.5)。そしてすべてのレイヤーグリフは使用済みとして印を付けられるので、コードポイントが直接マップしなくても、サブセッターはそのアウトラインを保持します。人は驚きやすい細部が1つあります。パレットエントリーインデックスの0xFFFFは、OpenType仕様では「テキストの前景色を使え」という意味ですが、HotPDFはこれをカレントのページ塗り色ではなく、黒として解決します。エモジフォントではほとんど問題になりません。前景エントリーに頼ってグリフを着色するアイコンフォントでは、テキストの色に従うと決めつける前に、出力を確認してください

HotPDFはCOLR v1のpaintグラフをどうPDFオペレーターへ変えるか

まずpaintテーブルを平坦で有界なグラフへ解析し、それから初めて各ノードをPDFの構成要素へマップする、というやり方です。COLR v1のグリフはレイヤーのリストではなく、paintレコードの有向非巡回グラフで、ノードはPaintColrLayersとPaintColrGlyphを通して共有され得ます。パーサーは4096 paintノード、深さ64階層、カラーストップ1024個で上限を設け、すべてのノードをactiveかdoneか追跡します。activeノードへの参照、つまり悪意あるフォントがレイヤー再利用で組み立てられるサイクルは、再帰する代わりに拒否されます。オフセットの基準こそ、最初の実装が間違える場所です。BaseGlyphPaintRecordのオフセットはBaseGlyphListの先頭基準、LayerListのpaintオフセットはLayerList基準、paintテーブルの中のOffset24はすべてそのpaintテーブル自身基準です。3つを同じ基準で解決すると、完全に合法なグリフが境界検査に落ちます。壊れたフォントそっくりに見える失敗です。グラフが組み上がれば、マッピングは直接的です:

  • PaintGlyphはグリフアウトラインを、テキストレンダリングモード7(ISO 32000-1 §9.3.6)のクリップとして設定し、その中で子を描きます
  • ソリッドのpaintはクリップされた矩形を塗りつぶし、線形グラデーションはマルチストップのaxial shadingに、放射グラデーションは2色のradial shadingになります(§8.7.4.5)
  • sweepグラデーションにはPDFの対応物がないので、HotPDFはカラーラインからサンプルした、単色のくさび96個で近似します
  • 変換はcmとして出力され、グリフのベースライン原点のまわりで共役化され、並進はFontSize / UnitsPerEmでスケールされます
  • PaintCompositeのモード13から27は、/Multiply、/Screen、/Luminosityのような分離可能と非分離のPDFブレンドモード(§11.3.5)へマップし、ExtGStateの/BMエントリーで設定されます

境界は明示的です。Porter-Duffのモード5から12(src_in、xor、plusなど)にはPDFブレンドモードの対応物がなく、線形と放射グラデーションのrepeatとreflectのextendモードは出力されず、ストップが異なるアルファ値を運ぶグラデーションは、単一の不透明度で偽装されません。ストップを2つ超える放射グラデーションは、最初と最後の色だけを保ちます。HotPDFは、1つのオペレーターを書く前に、グラフ全体をこの対応済みサブセットと照合します。だから対応外のグリフはページを無傷のままにして、描きかけを残す代わりに、ラスターフォールバックへ進みます

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で頭打ちです。プロファイルは意図的に制限的です。スクリプト、埋め込み画像、外部URL、data: URI、非ローカル参照はfail closedします。フォームは長い辺がフォントサイズと等しくなるようスケールされ、ベースラインに固定されます。これはy下向きのSVG座標系をy上向きのPDF座標系へ写します。注意として、ビルダーはグリフのためのSVG文書全体を受け取ります。glyphNNN要素の選択はありません。だから多くのグリフを1つの共有文書へ詰め込むフォントは、頼る前にテストする価値があります

ビットマップフォントはstrikeの選択と配置の問題です。CBDTでは、HotPDFは垂直ppemがTargetPixelsPerEmに最も近いCBLCサイズを選び、画像フォーマット17、18、19を受け付け、フォーマット19のメトリクスはCBLCのインデックスサブテーブルから読みます。このフォーマットは自分のメトリクスを一切格納しないからです。sbixでは、strikeのオフセットはテーブル基準、グリフのオフセットはstrike基準であり、dupeレコードは別のグリフのグラフィックを、自分のoriginオフセットを保ったまま再利用します。再帰に外側のoriginを上書きさせると、画像はずれます。PNGとJPEGのペイロードは内部でデコードされ、フォントサイズへ引き伸ばされる代わりにFontSize / PixelsPerEmYでスケールされ、完全に不透明でないピクセルが1つでもあるときはソフトマスク付き(§11.6.5.3)で書かれます。sbixのTIFFペイロードはデコードされず、イベントへ行きます

グリフをネイティブに描けないとき何が起きるか

HotPDFはOnColorGlyphRasterizeを上げ、あなたのハンドラーが返したRGBAビットマップを、なんでも配置します。何も割り当てられていないか、ハンドラーがHandledを偽のままにした場合は、DrawRegisteredColorGlyphがFalseを返し、ページは無変更のままです。イベントが発火するのは、対応済みサブセットの外にあるCOLR v1グラフ、セーフビルダーが拒否したSVG文書、内部デコーダーが読めないビットマップペイロードです。ハンドラーは、フォーマット、生のフォントバイト列、取り出されたアセット(SVG文書、まだgzipのままかもしれない、またはビットマップのバイト列。COLR v1では空)、グリフID、パレット、ターゲットのピクセルサイズを受け取ります

HotPDFのラスターフォールバックの図。OnColorGlyphRasterizeは、対応済みサブセットの外のCOLR v1グラフ、セーフビルダーが拒否したSVG文書、デコーダーが読めないビットマップペイロードで発火し、フォーマット、フォントバイト列、アセット、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を返します。ラスターフォールバックはそれでもラスタです。このやり方で描かれたエモジはベクターの鋭さを失います。出力解像度に合ったPixelSizeを要求してください。カラーパスを、欠落グリフ追跡の記事の描画時カバレッジ検査と組み合わせれば、任意のユーザーテキストを扱うパイプラインは、欠落グリフと色を失ったグリフの両方を報告できます

カラーグリフレンダラー、OpenTypeのshapingスタック、そしてセーフなSVGビルダーは、DelphiとC++Builderで利用できるHotPDF Delphi PDF componentにすべて同梱されています