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

PDFに埋め込むフォントをNoto 3種類に限定したいが、UIFont を指定するだけでは未収録文字でシステムフォントへフォールバックする。 描画前にCore Textで CTRun のフォント名とグリフを検査し、描画できない文字を全角疑問符 ? に置換する。

# PDFに埋め込むフォントをNoto 3種類に限定する

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

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

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

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

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

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

# UIFont を指定するだけではフォールバックを防げない

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

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

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

# 文字単位の検査と描画直前の再検査の2段階で確認する

処理は2段階に分ける。

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

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

必要なimportは次のとおりである。

import UIKit
import CoreText

# 許可フォントをロードしてPostScript名を保持する

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名だからである。

# 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を候補にする

通常文字には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の CTRun でフォント名と欠落グリフを検査する

# CTLine を作る

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

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

let line = CTLineCreateWithAttributedString(attributed)

# CTRun を列挙する

let runs = CTLineGetGlyphRuns(line) as NSArray

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

# 各runの実フォントが許可フォントかを確かめる

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

postScriptName が許可フォントの集合になければ、システムフォントへのフォールバックが起きている。 この場合は描画不可とする。

# グリフID 0 があれば欠落とみなす

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

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

  • glyphCount == 0
  • glyphs に 0 が含まれる

グリフ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出力を中止する。

# 置換後の文字列全体を CTFrame で再検査する

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

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

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を生成し直す。

# 検査済みの CTFrame をそのまま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の座標系から上下を反転する。

# 処理を5つの責務に分けて実装する

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

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

# 未収録漢字・アラビア文字・絵文字で置換結果を確かめる

次の入力で、期待どおりに置換されるかを確認する。

# 未収録漢字は ? になる

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

# アラビア文字は1文字ずつ ? になる

العربية

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

# 収録済みの絵文字はそのまま残る

🙂

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

# ZWJ絵文字は組み合わせ全体で判定される

😶‍🌫️

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

# TTFはアプリに同梱して UIAppFonts に登録する

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

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

# 置換する場合とPDF出力を中止する場合を分ける

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

# 埋め込まれたフォントはPDFのフォント情報で確認する

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