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(