Merge pull request #1017 from atani/fix/cjk-font-fallback

fix: add CJK font fallback to prevent decorative font rendering
This commit is contained in:
Lawrence Chen 2026-03-09 18:10:12 -07:00 committed by GitHub
commit 10f85ef8c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 353 additions and 0 deletions

View file

@ -1000,9 +1000,197 @@ class GhosttyApp {
loadReleaseAppSupportGhosttyConfigIfNeeded(config)
loadLegacyGhosttyConfigIfNeeded(config)
ghostty_config_load_recursive_files(config)
loadCJKFontFallbackIfNeeded(config)
ghostty_config_finalize(config)
}
/// When the user has not configured `font-codepoint-map` for CJK ranges,
/// Ghostty's `CTFontCollection` scoring may pick an inappropriate fallback
/// font for Hiragana, Katakana, and CJK symbols. The scoring prioritizes
/// monospace fonts, so decorative fonts with monospace attributes (e.g.
/// AB_appare from Adobe CC, or LingWai) can be selected depending on what
/// is installed. This injects a sensible default based on the system's
/// preferred languages.
///
/// See: https://github.com/manaflow-ai/cmux/pull/1017
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
if Self.userConfigContainsCJKCodepointMap() { return }
guard let mappings = Self.cjkFontMappings() else { return }
let lines = mappings.map { range, font in
"font-codepoint-map = \(range)=\(font)"
}.joined(separator: "\n")
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf")
do {
try lines.write(to: tmpURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpURL) }
tmpURL.path.withCString { path in
ghostty_config_load_file(config, path)
}
} catch {
#if DEBUG
Self.initLog("failed to write CJK font fallback config: \(error)")
#endif
}
}
/// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms).
private static let sharedCJKRanges = [
"U+3000-U+303F", // CJK Symbols and Punctuation
"U+4E00-U+9FFF", // CJK Unified Ideographs
"U+F900-U+FAFF", // CJK Compatibility Ideographs
"U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms
"U+3400-U+4DBF", // CJK Unified Ideographs Extension A
]
/// Unicode ranges specific to Japanese (kana).
private static let japaneseRanges = [
"U+3040-U+309F", // Hiragana
"U+30A0-U+30FF", // Katakana
]
/// Unicode ranges specific to Korean (Hangul).
private static let koreanRanges = [
"U+AC00-U+D7AF", // Hangul Syllables
"U+1100-U+11FF", // Hangul Jamo
]
/// Returns (range, font) pairs for CJK font fallback based on the system's
/// preferred languages, or nil if no CJK language is detected. Each language
/// only maps its own script ranges to avoid assigning glyphs to a font that
/// lacks coverage (e.g. Hangul to Hiragino Sans).
static func cjkFontMappings(
preferredLanguages: [String] = Locale.preferredLanguages
) -> [(String, String)]? {
var mappings: [(String, String)] = []
var coveredShared = false
for lang in preferredLanguages {
let lower = lang.lowercased()
let font: String
var langRanges: [String] = []
if lower.hasPrefix("ja") {
font = "Hiragino Sans"
langRanges = japaneseRanges
} else if lower.hasPrefix("ko") {
font = "Apple SD Gothic Neo"
langRanges = koreanRanges
} else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") {
font = "PingFang TC"
} else if lower.hasPrefix("zh") {
font = "PingFang SC"
} else {
continue
}
if !coveredShared {
for range in sharedCJKRanges {
mappings.append((range, font))
}
coveredShared = true
}
for range in langRanges {
mappings.append((range, font))
}
}
return mappings.isEmpty ? nil : mappings
}
/// Checks whether the user's Ghostty config files already contain
/// a `font-codepoint-map` entry covering CJK ranges. Also checks
/// application-support config paths that cmux may load at runtime.
static func userConfigContainsCJKCodepointMap(
configPaths: [String] = defaultCJKScanPaths()
) -> Bool {
var visited = Set<String>()
for rawPath in configPaths {
let path = NSString(string: rawPath).expandingTildeInPath
if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) {
return true
}
}
return false
}
/// Returns the default set of config paths to scan for existing
/// `font-codepoint-map` entries. Includes both the standard Ghostty
/// config locations and any app-support paths that cmux may load.
private static func defaultCJKScanPaths() -> [String] {
var paths = [
"~/.config/ghostty/config",
"~/.config/ghostty/config.ghostty",
"~/Library/Application Support/com.mitchellh.ghostty/config",
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
]
if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first {
let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier)
paths.append(releaseDir.appendingPathComponent("config").path)
paths.append(releaseDir.appendingPathComponent("config.ghostty").path)
if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier {
let currentDir = appSupport.appendingPathComponent(bundleId)
paths.append(currentDir.appendingPathComponent("config").path)
paths.append(currentDir.appendingPathComponent("config.ghostty").path)
}
}
return paths
}
/// Scans a single config file (and any files it includes) for
/// `font-codepoint-map` entries. Tracks visited paths to prevent
/// infinite recursion on cyclic includes.
private static func configFileContainsCodepointMap(
atPath path: String,
visited: inout Set<String>
) -> Bool {
let resolved = (path as NSString).standardizingPath
guard !visited.contains(resolved) else { return false }
visited.insert(resolved)
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return false
}
let parentDir = (resolved as NSString).deletingLastPathComponent
for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("font-codepoint-map") {
return true
}
if trimmed.hasPrefix("config-file") {
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
var includePath = parts[1]
.trimmingCharacters(in: .whitespaces)
// Ghostty supports optional includes with a trailing '?'
if includePath.hasSuffix("?") {
includePath.removeLast()
}
includePath = includePath
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
let expanded = NSString(string: includePath).expandingTildeInPath
let absolute = (expanded as NSString).isAbsolutePath
? expanded
: (parentDir as NSString).appendingPathComponent(expanded)
if configFileContainsCodepointMap(atPath: absolute, visited: &visited) {
return true
}
}
}
}
return false
}
static func shouldLoadLegacyGhosttyConfig(
newConfigFileSize: Int?,
legacyConfigFileSize: Int?

View file

@ -1293,4 +1293,169 @@ final class GhosttyMouseFocusTests: XCTestCase {
)
)
}
// MARK: - CJK Font Fallback
private func withTempConfig(
_ contents: String,
body: (String) -> Void
) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let file = dir.appendingPathComponent("config")
try contents.write(to: file, atomically: true, encoding: .utf8)
body(file.path)
}
// MARK: cjkFontMappings
func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
let fonts = Set(mappings.map(\.1))
let ranges = mappings.map(\.0)
XCTAssertTrue(fonts.contains("Hiragino Sans"))
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
}
func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])!
let fonts = Set(mappings.map(\.1))
let ranges = mappings.map(\.0)
XCTAssertTrue(fonts.contains("Apple SD Gothic Neo"))
XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables")
XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo")
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana")
}
func testCJKFontMappingsReturnsPingFangForChinese() {
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })
let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })
let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
}
func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
}
func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0)
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo")
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
}
// MARK: userConfigContainsCJKCodepointMap
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
XCTAssertFalse(
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
)
}
func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "font-family = Menlo\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = fonts.conf\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = \(included.path)?\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let fileA = dir.appendingPathComponent("a.conf")
let fileB = dir.appendingPathComponent("b.conf")
try "config-file = \(fileB.path)\n"
.write(to: fileA, atomically: true, encoding: .utf8)
try "config-file = \(fileA.path)\n"
.write(to: fileB, atomically: true, encoding: .utf8)
// Should not hang; should return false since neither file has font-codepoint-map
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
}
}