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:
commit
10f85ef8c7
2 changed files with 353 additions and 0 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue