技術記事

Delphiでシステムフォントにより非埋め込みPDFフォントを描画

PDFがフォントを埋め込んでいないとき、HotPDFコンポーネントはそのテキストを、HPDFMapBaseFontToSystemが選んだインストール済みWindowsフォントで描画します。このマッパーは/BaseFont名をデコードし、スタイル部分を剥ぎ、GDIがファミリーのインストールを確認するまでいくつかの綴りを試し、欠けている標準14フォントの幅をメトリック互換フォントから測り、描画の前に1バイトコードをUnicodeへ変換します。これらのステップの1つ1つは、素朴な版が実在のファイルで失敗したから存在します。RenderLoadedPageToBitmapページレンダラーは埋め込みプログラムをよく扱います。ここは、ファイルの中にまったくないフォントの話です

GDIはなぜ非埋め込みフォントに黙って間違った書体を描くのか

GDIは欠落フォントを報告しません。知らない書体名をCreateFontIndirectへ渡すと、黙って代替品を選びます。しばしばボールドの重みのない別のセリフ体です。初期のレンダラーはPDF名をほぼそのまま渡していたので、TimesNewRoman,Bold、TimesNewRomanPS-BoldMT、SegoeUI-Semiboldはどれも何にもマッチせず、GDIが選んだもので出てきました。名前はもっとひどいこともあります。ISO 32000-1 §7.3.5は、名前が任意のバイトを#xxとして書くことを許し、CJKのプロデューサーはフォント名をエスケープしたUTF-8やレガシーコードページのバイトで綴るのが日常です。v2.766.69より前は、エスケープ自体が書体名になっていました。HPDFMapBaseFontToSystemは現在、エスケープをまずデコードし、有効なUTF-8バイト列を文字として返し、その他の高位バイトはシステムコードページで読みます

スタイルを切り離すところで、ヒューリスティックが歯を立てます。カンマは常にファミリーを終えます(Arial,BoldはArialに)。しかしハイフンは、後ろの単語がスタイルであるときだけ終えます。Bold、Italic、Oblique、Regular、Roman、Medium、Light、Black、Heavy、Semi、Demi、Thin、Extra、Ultra、Condensed。このルールはMS-Minchoをそのまま保ちつつ、Calibri-LightをCalibriへ変えます。HotPDFは次に、ファミリーの綴りを試し、続いてPSMT、MT、PSのサフィックスを取り除いたファミリーの綴りを試します。v2.768.18から、これらの綴りは単語が始まりうる場所のあらゆるスペースの選択を網羅します。小文字の後に来る大文字の前(MyriadProはMyriad Proへ)、小文字が後に続くランの最後の大文字(UIGothic)、先頭のMSの後(MSPGothic)。完全にスペース入りの形から、書かれたままの名前まで。そのような場所が4つを超えると、完全スペース入りと結合形だけが試されます。スペースは盲目的に適用できません。Windowsは一部の単語を結合したまま保つからです。SimSunはまさにその綴りでインストールされており、MicrosoftYaHei、MicrosoftJhengHei、MSPGothicはMicrosoft YaHei、Microsoft JhengHei、MS PGothicに属します。v2.768.18より前、マッパーはすべての内側の大文字の前にスペースを入れたため、MicrosoftYaHeiはMicrosoft Ya Heiとして検索され、決して見つかりませんでした。候補がインストール済みと数えられるのは、CreateFontIndirectに続くGetTextFaceが要求した名前を返すとき、そしてv2.768.18からは、選択されたフォントのnameテーブルがそれをファミリー、完全またはタイポグラフィックファミリー名として任意の言語で載せているときです。答えは名前ごとにキャッシュされるので、インストールされていないフォントを多く持つ文書が、ページごとにWindowsへ名前の数だけ問い合わせることはもうありません

Delphiで非埋め込みPDFフォントをシステムフォントで描画するHotPDFのパイプライン。HPDFMapBaseFontToSystemは/BaseFont名の#xxエスケープバイトをデコードし、MS-Minchoをそのまま保ちながらBold、Italic、Lightのスタイルサフィックスを剥ぎ、Myriad ProやMicrosoft YaHeiのような候補の綴りを作り、GetTextFaceかフォント名テーブルがインストール済みの名前を確認したときだけ受け入れます
GDIは欠落フォントを報告せず、黙って代替します。マッピングは候補を順に試し、GDIが返した名前か、選択されたフォントのnameテーブルが載せている名前だけを信じます

マッピング関数はHPDFRenderFontMetricsユニットで公開されているので、プリフライトレポートは、各非埋め込みフォントがどのインストール済みファミリーで描かれるかを示せます。THotPDFがロード済み文書のためにすでに公開しているフォント列挙を使って:

uses
  HPDFDoc, HPDFRenderFontMetrics;

procedure ListSystemFontMappings(Pdf: THotPDF; Log: TStrings);
var
  Page, I: Integer;
  Info: THPDFLoadedFontInfo;
begin
  for Page := 0 to Pdf.LoadedPageCount - 1 do
    for I := 0 to Pdf.GetLoadedFontCount(Page) - 1 do
      if Pdf.GetLoadedFontInfo(Page, I, Info) and not Info.IsEmbedded then
        Log.Add(Format('page %d  /%s  %s -> %s',
          [Page + 1, string(Info.ResourceName), string(Info.FontName),
           HPDFMapBaseFontToSystem(Info.FontName)]));
end;

/Widthsを持たない標準14フォントをHotPDFはどう測るか

HotPDFは欠けているアドバンスを、同じメトリックを持つインストール済みフォントで測ります。ISO 32000-1 §9.6.2.2は標準14フォントが/Widthsを省略することを許し、ライブラリはAFMテーブルを同梱しないからです。ArialはHelveticaのメトリックを、Times New RomanはTimesを、Courier NewはCourierを運ぶので、HPDFMeasureBaseFontWidthsはlfHeight = -1000で対応する書体を作り、GetCharWidth32Wを呼びます。その高さでは結果が、PDFの幅が使う1/1000 em単位ですでに得られます。レンダラーはまず、各コードを/Encoding、/BaseEncoding、/Differencesを通ってUnicodeへ変え、StandardEncodingへデフォルトします。SVGエクスポートとテキスト抽出にはもう1つの罠があります。/Encodingをまったく持たない標準Type 1フォントが、エンコーディング情報のないデコーダーを作り、SVGエクスポートは決して登録せず、測られた幅はすべて未使用でした。暗黙のStandardEncodingを与えることで直りました。ただし事前定義エンコーディングとしてマークされている場合に限ります。CMap名の経路へ流すと、すべてのコードが0としてデコードされ、すべての幅がそれに従います

ボールド、イタリック、そしてフォントディスクリプターフラグの1ビットずれ

フォントディスクリプターの/Flagsエントリーは、ビットを0でなく1から番号付けします。だからForceBoldはビット19($40000)、Italicはビット7($40)です。ISO 32000-1 Table 123のとおり。旧コードは$20000、つまりビット18のSmallCapをテストしていました。間違いはv2.345.0からv2.766.53まで生き延びました。/FontDescriptorはほとんど常に間接参照で、フォントビルダーは直接オブジェクトしか読まなかったからです。フラグ分岐全体が走らず、同じ盲さは/Widths 12 0 Rも無視し、テキストを500ユニットのフォールバックアドバンスで組んでいました。v2.766.53がレンダラー経由で間接参照を解決し始めたとき、ビットは同じ変更で直されなければなりませんでした。さもないと、すべてのスモールキャップス書体が突然ボールドで描かれるところでした:

const
  // ISO 32000-1 Table 123はビット位置を1から数える
  FD_ITALIC     = $00040;  // ビット7
  FD_SMALLCAP   = $20000;  // ビット18、ウェイトではない
  FD_FORCEBOLD  = $40000;  // ビット19

procedure ApplyDescriptorFlags(Flags: Integer; var LF: TLogFont);
begin
  if (Flags and FD_FORCEBOLD) <> 0 then
    LF.lfWeight := FW_BOLD;
  if (Flags and FD_ITALIC) <> 0 then
    LF.lfItalic := 1;
end;
PDFフォントディスクリプター/Flagsエントリーのビット番号付け。ISO 32000-1 Table 123はビット1から数えるため、Italicはビット7の$40、SmallCapはビット18の$20000、ForceBoldはビット19の$40000です。HotPDFの$20000テストはSmallCapを狙っており、間接の/FontDescriptor参照が解決されない間だけ無害でした
フラグ分岐は40バージョンのあいだデッドコードでした。ディスクリプターが間接参照だったからです。参照が解決された途端、1ビットのずれが小さなキャップスのテキストとして顔を出しました

UCS2 CMapを持つCJKフォントはなぜ間違った幅を得るのか

事前定義UCS2 CMapを持つCJKテキストは、レンダラーがコードをCIDとして扱うと、正しいグリフを間違ったスペーシングで描きます。/Wは文字コードでなくCIDでインデックスされるからです。STSong-LightとUniGB-UCS2-Hでは、コードがたまたまUnicode値と等しいので、GDIは正しい文字を描き、バグはアドバンスに隠れます。小文字はコード97以上として届き、[1 95 500]のような/Wエントリーの外に落ち、すべてがデフォルト幅/DWの1000を受け取ります。v2.766.56から、HotPDFレンダラーはコードをCMapのcodespaceレンジ(ISO 32000-1 §9.7.6.2)を通って読み、幅を検索する前にCIDへマップします。使うのは内蔵のUCS2とUTF16テーブル、そして埋め込みCMapストリームだけです。GBK-EUC-Hのようなものへのidentity近似は、対応しているように見えるだけで間違った出力を出すので、レンダラーは見せかけをしません

UCS2 CMapを持つCJKテキストが正しいグリフを間違ったアドバンスで描く理由。/WはCIDでインデックスされ、コードはUnicode値です。だからSTSong-LightとUniGB-UCS2-Hでは、コード97以上の小文字が/Wエントリー[1 95 500]を外れて/DWデフォルトを取ります。HotPDFでは、コードをCMap codespaceレンジ経由でCIDへマップすることで直りました
このバグが隠れるのは、ここではコードがUnicodeと等しいからです。グリフは正しく見え、すべてのアドバンスは黙ってデフォルトに落ちます。CJK描画を判断するときは、形でなくスペーシングを見てください

中国語Windowsでアクセント付き文字が疑問符になる理由

1バイトコードは、ANSI(「A」)のGDI関数へ決して届いてはなりません。GetGlyphOutlineAとGetGlyphIndicesAはバイトをシステムコードページで解釈し、TextOutAは選択されたフォントの文字セットを使うからです。中国語のシステムでは、Arialのバイト$A9(Windows-1252の著作権記号)がGBKのリードバイトになり、「?」として描かれました。v2.766.83で追加されたアンヒントのアウトライン経路が、まっすぐ踏み込んだ罠です。v2.767.3は、実体化されたフォントにGetTextCharsetで文字セットを問い、TranslateCharsetInfoでコードページへ変え、バイトをMultiByteToWideCharに通し、W関数を呼びます。シンボルフォントは代わりにU+F000プラスコードを使います。Windows-1252と食い違うエンコーディング、つまり/Differences、StandardEncoding、MacRomanEncodingは、システムフォントが見る前にUnicodeへマップされます

システムフォントでの描画の限界

システムフォント描画は近似であり、HotPDFコンポーネントはどこで止まるかに正直です。v2.768.18より前、インストール検査はGetTextFaceが返す名前だけを比べていました。ローカライズされたWindowsでは、この関数はファミリー名をシステム言語で報告します。だから中国語WindowsのMicrosoft YaHeiや日本語WindowsのYu Minchoは欠落と判定され、GDIの代替で描かれました。v2.768.18から、別の名前で戻ってきた書体もフォントのnameテーブルで検索され、そのようなフォントは見つかります。メトリック互換が保証されるのはHelvetica、Times、Courierのファミリーだけです。SymbolはSymbolへ、ZapfDingbatsはWingdingsへマップされます。これはマッチでなくその場しのぎです。上のプリフライトが見るのも、各ページの/Resources辞書の中のフォントだけで、フォームXObjectの内側から参照されるものではありません。コードがどうしても描けないとき、描画時の未解決グリフ追跡がそれを報告します。サムネイルを睨むより良いシグナルです

恒久的な修正は、作成側にあります。HotPDF自身はFontEmbeddingをデフォルトでTrueにして書き出します。コードがHelveticaでSetFontを呼んでも、埋め込みArialで代替します。埋め込みテキストは、上の推測のどれでもなく、埋め込みフォントグリフレンダラーを通ります。入ってくるファイルのための手軽なガードは、マップされたファミリーがスクリーンフォントリストにないとき、描画の前に警告することです:

// VCL:Screen.Fontsがインストール済みファミリー名を列挙する(Formsユニット)
function MissingSystemFamily(const BaseFont: AnsiString): Boolean;
begin
  Result := Screen.Fonts.IndexOf(HPDFMapBaseFontToSystem(BaseFont)) < 0;
end;

ページレンダリング、テキスト抽出、書き出し側のフォントサブセット化を含むコンポーネントの全体は、HotPDF Delphi PDF componentの製品ページへ