# 許可フォント以外の文字を全角疑問符へ置換する実装ガイド
# 目的
PDF に埋め込まれるフォントを次の 3 種類だけに限定する。
NotoSansJP-RegularNotoSansJP-BoldNotoEmoji-Regular
入力文字を許可フォントだけで描画できない場合、システムフォントへフォールバックさせず、全角疑問符 ?(U+FF1F)へ置換する。
例えば次のように変換する。
入力: ABC𠀀🙂😶🌫️
出力: ABC?🙂?
実際の変換結果は、使用する TTF に収録されているグリフによって決まる。
# なぜ事前検査が必要か
UIFont(name:size:) でフォントを指定しただけでは、PDF に別フォントが混入しないとは限らない。
指定したフォントに対象文字のグリフが存在しない場合、iOS の描画処理はシステムフォントへ自動的にフォールバックすることがある。そのまま描画すると、見た目は表示できていても PDF 内では許可していないフォントが使用される。
対策として、描画前に Core Text で文字をシェーピングし、実際に生成された CTRun を検査する。
# 基本方針
処理は 2 段階に分ける。
- 各
Characterを描画前に検査し、許可フォントだけで描画できない文字を?へ置換する。 - 置換後の文字列全体から
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 == 0glyphsに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 出力を中止する。
# 文字列全体の最終検査
各文字を検査しても、複数文字の組み合わせや 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 を生成する。
# 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 描画 | 検査済みの CTFrame を CGContext へ描画 |
# 動作確認用の入力例
# 未収録漢字
𠀀
Unicode: U+20000
期待結果: ?
# アラビア文字
العربية
Noto Sans JP に収録されていない文字は、それぞれ ? に置換される。
# 収録済み絵文字
🙂
Noto Emoji に収録され、Core Text が許可フォントだけで描画できる場合は維持される。
# ZWJ 絵文字
😶🌫️
構成要素が個別に存在しても、組み合わせ全体を Noto Emoji だけで描画できない場合は ? に置換される。
# 注意事項
# TTF の登録が必要
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>
# 置換とエラーを分ける
- 入力文字が未収録:
?に置換する - 必須 TTF のロードに失敗: PDF 出力を中止する
?自体を描画できない: PDF 出力を中止する
# PDF 内フォントの確認
PDF ビューアの表示だけでは、どのフォントが埋め込まれたか判断できない。必要に応じて PDF のフォント情報を確認する。