# 許可フォント以外の文字を全角疑問符へ置換する実装ガイド

# 目的

PDF に埋め込まれるフォントを次の 3 種類だけに限定する。

  • NotoSansJP-Regular
  • NotoSansJP-Bold
  • NotoEmoji-Regular

入力文字を許可フォントだけで描画できない場合、システムフォントへフォールバックさせず、全角疑問符 U+FF1F)へ置換する。

例えば次のように変換する。

入力: ABC𠀀🙂😶‍🌫️
出力: ABC?🙂?

実際の変換結果は、使用する TTF に収録されているグリフによって決まる。


# なぜ事前検査が必要か

UIFont(name:size:) でフォントを指定しただけでは、PDF に別フォントが混入しないとは限らない。

指定したフォントに対象文字のグリフが存在しない場合、iOS の描画処理はシステムフォントへ自動的にフォールバックすることがある。そのまま描画すると、見た目は表示できていても PDF 内では許可していないフォントが使用される。

対策として、描画前に Core Text で文字をシェーピングし、実際に生成された CTRun を検査する。


# 基本方針

処理は 2 段階に分ける。

  1. Character を描画前に検査し、許可フォントだけで描画できない文字を へ置換する。
  2. 置換後の文字列全体から CTFrame を作成し、描画直前にもう一度 CTRun を検査する。

2 段階目は防御的な確認である。複数文字を組み合わせた結果としてフォールバックが発生するケースを検出する。


# 必要な import

import UIKit
import CoreText

# 許可フォントのロード

PDF 生成開始時に 3 フォントをロードする。

guard let regular = UIFont(name: "NotoSansJP-Regular", size: 12),
      let bold = UIFont(name: "NotoSansJP-Bold", size: 12),
      let emoji = UIFont(name: "NotoEmoji-Regular", size: 12) else {
    // PDF 出力を中止する
    throw FontError.requiredFontUnavailable
}

フォントのロード失敗時に UIFont.systemFont を使用してはいけない。システムフォントへの切り替えを許可すると、要件を満たせなくなる。

ロード後、実際の PostScript 名を集合として保持する。

let allowedPostScriptNames: Set<String> = [
    regular.fontName,
    bold.fontName,
    emoji.fontName
]

ファイル名ではなく fontName を使用する。Core Text の run から取得できるのは PostScript 名だからである。


# 文字単位の検査

# Unicode Scalar 単位ではなく Character 単位で処理する

Swift の Character 単位で文字列を走査する。

for character in inputText {
    let candidate = String(character)
    // candidate を検査する
}

絵文字には複数の Unicode Scalar から構成されるものがある。

😶‍🌫️ = 😶 + ZWJ + 🌫️ + Variation Selector

Scalar 単位で分割すると、組み合わせ全体を正しく判定できない。Character 単位なら、利用者が 1 文字として扱うまとまりを維持できる。


# 通常文字と絵文字で候補フォントを分ける

通常文字には Noto Sans JP、絵文字には Noto Emoji を候補として使用する。

絵文字判定では isEmoji だけを使用しない。数字や # なども絵文字関連文字として判定されるためである。

表示用絵文字の判定例:

extension String {
    var containsDisplayEmoji: Bool {
        unicodeScalars.contains { scalar in
            scalar.properties.isEmojiPresentation ||
            scalar.value == 0x200D ||
            scalar.value == 0xFE0F
        }
    }
}

候補フォントの選択:

let candidateFont = candidate.containsDisplayEmoji
    ? emojiFont
    : baseNotoSansJPFont

baseNotoSansJPFont は、描画箇所に応じて Regular または Bold とする。


# Core Text によるグリフ検査

# 1. CTLine を作る

検査したい文字へ候補フォントを明示して NSAttributedString を作る。

let attributed = NSAttributedString(
    string: candidate,
    attributes: [.font: candidateFont]
)

let line = CTLineCreateWithAttributedString(attributed)

# 2. CTRun を列挙する

let runs = CTLineGetGlyphRuns(line) as NSArray

run が 0 件の場合は描画不可とする。

# 3. 各 run の実フォントを検査する

let attributes = CTRunGetAttributes(run) as NSDictionary
let ctFont = attributes[kCTFontAttributeName] as! CTFont
let postScriptName = CTFontCopyPostScriptName(ctFont) as String

postScriptName が許可フォント集合に存在しなければ、システムフォントへのフォールバックが発生している。描画不可とする。

# 4. 欠落グリフを検査する

let glyphCount = CTRunGetGlyphCount(run)
var glyphs = Array(repeating: CGGlyph(), count: glyphCount)
CTRunGetGlyphs(run, CFRange(location: 0, length: 0), &glyphs)

次のいずれかに該当すれば描画不可とする。

  • glyphCount == 0
  • glyphs0 が含まれる

グリフ ID 0.notdef、つまり欠落グリフとして扱う。


# 描画不可文字の置換

検査に失敗した文字は、全角疑問符へ置換する。

let replacementCharacter = "?"

置換後の には Noto Sans JP の Regular または Bold を明示する。

if canRender(candidate, with: candidateFont) {
    append(candidate, font: candidateFont)
} else {
    append("?", font: baseNotoSansJPFont)
}

初期化時に 自体を Noto Sans JP で描画できることも確認する。描画できない場合、代替文字として成立しないため PDF 出力を中止する。


# 文字列全体の最終検査

各文字を検査しても、複数文字の組み合わせや Core Text のレイアウト処理によって、最終描画時に別フォントが選択される可能性がある。

そのため、最終的な NSAttributedString から CTFrame を作り、frame 内のすべての CTLineCTRun を再検査する。

let framesetter = CTFramesetterCreateWithAttributedString(attributedText)
let path = CGPath(rect: drawRect, transform: nil)
let frame = CTFramesetterCreateFrame(
    framesetter,
    CFRange(location: 0, length: attributedText.length),
    path,
    nil
)

列挙順序:

CTFrame
  -> CTLine
    -> CTRun
      -> 使用フォントの PostScript 名
      -> グリフ ID

許可外フォントまたは欠落グリフが残っていた場合は、安全側に倒して対象文字列全体を の繰り返しへ置換し、再度 frame を生成する。


# Core Text で PDF へ描画する

UIKit の文字列描画 API に戻すと、再度フォールバックが発生する可能性がある。検査済みの CTFrame をそのまま描画する。

cgContext.saveGState()
cgContext.clip(to: rect)
cgContext.translateBy(x: rect.minX, y: rect.maxY)
cgContext.scaleBy(x: 1, y: -1)
cgContext.textMatrix = .identity
CTFrameDraw(frame, cgContext)
cgContext.restoreGState()

CTFrameDraw は Core Text 座標系で描画するため、UIKit 座標系から上下反転する。


# 実装時の責務分割

次のように分けると実装しやすい。

責務 内容
許可フォント管理 3 フォントのロード、サイズ変更、PostScript 名集合の保持
文字検査 CTLine を生成し、run のフォント名とグリフを検査
文字列変換 Character 単位で検査し、失敗文字を に置換
最終検査 CTFrame 全体を run 単位で再検査
PDF 描画 検査済みの CTFrameCGContext へ描画

# 動作確認用の入力例

# 未収録漢字

𠀀
Unicode: U+20000
期待結果: ?

# アラビア文字

العربية

Noto Sans JP に収録されていない文字は、それぞれ に置換される。

# 収録済み絵文字

🙂

Noto Emoji に収録され、Core Text が許可フォントだけで描画できる場合は維持される。

# ZWJ 絵文字

😶‍🌫️

構成要素が個別に存在しても、組み合わせ全体を Noto Emoji だけで描画できない場合は に置換される。


# 注意事項

# TTF の登録が必要

3 個の TTF をアプリ bundle に含め、Info.plistUIAppFonts へ登録する。

<key>UIAppFonts</key>
<array>
    <string>NotoSansJP-Regular.ttf</string>
    <string>NotoSansJP-Bold.ttf</string>
    <string>NotoEmoji-Regular.ttf</string>
</array>

# 置換とエラーを分ける

  • 入力文字が未収録: に置換する
  • 必須 TTF のロードに失敗: PDF 出力を中止する
  • 自体を描画できない: PDF 出力を中止する

# PDF 内フォントの確認

PDF ビューアの表示だけでは、どのフォントが埋め込まれたか判断できない。必要に応じて PDF のフォント情報を確認する。