# 許可フォント以外の文字を全角疑問符へ置換する実装ガイド
PDFに埋め込むフォントをNoto 3種類に限定したいが、UIFont を指定するだけでは未収録文字でシステムフォントへフォールバックする。
描画前にCore Textで CTRun のフォント名とグリフを検査し、描画できない文字を全角疑問符 ? に置換する。
# PDFに埋め込むフォントをNoto 3種類に限定する
PDFに埋め込まれるフォントを、次の3種類だけに限定する。
NotoSansJP-RegularNotoSansJP-BoldNotoEmoji-Regular
入力文字を許可フォントだけで描画できない場合は、システムフォントへフォールバックさせない。
代わりに全角疑問符 ?(U+FF1F)へ置換する。
例えば次のように変換する。
入力: ABC𠀀🙂😶🌫️
出力: ABC?🙂?
実際の変換結果は、使用するTTFに収録されているグリフによって決まる。
# UIFont を指定するだけではフォールバックを防げない
UIFont(name:size:) でフォントを指定しただけでは、PDFに別フォントが混入しないとは限らない。
指定したフォントに対象文字のグリフがない場合、iOSの描画処理はシステムフォントへ自動でフォールバックすることがある。 そのまま描画すると、見た目は表示できていても、PDF内では許可していないフォントが使われる。
そこで、描画前にCore Textで文字をシェーピングし、実際に生成された CTRun を検査する。
# 文字単位の検査と描画直前の再検査の2段階で確認する
処理は2段階に分ける。
- 各
Characterを描画前に検査し、許可フォントだけで描画できない文字を?へ置換する。 - 置換後の文字列全体から
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 == 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出力を中止する。
# 置換後の文字列全体を 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のフォント情報を確認する。