fix: honor CJK-capable font-family before fallback injection (#2241)

This commit is contained in:
Austin Wang 2026-03-27 23:45:30 -07:00 committed by GitHub
parent 27fa3873be
commit 97fee253b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 173 additions and 10 deletions

View file

@ -4,6 +4,7 @@ import AppKit
import Metal
import QuartzCore
import Combine
import CoreText
import Darwin
import Sentry
import Bonsplit
@ -1365,15 +1366,12 @@ class GhosttyApp {
/// 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 without overriding
/// user-managed fallback chains.
/// user-managed fallback chains or configured fonts that already cover
/// the affected CJK ranges.
///
/// See: https://github.com/manaflow-ai/cmux/pull/1017
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
let userFontConfig = Self.userFontConfigSummary()
if userFontConfig.containsCodepointMap { return }
if userFontConfig.hasExplicitFontFamilyFallbackChain { return }
guard let mappings = Self.cjkFontMappings() else { return }
guard let mappings = Self.autoInjectedCJKFontMappings() else { return }
let lines = mappings.map { range, font in
"font-codepoint-map = \(range)=\(font)"
@ -1409,6 +1407,21 @@ class GhosttyApp {
"U+30A0-U+30FF", // Katakana
]
/// Representative scalars used to detect whether the configured primary
/// font already covers the ranges cmux would otherwise auto-map.
private static let cjkCoverageSampleCharactersByRange: [String: [UniChar]] = [
"U+3000-U+303F": [0x3001, 0x300C],
"U+4E00-U+9FFF": [0x4E00, 0x65E5, 0x6C34],
"U+F900-U+FAFF": [0xF900],
"U+FF00-U+FFEF": [0xFF10, 0xFF21],
"U+3400-U+4DBF": [0x3400],
"U+1100-U+11FF": [0x1100, 0x1161],
"U+3130-U+318F": [0x3131, 0x314F],
"U+3040-U+309F": [0x3042, 0x3093],
"U+30A0-U+30FF": [0x30A2, 0x30F3],
"U+AC00-U+D7AF": [0xAC00, 0xD55C],
]
private struct UserFontConfigSummary {
var containsCodepointMap = false
var effectiveFontFamilies: [String] = []
@ -1485,6 +1498,38 @@ class GhosttyApp {
return mappings.isEmpty ? nil : mappings
}
/// Returns only the CJK mappings cmux should auto-inject after respecting
/// explicit user overrides and the glyph coverage of the configured
/// primary font family.
static func autoInjectedCJKFontMappings(
preferredLanguages: [String] = Locale.preferredLanguages,
configPaths: [String] = loadedCJKScanPaths(),
rangeCoverageProbe: ((String, String) -> Bool)? = nil
) -> [(String, String)]? {
guard var mappings = cjkFontMappings(preferredLanguages: preferredLanguages) else { return nil }
let summary = userFontConfigSummary(configPaths: configPaths)
if summary.containsCodepointMap || summary.hasExplicitFontFamilyFallbackChain {
return nil
}
guard let configuredFontFamily = summary.effectiveFontFamilies.first else {
return mappings
}
if let rangeCoverageProbe {
mappings.removeAll { range, _ in
rangeCoverageProbe(configuredFontFamily, range)
}
} else if let configuredFont = configuredCTFont(named: configuredFontFamily) {
mappings.removeAll { range, _ in
fontContainsGlyphs(configuredFont, forRange: range)
}
}
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.
@ -1502,11 +1547,58 @@ class GhosttyApp {
static func shouldInjectCJKFontFallback(
preferredLanguages: [String] = Locale.preferredLanguages,
configPaths: [String] = loadedCJKScanPaths()
configPaths: [String] = loadedCJKScanPaths(),
rangeCoverageProbe: ((String, String) -> Bool)? = nil
) -> Bool {
guard cjkFontMappings(preferredLanguages: preferredLanguages) != nil else { return false }
let summary = userFontConfigSummary(configPaths: configPaths)
return !summary.containsCodepointMap && !summary.hasExplicitFontFamilyFallbackChain
autoInjectedCJKFontMappings(
preferredLanguages: preferredLanguages,
configPaths: configPaths,
rangeCoverageProbe: rangeCoverageProbe
) != nil
}
private static func configuredCTFont(
named name: String,
size: CGFloat = 12
) -> CTFont? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let font = CTFontCreateWithName(trimmed as CFString, size, nil)
let normalizedRequestedName = normalizedFontName(trimmed)
let resolvedNames = [
kCTFontFamilyNameKey,
kCTFontFullNameKey,
kCTFontPostScriptNameKey,
].compactMap { CTFontCopyName(font, $0) as String? }
guard resolvedNames.contains(where: { normalizedFontName($0) == normalizedRequestedName }) else {
return nil
}
return font
}
private static func fontContainsGlyphs(
_ font: CTFont,
forRange range: String
) -> Bool {
guard let characters = cjkCoverageSampleCharactersByRange[range] else {
return false
}
var glyphs = Array(repeating: CGGlyph(), count: characters.count)
let hasGlyphs = CTFontGetGlyphsForCharacters(font, characters, &glyphs, characters.count)
return hasGlyphs && !glyphs.contains(0)
}
private static func normalizedFontName(_ name: String) -> String {
name
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
.folding(options: [.diacriticInsensitive, .widthInsensitive], locale: Locale(identifier: "en_US_POSIX"))
.lowercased()
}
private static func userFontConfigSummary(

View file

@ -2138,6 +2138,54 @@ final class GhosttyMouseFocusTests: XCTestCase {
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
}
// MARK: autoInjectedCJKFontMappings
func testAutoInjectedCJKFontMappingsSkipsRangesCoveredByConfiguredPrimaryFont() throws {
let coveredRanges: Set<String> = [
"U+3000-U+303F",
"U+4E00-U+9FFF",
"U+F900-U+FAFF",
"U+FF00-U+FFEF",
"U+3400-U+4DBF",
]
try withTempConfig("font-family = Sarasa Mono K\n") { path in
XCTAssertNil(
GhosttyApp.autoInjectedCJKFontMappings(
preferredLanguages: ["zh-Hans-CN"],
configPaths: [path],
rangeCoverageProbe: { fontFamily, range in
XCTAssertEqual(fontFamily, "Sarasa Mono K")
return coveredRanges.contains(range)
}
)
)
}
}
func testAutoInjectedCJKFontMappingsKeepsOnlyUncoveredRanges() throws {
let coveredRanges: Set<String> = [
"U+3000-U+303F",
"U+4E00-U+9FFF",
"U+F900-U+FAFF",
"U+FF00-U+FFEF",
"U+3400-U+4DBF",
]
try withTempConfig("font-family = Example CJK Mono\n") { path in
let mappings = GhosttyApp.autoInjectedCJKFontMappings(
preferredLanguages: ["ja-JP"],
configPaths: [path],
rangeCoverageProbe: { _, range in
coveredRanges.contains(range)
}
)!
XCTAssertEqual(Set(mappings.map(\.0)), Set(["U+3040-U+309F", "U+30A0-U+30FF"]))
XCTAssertEqual(Set(mappings.map(\.1)), Set(["Hiragino Sans"]))
}
}
// MARK: userConfigContainsCJKCodepointMap
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
@ -2386,6 +2434,29 @@ final class GhosttyMouseFocusTests: XCTestCase {
}
}
func testShouldInjectCJKFontFallbackSkipsConfiguredFontThatAlreadyCoversMappedRanges() throws {
let coveredRanges: Set<String> = [
"U+3000-U+303F",
"U+4E00-U+9FFF",
"U+F900-U+FAFF",
"U+FF00-U+FFEF",
"U+3400-U+4DBF",
]
try withTempConfig("font-family = Sarasa Mono K\n") { path in
XCTAssertFalse(
GhosttyApp.shouldInjectCJKFontFallback(
preferredLanguages: ["zh-Hans-CN"],
configPaths: [path],
rangeCoverageProbe: { fontFamily, range in
XCTAssertEqual(fontFamily, "Sarasa Mono K")
return coveredRanges.contains(range)
}
)
)
}
}
func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws {
let appSupport = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")