What happens when text contains characters the font does not have, and why should you never hard-code system font names?
Predict first
Which font draws one character per script?
import UIKit
import CoreText
@MainActor func run() {
let base = UIFont.systemFont(ofSize: 17)
for (name, ch) in [("Latin", "A"), ("Thai", "ส"), ("Devanagari", "न"), ("Arabic", "م"), ("Hebrew", "ש"), ("Han", "世"), ("Hangul", "한"), ("Emoji", "😀")] {
let f = CTFontCreateForString(base as CTFont, ch as CFString, CFRange(location: 0, length: (ch as NSString).length))
print(name, CTFontCopyPostScriptName(f), CTFontGetSize(f))
}
}
MainActor.assumeIsolated { run() }
- Latin .SFUI-Regular 17.0 / Thai .ThonburiUI-Regular 17.0 / Devanagari .SFDevanagari-Regular 17.0 / Arabic .SFArabic-Regular 17.0 / Hebrew .SFHebrew-Regular 17.0 / Han .PingFangUITextSC-Regular 17.0 / Hangul .AppleSDGothicNeoI-Regular 17.0 / Emoji .AppleColorEmojiUI 17.0
- Every line .SFUI-Regular 17.0
- Missing glyphs print as empty
Mental model. A travelling band hires local musicians when its own instruments do not fit a song. Fallback fonts are those guests: the sound is right, but not always in tune with the rest of the band.
No font covers every script. When a character is missing, Core Text walks a cascade list of fallback fonts and draws that glyph with the first one that has it, which is why a Thai or Arabic string in a label that uses the system font still shows. The check asks Core Text which font actually draws one character per script with the 17-point system font: Latin uses the system font (.SFUI-Regular), Thai uses .ThonburiUI-Regular, Devanagari, Arabic and Hebrew use the matching SF script fonts, Han uses a PingFang font, Hangul an Apple SD Gothic font and an emoji the colour emoji font. Two practical consequences follow. A line can mix fonts with different metrics, so give text room (taller scripts such as Thai and Devanagari need generous line height and fixed-height labels clip), and let it wrap and scale with Dynamic Type (Q469) instead of fixing its height. And the names start with a dot: system font names are private, change between releases and cannot be created with UIFont(name:), so always use UIFont.systemFont, text styles or preferredFont.
A custom brand font usually covers only Latin, so the other scripts silently fall back and your typography is inconsistent; decide per language whether to ship a font that covers the script, or rely on the system font for those locales (and check weights, which fallback fonts may not have). Han characters are shared between Chinese, Japanese and Korean (“Han unification”), and the glyph shapes differ by language; the check shows a bare Han character resolving to a Simplified Chinese font, so make sure the language reaches the text system for CJK content (a localised string, the right locale on the text view) rather than relying on defaults. This was verified for the fonts above on the simulator; how a given custom font cascades, and the language tagging attributes, were not run.
Each script gets its own fallback font at the same size; the names are private.
Common traps
- Fixing a label height to the Latin line height. Scripts with tall glyphs and stacked marks clip.
- Calling
UIFont(name: ".SFUI-Regular"). System fonts are private; use the system font APIs.
They might follow up with
- How do I know my custom font lacks a script? Ask Core Text which font renders a sample, as the check does.
- Does emoji change line height? Not run.
Related questions
- 24.2.1 How does Dynamic Type work, how do you support it in UIKit and SwiftUI, and how do layouts adapt at…
- 24.4.1 How do you test internationalisation before translations exist: pseudolanguages, launch arguments, text…
- 1.9.2 Character vs Unicode.Scalar vs UTF-8 code unit?
← How do dynamic colours work, how do you give a colour dark and… Which layouts and images mirror for right-to-left languages… →