技術記事

PDF での合成グリフと CFF seac アクセントのレンダリング

HotPDF は、1 つのグリフを他のグリフから組み立てる 2 つのフォント機構を正しく扱うことで、埋め込みフォントのアクセント付き文字を正しくレンダリングします。1 つは TrueType 合成グリフで、コンポーネントのオフセットはグリフ単位で、グリフからデバイスへのマトリクスより先に適用されねばなりません。もう 1 つは CFF の seac 形式の endchar で、StandardEncoding コードによってベース文字とアクセントを縫い合わせます。どちらかを間違えると、é、Å、ü、カールした二重引用符が一部を欠いて出てきます。周囲の普通の文字は完璧に見えるのに、です

最後の細部こそが、これらのバグを高くつかせるものです。レポートはレンダリングされ、本文は無傷、壊れているのは「München」に穴が空いているだけ、あるいは引用符がシングルクォート 1 個と空白で始まるだけです。誰もレンダラーを疑いません。最初の仮説はいつも壊れたフォントか悪いプロデューサで、チケットは 1 週間たらい回しになります。本記事では、各機構で実際に何が起きるのか、HotPDF Delphi コンポーネントが v2.768.10 と v2.768.11 以降でどう扱うか、そして HotPDF がフォントを読む代わりに書くときに合成グリフを無傷に保つ、v2.770.83 の関連するサブセット化の修正をたどります

PDF グリフからアクセントだけが消えるのはなぜか

アクセントが消えるのは、グリフの中でゼロでないオフセットを運ぶのがこの部分だけだからです。TrueType と CFF のどちらのフォントも、アウトラインの再利用で容量を節約します。é は新しい形状として描かれるのではなく「e グリフ、プラス右上にずらした acute グリフ」として描かれます。ベース文字はほぼ常にオフセット (0, 0) に座っているため、オフセットの適用方法のどんな誤りもこれには触れません。動くコンポーネントはアクセントであり、だから間違った場所に着地するか、まったく描かれないかするのもアクセントです

この症状のパターン自体が診断材料です。アクセント付き文字のベース文字はレンダリングされるのにダイアクリティカルマークが出ない、あるいは “ と ” の対の句読点が 2 つではなく 1 つの記号として現れる場合は、フォントファイルを調べるのを止めてください。壊れたフォントは通常グリフ全体を壊し、それはすべてのビューアで壊れます。1 つのレンダラーだけで組み立てグリフの部品が欠け、Acrobat ではページが正常に見えるなら、疑うべきはコンポーネントを組み立てるコードです

TrueType 合成グリフはコンポーネントをどう配置するのか

TrueType 合成グリフは、各コンポーネントをフォント単位で表された独自の小さなアフィン変換で配置します。この変換は、グリフからデバイスへのマトリクスより先に、コンポーネントのアウトラインに作用せねばなりません。OpenType の glyf テーブルでは、合成グリフは負の輪郭数を持ち、その後にコンポーネントレコードのリストが続きます。各レコードはフラグワード、コンポーネントのグリフインデックス、バイトかワードの 2 つの引数(ARG_1_AND_2_ARE_WORDS、0x0001)、そしてオプションのスケールを持ちます。WE_HAVE_A_SCALE(0x0008)なら F2Dot14 値 1 つ、WE_HAVE_AN_X_AND_Y_SCALE(0x0040)なら 2 つ、WE_HAVE_A_TWO_BY_TWO(0x0080)なら完全な 2x2 マトリクスです。ARGS_ARE_XY_VALUES(0x0002)が立っているとき、2 つの引数はフォント単位の dx/dy オフセットです。MORE_COMPONENTS(0x0020)は、次のレコードが続くことを告げます

そこで各コンポーネントについて、スケール部とオフセットからコンポーネントマトリクス CM を組み立て、フォント単位をデバイスピクセルに写すマトリクス M と結合します。この結合の順序こそがバグの全てです。HotPDF の内部のマトリクス乗算は列ベクトル規約を使うため、A と B の積は B を先に、次に A を適用します。正しい合成は、グリフ空間で先に CM、次に M です。逆に書くと、M が先に走り、CM のオフセットは後から足されます。既にデバイスピクセルであり、決してスケールされません

// 概念例: 列ベクトル規約の 2D アフィンレコード。
// Mul(A, B) は点をまず B を通し、その後 A を通して写す
type
  TAffine = record
    a, b, c, d, e, f: Double;
  end;

function Mul(const A, B: TAffine): TAffine;
begin
  Result.a := A.a * B.a + A.c * B.b;
  Result.b := A.b * B.a + A.d * B.b;
  Result.c := A.a * B.c + A.c * B.d;
  Result.d := A.b * B.c + A.d * B.d;
  Result.e := A.a * B.e + A.c * B.f + A.e;
  Result.f := A.b * B.e + A.d * B.f + A.f;
end;

// M  : フォント単位 -> デバイスピクセル(微小スケール、y 反転)
// CM : コンポーネント変換。e/f はフォント単位の dx/dy を保持
Sub := Mul(M, CM);   // 正しい: オフセットはアウトラインと共に縮小される
// Sub := Mul(CM, M); // 誤り: dx/dy が生のデバイスピクセルとして着地する

数字は、一度目にすれば失敗を明白にします。Arial は 2048 単位の em を使い、é のアクセントコンポーネントはそのフォント単位でオフセットを運びます。正しい順序では、このオフセットはアウトラインと共に縮まり、通常のズームでは数ピクセルになります。逆の順序では、生のフォント単位値がデバイスピクセルとして加算され、HotPDF のテストレンダリングでは Arial の é のアクセントが文字から 243 ピクセル離れて着地しました。グリフのはるか外、たいていは可視範囲の外です。Arial の左二重引用符はシングルクォート形状の 2 つのコピーで、2 つ目がずらされたものです。だから 1 個の ‘ とそれに続く空の空間としてレンダリングされていました

線形部分はめったにこのバグを露出しません。アクセントコンポーネントは通常まったくスケールを運ばず、一様スケールはどのみち M と可換です。並積の逆転を暴くのは平行移動だけです。素の Latin テキストでの手早いテストが通ってしまうのも同じ理由です。逆転は v2.768.10 が直すまで多くのリリースにわたってコードに座り続け、それを見せたのはオフセットを持つコンポーネントだけでした

再帰と循環

合成グリフは他の合成グリフを参照し得るため、レンダラーは再帰的にたどり、自衛せねばなりません。HotPDF はネストの深さを 5 レベル、グリフごとのコンポーネント総数を 1024 で打ち止めにし、自分自身のグリフインデックスを名指すコンポーネントはスキップし、めったに使われないポイントマッチングモード(オフセットではなくポイント番号である引数)はゼロオフセットとして扱います。敵対的あるいは破損したフォントはコンポーネントを 1 つ失わせることはできても、ページのレンダリングをハングさせることはできません

CFF endchar の seac 形式とは何か

endchar の seac 形式は、CFF Type 2 charstring が「このグリフはベース文字プラスアクセントだ」と述べる、非推奨の方法です。endchar が 4 つの引数を持つとき、それは adx ady bchar achar です。Adobe Technical Note #5177 が Type 1 との互換性に関する付録で説明しています。名前は Type 1 の seac オペレータ(standard encoding accented character)に由来し、Type 1 フォントから変換された CFF サブセットは今も、アクセント付き Latin 文字のためにこれを多用します

実装者をつまずかせる細部が 3 つあります。第 1 に、bchar と achar はグリフ ID ではありません。Adobe StandardEncoding のコードであり、レンダラーは各コードをグリフ名へ写像し、その名前をフォントの charset で引いて GID を見つけます。第 2 に、Type 2 には asb 引数がありません。Type 1 の seac が asb adx ady bchar achar を取るのと違います。Type 2 ではアクセントの原点は、サイドベアリング補正なしに、ベースグリフの原点からの (adx, ady) に直行します。第 3 に、CID キー化 CFF フォントは seac をまったく使えません。StandardEncoding コードを解決するためのグリフ名 charset が存在しないからです

それから幅があります。すべての Type 2 charstring は、最初のスタッククリアオペレータをオプションのアドバンス幅引数で始められ、endchar はそのオペレータの 1 つです。引数 1 個の素の endchar は幅を運び、引数 5 個の seac の endchar は幅に続いて 4 つの seac 値を運びます。規則は、引数を期待される固定数——seac では 4——に対して数えることです。v2.768.11 より前の HotPDF はどんな endchar も「幅を取って停止」と扱っていたため、seac グリフは空のアウトラインを生んでいました。ベースもアクセントもなく、行の中の正しくサイズされた空白だけです

// Type 2 インタプリタにおける endchar 判定の概念例。
// Stack は endchar に先行するオペランドを保持。Count はその個数。
if (Count >= 4) and not IsCIDFont then
begin
  // 5 個のオペランド: 最初はアドバンス幅、残りが seac
  if Count = 5 then
    TakeWidthAndShift;                 // 幅を落とし、4 オペランドを残す
  if StackDepth = 4 then
  begin
    BaseGID   := GIDForName(StdEncName(Round(Stack[2])));  // bchar
    AccentGID := GIDForName(StdEncName(Round(Stack[3])));  // achar
    DrawGlyph(BaseGID, Identity);
    DrawGlyph(AccentGID, Translate(Stack[0], Stack[1]));   // adx, ady
    Exit;
  end;
end;
// それ以外: 通常の endchar。オプション幅、パスを閉じる

実際のインタプリタでは、両方のコンポーネントグリフは同じ charstring エンジンで実行されます。アクセントのマトリクスは現在のグリフマトリクスと (adx, ady) への平行移動の合成であり、後でマトリクスは復元されるため、行の後続のグリフは影響を受けません。どちらかの名前が GID に解決されない場合、そのコンポーネントはグリフ全体を失敗させるのではなくスキップされます。サブセットが少し切り詰められすぎているときに欲しい、寛容な挙動です

自分の Delphi コードから修正を確認する

合成グリフと seac の処理を検証する最速の方法は、アクセント付きテキストを含むページを、埋め込みフォントから 2 つの公開出力経路でレンダリングすることです。ラスタ出力の THotPDF.RenderLoadedPageToBitmap と、EmbedFonts を True にしたベクタ出力の THotPDF.BuildLoadedPageSVG です。両者は同じグリフアウトラインコードを共有するため、どちらも v2.768.10 と v2.768.11 の修正を受けました。パイプライン全体についてはHotPDF が埋め込み TrueType、CFF、Type 3 グリフをレンダリングする仕組み、ビットマップのオプションはDelphi で PDF ページをビットマップにレンダリングするで扱っています

uses
  System.Classes, Vcl.Graphics, HPDFDoc;

procedure RenderAccentProbe(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  Svg: AnsiString;
  Lines: TStringList;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) <= 0 then
      Exit;

    // ラスタ経路: 144 DPI なら位置ずれしたアクセントを見つけやすい
    Bmp := Pdf.RenderLoadedPageToBitmap(0, 144);
    if Bmp <> nil then
    try
      Bmp.SaveToFile('accent-probe.bmp');
    finally
      Bmp.Free;
    end;

    // ベクタ経路: EmbedFonts = True はグリフのアウトラインを書き出すため、
    // 合成と seac の組み立てが SVG パスそのものの中に見える
    Svg := Pdf.BuildLoadedPageSVG(0, True);
    if Svg <> '' then
    begin
      Lines := TStringList.Create;
      try
        Lines.Text := string(Svg);
        Lines.SaveToFile('accent-probe.svg');
      finally
        Lines.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

テスト文書は意図して選びます。é、Å、カールした二重引用符を含む埋め込み TrueType サブセットの PDF を 1 つ、そして Type 1 フォントから作られた埋め込み CFF(FontFile3、Type1C)サブセットの PDF を 1 つ欲しいところです。seac が今も生きているのは後者で、たいてい古い組版出力や一部の印刷ワークフローに見られます。プロデューサが埋め込まなかったフォントで組まれたテキストは、ここでは何も教えてくれません。それらのグリフは、ISO 32000-1 §9.9 が記述する埋め込みフォントプログラムではなく、インストール済みの Windows フォントから来るからです

フォントのサブセット化で合成グリフが壊れたのはなぜか

サブセット化で合成グリフが壊れたのは、コンポーネントがスケールや 2x2 変換を運ぶたびに、サブセット化器がコンポーネントレコードを誤ったストライドで読み進め、次のコンポーネントのグリフインデックスを誤読していたからです。HotPDF はこれを v2.770.83 で修正しました。サブセット化はレンダリングのバグの書き込み側の双子です。HotPDF がインストール済み TrueType フォントのサブセットを埋め込むとき、合成グリフが参照するすべてのグリフを保持し、それらの参照を新しい、コンパクトなグリフ ID へ振り直さねばなりません

旧いウォーカは引数サイズのフラグと more-components フラグしか見ていなかったため、レコードごとに 6 か 8 バイト進みました。スケールフラグが加える 2、4、8 バイトの変換データを無視していたのです。スケールを持つコンポーネントの後では、次に読んだ「グリフインデックス」は実際には変換バイトでした。本物のコンポーネントはサブセットから漏れ得て、振り直しの段階は新しいグリフ ID を変換データへ直接書き込み得ました。結果として、一部のビューアで間違ったアクセント、歪んだアクセント、あるいはアクセントなしでレンダリングされるアクセント付き文字が生まれます。しかもコンポーネントをスケールするフォントでのみ発生するため、ほとんどのテストはこれを捕まえませんでした

ストライドのバグの隣に、もう 2 つの問題が座っていました。グリフインデックスの振り直しは冪等ではなく、ネストした合成が 2 回振り直されていました。マークされた全合成をめぐるパスで 1 回、その内部への再帰降下でもう 1 回です。HotPDF は今や各合成をちょうど 1 回だけ振り直し、「この合成はどのグリフを必要とするか」の段階をワークキューから駆動します。互いを参照する合成の循環は終了し、フォントの maxp グリフ数を超えるグリフインデックスは拒否され、空のグリフが合成としてパースされることはありません

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'accents-subset.pdf';
    Pdf.FontEmbedding := True;          // 使われるグリフを埋め込む
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 14);
    // é、Å、ü、カール引用符はすべて Arial では合成グリフ
    Pdf.CurrentPage.UnicodeTextOut(50, 720, 0,
      'Café, Ångström, München, “quoted”');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

そのファイルを上のプローブと 2 つ目のビューアに回してみてください。v2.770.83 からは、スケールや 2x2 変換の背後にあるコンポーネントを含め、すべての合成グリフのすべてのコンポーネントがサブセット化を生き延びます。アクセントは HotPDF、Acrobat、ブラウザのビューアで同じようにレンダリングされます

合成グリフ処理がカバーしないもの

合成グリフの修正がカバーするのはオフセットベースの配置であり、これが現実のほぼすべてのフォントが使う方式です。残りは保守的に近似します。2 つの引数がオフセットではなく整列すべきポイントを名指す TrueType のポイントマッチング合成は、ゼロオフセットで描かれます。CID キー化 CFF フォントの seac は、仕様がそこでの使用を禁じるため設計上無視されます。切り詰められたサブセットにベースやアクセントのコードに対応するグリフがない seac は、見つけられるコンポーネントを何でも描きます。これらのどのケースもレンダラーをクラッシュさせませんが、アーカイブや PDF/A 向けに PDF を作るなら、より安全なのは、オフセット合成と seac なしの Type 2 charstring を使うフォントを埋め込むことです。1 つのレンダラーだけでなく、すべての消費者が同じ文字を見られるようにするためです

FAQ

自分の PDF レンダラーでアクセント付き文字がアクセントなしでレンダリングされるのはなぜか

アクセント付き文字はたいてい合成グリフか CFF の seac グリフであり、アクセントはオフセットを持つ唯一のコンポーネントです。コンポーネントのオフセットをデバイスマトリクスの後に適用するレンダラーや、endchar の seac を無視するレンダラーは、ベース文字を正常にレンダリングしながら、アクセントを落としたり場所を間違えたりします

HotPDF は CFF endchar の seac 形式に対応しているのか

はい。v2.768.11 から、HotPDF は引数 4 個、あるいは幅を含め 5 個の endchar を、ベースグリフ プラス (adx, ady) にずらしたアクセントとして描きます。両方とも StandardEncoding 名とフォント charset で解決し、ビットマップレンダリングと埋め込みフォント付き SVG エクスポートの両方に対応します

HotPDF が TrueType フォントをサブセット化するとき合成グリフは保持されるのか

はい。v2.770.83 から、HotPDF は 2、4、8 バイトのコンポーネント変換を正しく読み飛ばし、参照されるすべてのコンポーネントをサブセットに保持し、ネストした合成を 1 回だけ振り直し、循環する、あるいは範囲外のグリフ参照で安全に停止します

合成グリフのレンダリング、CFF seac のサポート、安全な TrueType サブセット化は、HotPDF Delphi PDF Component の一部として出荷されています。外部 DLL なしで PDF 文書を作成、編集、抽出、レンダリングする、Delphi と C++Builder 向けのネイティブ VCL ライブラリです