技術記事

Delphi で描画時に欠落 PDF グリフを検出する

PDF の欠落グリフはエラーではありません。生成側が選択されたフォントがマップできない文字を要求すると、フォントはグリフインデックスゼロを返し、でき上がるファイルは構造的に有効で、どこでも開き、名前や金額があるべき場所に空の箱を表示します。生成パイプラインの中の誰も気づきません。受け取った人が気づきます。HotPDF はこのループを TrackUnresolvedGlyphs で閉じます。これを有効にすると、テキスト描画経路は、グリフ検索がインデックスゼロに解決するすべてのコードポイントを記録し、一意な発見ごとに 1 回 OnUnresolvedGlyph を発火します。コードポイント、失敗したフォント、それが属するスクリプト、そしてそれをカバーするフォントの提案を伴います

検出は答えの半分です。もう半分は SetFontFallbackChain です。スクリプトごとに順序付きフォントリストを登録し、よくあるケースは自己解決し、本当に隙間であるものだけがハンドラへ届きます。この 2 つで、かつて顧客から報告されていた一連の欠陥が、ビルド時のチェックに変わります

欠落グリフはなぜ何も投げないのか

ISO 32000 は、カバレッジを検証する義務を生成側に課していないためです。そしてグリフインデックスゼロは正当なグリフです。それは .notdef であり、そのアウトラインはフォントデザイナーが選びます。通常は空または中空の矩形、ときには何もありません。それを描くビューアは正しく振る舞っています。テキスト抽出は正しい文字を返すことさえあります。/ToUnicode マッピングはアウトラインからではなくソーステキストから書かれるためです。したがって自動化されたラウンドトリップチェックは、表示テキストに穴の開いた文書を喜んで通します

欠落 PDF グリフが静かであり続ける理由の図。グリフゼロが空の箱を描く間、ToUnicode 抽出はラウンドトリップチェックを通る。
グリフゼロは正当な .notdef の答えであり、/ToUnicode はソーステキストから書かれます。そのためパイプライン内の何ものも、この隙間について告げられません

実務上の帰結は、カバレッジは描画の時点で確認しなければならないということです。ライブラリがまだ、どのコードポイントが要求され、フォントが実際にどのグリフを提供したかを知っているうちに。その後は情報は消えます

検出器が見るべきはデバイスコンテキストではなくサブセット状態

最初の実装が誤ったのはここであり、その理由は理解する価値があります。テキストパイプラインに後付けされるあらゆるカバレッジチェックに当てはまるからです。HotPDF には 2 つのテキスト経路があります。1 つは、登録時に構築されたメモリ内文字マップを持つ、登録済み Unicode TrueType フォントを通じて出力します。もう 1 つは、文字の実行ごとに新しいデバイスコンテキストとフォントハンドルを作るレガシー GDI 経路です

GDI 経路からカバレッジを判断するのは見込みがありません。そのマッピングは、出力されるコンテンツストリームに最終的に入るマッピングではなく、両者は同期されていません。そのため GDI の結果を読む検出器は、印字可能な ASCII 範囲全体を未解決として報告します。権威ある答えは登録済みフォントの中にあります。RegisterUnicodeTTF が解析する文字マップであり、GetUnicodeGlyphForCodepoint で照会します。したがって検出器は、どの GDI 条件でもなくサブセット準備完了状態でゲートされ、Unicode フォントを一度も登録しない文書では単純に動きません。それらの文書はいずれにせよ標準エンコーディングに制限されているため、これが正しいです

2 つ目の罠がその隣にあります。フォントの GDI ファミリー名と、登録時にフォントバイナリから抽出された PostScript 名は異なる文字列であり、正規化できる類の違いではありません。Arial Unicode MS というファミリーは、PostScript 名 ArialMT を運びます。「現在選択されているフォントは登録したものか」というゲートを名前で比較して書くと、決して発火しない死んだコードです。ゲートは状態に対して設けます。フォント名に対して決して

サブセット状態のゲート、GetUnicodeGlyphForCodepoint の照会、OnUnresolvedGlyph イベントの配線を示す HotPDF 未解決グリフ検出のフロー。
カバレッジは GDI ではなく登録済み Unicode フォントマップから判断され、一意なコードポイントごとに 1 つのイベントがフォント提案とともに発火します

グリフ検出器を絵文字でテストしない

自明なテストケースは笑顔の顔であり、検出器が壊れていると確信させてくれます。追加面(astral planes)の一般的な絵文字コードポイントは、それを直接グリフインデックスへマップする私用領域の合成経路を通って解決するため、一般的なカバレッジ分岐へは決して届きません。検出器は正しく振る舞っており、テストが誤った経路を測っています

代わりに未割り当てのコードポイントを使ってください。U+0378 は Unicode で恒久的に未割り当てであり、どのフォントも正当にはマップできず、確認したい分岐を正確に通ります。「機能が壊れている」と「テストが機能を迂回する入力を選んだ」の区別には実際の時間がかかり、未割り当てコードポイントはそれを避ける最も安価な方法です

type
  TCoverageAudit = class
  private
    FFindings: TStringList;
  public
    procedure Handle(Sender: TObject;
      const Info: THPDFUnresolvedGlyphInfo);
    property Findings: TStringList read FFindings;
  end;

procedure TCoverageAudit.Handle(Sender: TObject;
  const Info: THPDFUnresolvedGlyphInfo);
begin
  // 一意なコードポイントごとに 1 回発火。出現ごとではない
  FFindings.Add(Format('U+%.4X missing in %s (script %d), try: %s',
    [Info.CodePoint, String(Info.FontName), Ord(Info.Script),
     String(Info.SuggestedFonts)]));
end;

// 生成ジョブへの配線
Pdf := THotPDF.Create(nil);
try
  Pdf.TrackUnresolvedGlyphs := True;
  Pdf.OnUnresolvedGlyph := Audit.Handle;
  Pdf.RegisterUnicodeTTF('C:\Windows\Fonts\arial.ttf');
  Pdf.BeginDoc;
  Pdf.CurrentPage.SetFont('Arial', [], 11);
  Pdf.CurrentPage.TextOut(50, 720, 0, CustomerName);
  Pdf.EndDoc;
  if Audit.Findings.Count > 0 then
    // 箱だらけのページを出荷する代わりにジョブを失敗させる
    raise Exception.Create(Audit.Findings.Text);
finally
  Pdf.Free;
end;

フォールバックチェーンはスクリプトごと。フォントごとではない

フォールバックがソースフォントごとではなくスクリプトごとにスコープされる理由は、カバレッジの隙間が書記体系ごとに塊になるからです。ラテン文字のテキストフォントは、デーヴァナーガリー、タイ文字、漢字、絵文字を一度にすべて欠いており、それぞれの代替は異なるフォントです。したがってスクリプトごとに 1 つのチェーンを宣言することが、実際の配備を記述します。本文用の 1 つのラテンフォント、1 つの CJK フォント、1 つの絵文字フォント、1 つの総受けです

hfsCJK、hfsArabic、hfsEmoji、hfsOther の各スクリプトを順序付き代替フォントチェーンへ対応させる、HotPDF のスクリプト別フォントフォールバックの図。
各スクリプトは独自の順序付きチェーンを得ます。漢字、アラビア文字、絵文字を欠くラテン本文フォントは、それをカバーするフォントへ落ち着きます
// THPDFFontScript は hfsCommon, hfsLatin, hfsGreek, hfsCyrillic,
// hfsHebrew, hfsArabic, hfsIndic, hfsSoutheastAsian, hfsCJK, hfsKana,
// hfsHangul, hfsEmoji, hfsOther をカバーする
Pdf.SetFontFallbackChain(hfsCJK,
  ['Microsoft YaHei', 'SimSun', 'Yu Gothic']);
Pdf.SetFontFallbackChain(hfsArabic, ['Segoe UI', 'Arial']);
Pdf.SetFontFallbackChain(hfsEmoji, ['Segoe UI Emoji']);
Pdf.SetFontFallbackChain(hfsOther, ['Arial Unicode MS']);

フォールバックと検出は代替ではなく補完です。チェーンが予期したカバレッジを処理し、検出器が予期しなかったカバレッジを報告します。任意の顧客データを処理するシステムでは、後者が興味深い半分です。フォントの代替はメトリクスを変えるため、フォールバックした段落は再フローし得ることに注意してください。レイアウトが重要なら、代替フォントのクロージャとサブセット化の振る舞いをフォントサブセットクロージャの記事で読み込む価値があり、並べ替えや結合を必要とするスクリプトは複雑なスクリプトのテキストシェーピングで述べるシェーピング段階が処理します

既存の経路を危険にさらさずに振る舞いを後付けする方法

同じリリースでは、ペアスペーシングのためのレガシー kern テーブルフォールバックも追加されました。そのスコープの設け方は、まねる価値のあるパターンです。カーニングロジックへ新しい判断点を追加する代わりに、フォールバックは、GPOS テーブルを持たないフォント向けにすでに存在した早期脱出分岐に引っ掛けてあります。GPOS を持つモダンなフォントは決してそこへ届かないため、その振る舞いはテストによってではなく構造によって変わりません。Unicode フォントを登録しない経路は 2 つのゼロオフセットを生むため、こちらも変わりません

これが成熟したレンダリングライブラリにおける低リスクの後付けの一般的な形です。現在何も生み出していない分岐を見つけ、新しい振る舞いをそこへ置く。「何も退行させていないと信じる」が「退行させ得ない」に変わります。他人の請求書が通るテキストエンジンについて言えることとしては、はるかに良い言葉です

ログではなくゲートにする

カバレッジの発見は、何かがそれで失敗してこそ有用です。文書生成サービスで生産的な配置は、実際の顧客名、住所、製品説明のコーパスに対して夜間リグレッションジョブで追跡を有効に保ち、発見があればジョブを失敗させることです。イベントは出現ごとではなく一意なコードポイントごとに 1 回発火するため、スクリプト全体が欠けていても出力は読める大きさに保たれます

本番では同じハンドラはテレメトリとして使うほうがよいでしょう。コードポイントとフォントを記録し、文書の提供を続け、次に配備フォントセットへどのスクリプトを追加すべきかを集計に教えてもらいます。埋め込みフォントと代替フォントのレンダリング振る舞いについては埋め込みフォントグリフのレンダリングでさらに扱っており、TrackUnresolvedGlyphs を含む全プロパティリストは、HotPDF Delphi PDF component の製品ページに文書があります