From fd65a44c1e9baa570eaf4390298f3408b301f07e Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 19 Sep 2026 11:55:08 +0530 Subject: [PATCH] Fix #10496 --- docs/changelog.rst | 3 + kitty/fast_data_types.pyi | 5 + kitty/fontconfig.c | 330 +++++++++++++++++++++++++++++++------- kitty/fonts.c | 2 +- kitty/fonts/fontconfig.py | 2 + kitty/freetype.c | 13 ++ kitty_tests/fonts.py | 81 ++++++++++ 7 files changed, 374 insertions(+), 62 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 24ff090c0..bf9cc292e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -256,6 +256,9 @@ Detailed list of changes - Splits layout: Fix dragging a border resizing the wrong split in nested layouts. +- Linux: Fix drawing a screen containing many distinct codepoints that are not + present in the main font causing a noticeable stall (:iss:`10496`) + - Graphics protocol: Fix scaled images (created with the ``r`` or ``c`` keys) being distorted instead of clipped when scrolled against a margin (:iss:`10377`) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 6c505eb57..4d5340d65 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -446,6 +446,10 @@ def fc_match_postscript_name(postscript_name: str) -> FontConfigPattern: pass def add_font_file(path: str) -> bool: ... +def clear_fallback_font_cache() -> None: ... +def fc_match_fallback( + text: str, bold: bool = False, italic: bool = False, prefer_color: bool = False, use_candidate_cache: bool = True +) -> FontConfigPattern: ... def set_builtin_nerd_font(path: str) -> Union[CoreTextFont, FontConfigPattern]: ... class FeatureData(TypedDict): @@ -460,6 +464,7 @@ class Face: def get_variable_data(self) -> VariableData: ... def identify_for_debug(self) -> str: ... def postscript_name(self) -> str: ... + def has_codepoint(self, cp: int) -> bool: ... def set_size(self, sz_in_pts: float, dpi_x: float, dpi_y: float) -> None: ... def render_sample_text(self, text: str, width: int, height: int, fg_color: int = 0xFFFFFF) -> tuple[bytes, int, int]: ... def render_codepoint(self, cp: int, fg_color: int = 0xFFFFFF) -> tuple[bytes, int, int]: ... diff --git a/kitty/fontconfig.c b/kitty/fontconfig.c index ea241125a..40f89b397 100644 --- a/kitty/fontconfig.c +++ b/kitty/fontconfig.c @@ -46,6 +46,11 @@ static struct { #define FcPatternGetMatrix dynamically_loaded_fc_symbol.PatternGetMatrix #define FcPatternAddCharSet dynamically_loaded_fc_symbol.PatternAddCharSet #define FcConfigAppFontAddFile dynamically_loaded_fc_symbol.ConfigAppFontAddFile +#define FcFontSort dynamically_loaded_fc_symbol.FontSort +#define FcFontSetMatch dynamically_loaded_fc_symbol.FontSetMatch +#define FcPatternGetCharSet dynamically_loaded_fc_symbol.PatternGetCharSet +#define FcCharSetHasChar dynamically_loaded_fc_symbol.CharSetHasChar +#define FcConfigGetCurrent dynamically_loaded_fc_symbol.ConfigGetCurrent static struct { FcBool (*Init)(void); @@ -72,6 +77,11 @@ static struct { FcResult (*PatternGetMatrix)(const FcPattern *p, const char *object, int n, FcMatrix **m); FcBool (*PatternAddCharSet)(FcPattern *p, const char *object, const FcCharSet *c); FcBool (*ConfigAppFontAddFile)(FcConfig *config, const FcChar8 *file); + FcFontSet *(*FontSort)(FcConfig *config, FcPattern *p, FcBool trim, FcCharSet **csp, FcResult *result); + FcPattern *(*FontSetMatch)(FcConfig *config, FcFontSet **sets, int nsets, FcPattern *p, FcResult *result); + FcResult (*PatternGetCharSet)(const FcPattern *p, const char *object, int n, FcCharSet **c); + FcBool (*CharSetHasChar)(const FcCharSet *fcs, FcChar32 ucs4); + FcConfig *(*ConfigGetCurrent)(void); } dynamically_loaded_fc_symbol = {0}; #define LOAD_FUNC(name) \ { \ @@ -124,6 +134,11 @@ load_fontconfig_lib(void) { LOAD_FUNC(PatternGetMatrix); LOAD_FUNC(PatternAddCharSet); LOAD_FUNC(ConfigAppFontAddFile); + LOAD_FUNC(FontSort); + LOAD_FUNC(FontSetMatch); + LOAD_FUNC(PatternGetCharSet); + LOAD_FUNC(CharSetHasChar); + LOAD_FUNC(ConfigGetCurrent); } #undef LOAD_FUNC @@ -136,11 +151,14 @@ ensure_initialized(void) { } } +static void clear_fallback_candidates(void); + static void finalize(void) { if (initialized) { Py_CLEAR(builtin_nerd_font.face); Py_CLEAR(builtin_nerd_font.descriptor); + clear_fallback_candidates(); FcFini(); dlclose(libfontconfig_handle); libfontconfig_handle = NULL; @@ -353,45 +371,204 @@ end: static char_type char_buf[1024]; -static void -add_charset(FcPattern *pat, size_t num) { - FcCharSet *charset = NULL; - if (num) { - charset = FcCharSetCreate(); - if (charset == NULL) { - PyErr_NoMemory(); - goto end; - } - for (size_t i = 0; i < num; i++) { - if (!FcCharSetAddChar(charset, char_buf[i])) { - PyErr_SetString(PyExc_RuntimeError, "Failed to add character to fontconfig charset"); - goto end; - } - } - AP(FcPatternAddCharSet, FC_CHARSET, charset, "charset"); - } -end: - if (charset != NULL) FcCharSetDestroy(charset); -} - static bool -_native_fc_match(FcPattern *pat, FontConfigFace *ans) { +add_charset(FcPattern *pat, const char_type *chars, size_t num) { bool ok = false; - FcPattern *match = NULL; - FcResult result; - FcConfigSubstitute(NULL, pat, FcMatchPattern); - FcDefaultSubstitute(pat); - /* printf("fc_match = %s\n", FcNameUnparse(pat)); */ - match = FcFontMatch(NULL, pat, &result); - if (match == NULL) { - PyErr_SetString(PyExc_KeyError, "FcFontMatch() failed"); + FcCharSet *charset = NULL; + if (!num) return true; + charset = FcCharSetCreate(); + if (charset == NULL) { + PyErr_NoMemory(); goto end; } + for (size_t i = 0; i < num; i++) { + if (!FcCharSetAddChar(charset, chars[i])) { + PyErr_SetString(PyExc_RuntimeError, "Failed to add character to fontconfig charset"); + goto end; + } + } + AP(FcPatternAddCharSet, FC_CHARSET, charset, "charset"); + ok = true; +end: + if (charset != NULL) FcCharSetDestroy(charset); + return ok; +} + +// Fallback font matching {{{ +// Asking fontconfig for the font to use for a codepoint the main font does not +// cover means calling FcFontMatch(), which scores the entire system font +// database. Cells that need a fallback font are memoized by fallback_font() in +// fonts.c, but a screen containing many *distinct* uncovered codepoints (for +// instance Nerd Font Private Use Area icons with no Nerd Font installed) still +// needs one full database scan per distinct cell, which stalls the main thread +// for tens to hundreds of milliseconds. See +// https://github.com/kovidgoyal/kitty/issues/10496 +// +// Instead, cache the list of candidate fonts, sorted by closeness to the +// requested family and style, once per (family, style, color) combination. A +// lookup then walks that in-memory list using the candidates' charsets, narrows +// the field down to a handful of fonts and has fontconfig score only those. + +typedef struct FallbackCandidates { + char *family; // NULL means no family was requested + bool bold, italic, prefer_color; + FcFontSet *fonts; // all candidate fonts, closest match first + FcCharSet *coverage; // union of the charsets of all candidates +} FallbackCandidates; + +static struct { + FallbackCandidates *entries; + size_t count, capacity; + FcPattern **scratch; // scratch space used to build the narrowed down font set + size_t scratch_capacity; + FcConfig *config; // the fontconfig configuration entries were built against +} fallback_candidates = {0}; + +static void +clear_fallback_candidates(void) { + for (size_t i = 0; i < fallback_candidates.count; i++) { + FallbackCandidates *q = fallback_candidates.entries + i; + free(q->family); + if (q->fonts) FcFontSetDestroy(q->fonts); + if (q->coverage) FcCharSetDestroy(q->coverage); + } + free(fallback_candidates.entries); + free(fallback_candidates.scratch); + memset(&fallback_candidates, 0, sizeof(fallback_candidates)); +} + +static FcPattern * +create_fallback_pattern(const char *family, bool bold, bool italic, bool prefer_color) { + FcPattern *pat = FcPatternCreate(); + if (pat == NULL) { + PyErr_NoMemory(); + return NULL; + } + if (family) AP(FcPatternAddString, FC_FAMILY, (const FcChar8 *)family, "family"); + if (bold) { AP(FcPatternAddInteger, FC_WEIGHT, FC_WEIGHT_BOLD, "weight"); } + if (italic) { AP(FcPatternAddInteger, FC_SLANT, FC_SLANT_ITALIC, "slant"); } + if (prefer_color) { AP(FcPatternAddBool, FC_COLOR, true, "color"); } + return pat; +end: + FcPatternDestroy(pat); + return NULL; +} + +static FallbackCandidates * +candidates_for(const char *family, bool bold, bool italic, bool prefer_color) { + FcConfig *config = FcConfigGetCurrent(); + if (config != fallback_candidates.config) clear_fallback_candidates(); + for (size_t i = 0; i < fallback_candidates.count; i++) { + FallbackCandidates *q = fallback_candidates.entries + i; + if (q->bold == bold && q->italic == italic && q->prefer_color == prefer_color && + (q->family == NULL ? family == NULL : (family != NULL && strcmp(q->family, family) == 0))) + return q; + } + FcPattern *pat = create_fallback_pattern(family, bold, italic, prefer_color); + if (!pat) { + PyErr_Clear(); // fall back to FcFontMatch() + return NULL; + } + FcConfigSubstitute(NULL, pat, FcMatchPattern); + FcDefaultSubstitute(pat); + // trim must be false so that the candidate list contains every font + // FcFontMatch() would consider, in the order in which it prefers them + FcCharSet *coverage = NULL; + FcResult result; + FcFontSet *fonts = FcFontSort(NULL, pat, FcFalse, &coverage, &result); + FcPatternDestroy(pat); + if (fonts && fonts->nfont < 1) { + FcFontSetDestroy(fonts); + fonts = NULL; + } + if (!fonts && coverage) { + FcCharSetDestroy(coverage); + coverage = NULL; + } + ensure_space_for(&fallback_candidates, entries, FallbackCandidates, fallback_candidates.count + 1, capacity, 8, true); + if (fonts) ensure_space_for(&fallback_candidates, scratch, FcPattern *, (size_t)fonts->nfont, scratch_capacity, 64, false); + FallbackCandidates *ans = fallback_candidates.entries + fallback_candidates.count++; + *ans = (FallbackCandidates){.bold = bold, .italic = italic, .prefer_color = prefer_color, .fonts = fonts, .coverage = coverage}; + if (family) { + ans->family = strdup(family); + if (!ans->family) fatal("Out of memory allocating fallback font candidate key"); + } + fallback_candidates.config = config; + // fonts is NULL if FcFontSort() failed, cached so that it is not retried + // for every lookup, match_fallback_font() then uses FcFontMatch() + return ans; +} + +static FcPattern * +match_in_candidates(const FallbackCandidates *q, FcPattern *pat, const char_type *chars, size_t num_chars, FcResult *result) { + // Chars that no font at all covers cannot change which font fontconfig + // prefers, since every candidate is missing them equally. + char_type relevant[arraysz(char_buf)]; + size_t num_relevant = 0; + for (size_t i = 0; i < num_chars; i++) { + if (!q->coverage || FcCharSetHasChar(q->coverage, chars[i])) relevant[num_relevant++] = chars[i]; + } + FcPattern **narrow = fallback_candidates.scratch; + int num_narrow = 1; + // The closest candidate ignoring coverage altogether always has to be + // considered: it is the answer when no font covers any of the chars and it + // can also win on higher priority criteria such as FC_COLOR, which + // fontconfig ranks above coverage. + narrow[0] = q->fonts->fonts[0]; + for (int i = 0; i < q->fonts->nfont && num_relevant; i++) { + FcPattern *f = q->fonts->fonts[i]; + FcCharSet *cs = NULL; + if (FcPatternGetCharSet(f, FC_CHARSET, 0, &cs) != FcResultMatch || !cs) continue; + size_t covered = 0; + for (size_t c = 0; c < num_relevant; c++) { + if (FcCharSetHasChar(cs, relevant[c])) covered++; + } + if (!covered) continue; + if (f != narrow[0]) narrow[num_narrow++] = f; + // Once a font covering every char has been found, no font later in the + // candidate list can score better, since fontconfig prefers closer + // candidates and full coverage is the best possible coverage score. + // Earlier candidates with only partial coverage are kept, as they can + // still win on criteria fontconfig ranks above coverage, such as + // FC_COLOR. + if (covered == num_relevant) break; + } + FcFontSet set = {.nfont = num_narrow, .sfont = num_narrow, .fonts = narrow}; + FcFontSet *sets[1] = {&set}; + return FcFontSetMatch(NULL, sets, 1, pat, result); +} + +// Returns the (render prepared) fontconfig pattern for the best font for the +// specified chars, which the caller must destroy, or NULL with a Python +// exception set. Set use_candidate_cache to false to use FcFontMatch() directly, +// this is used by the tests to check the two give equivalent results. +static FcPattern * +match_fallback_font(const char *family, bool bold, bool italic, bool prefer_color, const char_type *chars, size_t num_chars, bool use_candidate_cache) { + if (family && !family[0]) family = NULL; + if (num_chars > arraysz(char_buf)) num_chars = arraysz(char_buf); + FcResult result; + const FallbackCandidates *q = NULL; + FcPattern *ans = NULL, *pat = create_fallback_pattern(family, bold, italic, prefer_color); + if (!pat) return NULL; + if (!add_charset(pat, chars, num_chars)) goto end; + FcConfigSubstitute(NULL, pat, FcMatchPattern); + FcDefaultSubstitute(pat); + if (use_candidate_cache) q = candidates_for(family, bold, italic, prefer_color); + ans = (q && q->fonts) ? match_in_candidates(q, pat, chars, num_chars, &result) : FcFontMatch(NULL, pat, &result); + if (ans == NULL) PyErr_SetString(PyExc_KeyError, "Failed to find any font matching the specified pattern"); +end: + FcPatternDestroy(pat); + return ans; +} +// }}} + +static bool +extract_face_from_pattern(FcPattern *match, FontConfigFace *ans) { FcChar8 *out; #define g(func, prop, output) \ if (func(match, prop, 0, &output) != FcResultMatch) { \ PyErr_SetString(PyExc_ValueError, "No " #prop " found in fontconfig match result"); \ - goto end; \ + return false; \ } g(FcPatternGetString, FC_FILE, out); if (FcPatternGetInteger(match, FC_INDEX, 0, &ans->index) != FcResultMatch) ans->index = 0; // ignore missing index assume it is zero @@ -401,11 +578,24 @@ _native_fc_match(FcPattern *pat, FontConfigFace *ans) { ans->path = strdup((char *)out); if (!ans->path) { PyErr_NoMemory(); - goto end; + return false; } - ok = true; -end: - if (match != NULL) FcPatternDestroy(match); + return true; +} + +static bool +_native_fc_match(FcPattern *pat, FontConfigFace *ans) { + FcResult result; + FcConfigSubstitute(NULL, pat, FcMatchPattern); + FcDefaultSubstitute(pat); + /* printf("fc_match = %s\n", FcNameUnparse(pat)); */ + FcPattern *match = FcFontMatch(NULL, pat, &result); + if (match == NULL) { + PyErr_SetString(PyExc_KeyError, "FcFontMatch() failed"); + return false; + } + bool ok = extract_face_from_pattern(match, ans); + FcPatternDestroy(match); return ok; } @@ -578,20 +768,11 @@ fallback_font(char_type ch, const char *family, bool bold, bool italic, bool pre ensure_initialized(); memset(ans, 0, sizeof(FontConfigFace)); bool ok = false; - FcPattern *pat = FcPatternCreate(); - if (pat == NULL) { - PyErr_NoMemory(); - return ok; + FcPattern *match = match_fallback_font(family, bold, italic, prefer_color, &ch, 1, true); + if (match) { + ok = extract_face_from_pattern(match, ans); + FcPatternDestroy(match); } - if (family) AP(FcPatternAddString, FC_FAMILY, (const FcChar8 *)family, "family"); - if (bold) { AP(FcPatternAddInteger, FC_WEIGHT, FC_WEIGHT_BOLD, "weight"); } - if (italic) { AP(FcPatternAddInteger, FC_SLANT, FC_SLANT_ITALIC, "slant"); } - if (prefer_color) { AP(FcPatternAddBool, FC_COLOR, true, "color"); } - char_buf[0] = ch; - add_charset(pat, 1); - ok = _native_fc_match(pat, ans); -end: - if (pat != NULL) FcPatternDestroy(pat); if (!ok && builtin_nerd_font.face && builtin_nerd_font.descriptor && glyph_id_for_codepoint(builtin_nerd_font.face, ch) > 0) { PyObject *pypath = PyDict_GetItemString(builtin_nerd_font.descriptor, "path"); PyObject *pyindex = PyDict_GetItemString(builtin_nerd_font.descriptor, "index"); @@ -624,16 +805,14 @@ create_fallback_face(PyObject UNUSED *base_face, const ListOfChars *lc, bool bol ensure_initialized(); PyObject *ans = NULL; RAII_PyObject(d, NULL); - FcPattern *pat = FcPatternCreate(); - if (pat == NULL) return PyErr_NoMemory(); bool glyph_found = false; - AP(FcPatternAddString, FC_FAMILY, (const FcChar8 *)(emoji_presentation ? "emoji" : "monospace"), "family"); - if (!emoji_presentation && bold) { AP(FcPatternAddInteger, FC_WEIGHT, FC_WEIGHT_BOLD, "weight"); } - if (!emoji_presentation && italic) { AP(FcPatternAddInteger, FC_SLANT, FC_SLANT_ITALIC, "slant"); } - if (emoji_presentation) { AP(FcPatternAddBool, FC_COLOR, true, "color"); } size_t num = cell_as_unicode_for_fallback(lc, char_buf, arraysz(char_buf)); - add_charset(pat, num); - d = _fc_match(pat); + FcPattern *match = match_fallback_font( + emoji_presentation ? "emoji" : "monospace", !emoji_presentation && bold, !emoji_presentation && italic, emoji_presentation, char_buf, num, true); + if (match) { + d = pattern_as_dict(match); + FcPatternDestroy(match); + } face_from_descriptor: if (d) { ssize_t idx = -1; @@ -650,10 +829,6 @@ face_from_descriptor: } end: Py_CLEAR(d); - if (pat != NULL) { - FcPatternDestroy(pat); - pat = NULL; - } if (!glyph_found && !PyErr_Occurred()) { if (builtin_nerd_font.face && has_cell_text(face_has_codepoint, builtin_nerd_font.face, false, lc)) { Py_CLEAR(ans); @@ -723,10 +898,41 @@ add_font_file(PyObject UNUSED *self, PyObject *args) { ensure_initialized(); const char *path = NULL; if (!PyArg_ParseTuple(args, "s", &path)) return NULL; - if (FcConfigAppFontAddFile(NULL, (const unsigned char *)path)) Py_RETURN_TRUE; + if (FcConfigAppFontAddFile(NULL, (const unsigned char *)path)) { + clear_fallback_candidates(); // the newly added font must be considered for fallback + Py_RETURN_TRUE; + } Py_RETURN_FALSE; } +static PyObject * +clear_fallback_font_cache(PyObject UNUSED *self, PyObject UNUSED *args) { + clear_fallback_candidates(); + Py_RETURN_NONE; +} + +// Used by the tests to verify that the cached candidate list based matching is +// equivalent to asking fontconfig to score the entire font database. +static PyObject * +fc_match_fallback(PyObject UNUSED *self, PyObject *args, PyObject *kw) { + ensure_initialized(); + PyObject *text; + int bold = 0, italic = 0, prefer_color = 0, use_candidate_cache = 1; + static char *kwds[] = {"text", "bold", "italic", "prefer_color", "use_candidate_cache", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, kw, "U|pppp", kwds, &text, &bold, &italic, &prefer_color, &use_candidate_cache)) return NULL; + const Py_ssize_t count = PyUnicode_GET_LENGTH(text); + if (count < 1 || count > (Py_ssize_t)arraysz(char_buf)) { + PyErr_Format(PyExc_ValueError, "text must have between 1 and %zu characters", arraysz(char_buf)); + return NULL; + } + for (Py_ssize_t i = 0; i < count; i++) char_buf[i] = PyUnicode_READ_CHAR(text, i); + FcPattern *match = match_fallback_font(prefer_color ? "emoji" : "monospace", bold, italic, prefer_color, char_buf, count, use_candidate_cache); + if (!match) return NULL; + PyObject *ans = pattern_as_dict(match); + FcPatternDestroy(match); + return ans; +} + #undef AP @@ -735,6 +941,8 @@ static PyMethodDef module_methods[] = { METHODB(fc_match, METH_VARARGS), METHODB(fc_match_postscript_name, METH_VARARGS), METHODB(add_font_file, METH_VARARGS), + METHODB(clear_fallback_font_cache, METH_NOARGS), + {"fc_match_fallback", (PyCFunction)(void (*)(void))(fc_match_fallback), METH_VARARGS | METH_KEYWORDS, NULL}, METHODB(set_builtin_nerd_font, METH_O), {NULL, NULL, 0, NULL} /* Sentinel */ }; diff --git a/kitty/fonts.c b/kitty/fonts.c index 4b30172c6..4ac4a18e4 100644 --- a/kitty/fonts.c +++ b/kitty/fonts.c @@ -2710,7 +2710,7 @@ get_fallback_font(PyObject UNUSED *self, PyObject *args) { PyErr_SetString(PyExc_ValueError, "Too many fallback fonts"); return NULL; } - return fg->fonts[ans].face; + return Py_NewRef(fg->fonts[ans].face); } static PyObject * diff --git a/kitty/fonts/fontconfig.py b/kitty/fonts/fontconfig.py index a010f6ab4..4c50aafaf 100644 --- a/kitty/fonts/fontconfig.py +++ b/kitty/fonts/fontconfig.py @@ -14,6 +14,7 @@ from kitty.fast_data_types import ( FC_WEIGHT_REGULAR, FC_WIDTH_NORMAL, Face, + clear_fallback_font_cache, fc_list, ) from kitty.fast_data_types import ( @@ -48,6 +49,7 @@ def clear_caches() -> None: all_fonts_map.cache_clear() fc_match.cache_clear() weight_range_for_family.cache_clear() + clear_fallback_font_cache() @lru_cache(maxsize=2) diff --git a/kitty/freetype.c b/kitty/freetype.c index 25b8b8acd..3ea800dd6 100644 --- a/kitty/freetype.c +++ b/kitty/freetype.c @@ -354,6 +354,18 @@ face_equals_descriptor(PyObject *face_, PyObject *descriptor) { static char *get_variation_as_string(Face *self); +static PyObject * +has_codepoint(Face *self, PyObject *cp) { + unsigned long ch = PyLong_AsUnsignedLong(cp); + if (PyErr_Occurred()) return NULL; + if (ch > 0x10ffff) { + PyErr_SetString(PyExc_ValueError, "Not a valid unicode codepoint"); + return NULL; + } + if (glyph_id_for_codepoint((PyObject *)self, ch) > 0) Py_RETURN_TRUE; + Py_RETURN_FALSE; +} + PyObject * face_from_descriptor(PyObject *descriptor, FONTS_DATA_HANDLE fg) { #define D(key, conv, missing_ok) \ @@ -1642,6 +1654,7 @@ static PyMemberDef members[] = { }; static PyMethodDef methods[] = { + METHODB(has_codepoint, METH_O), METHODB(postscript_name, METH_NOARGS), METHODB(identify_for_debug, METH_NOARGS), METHODB(extra_data, METH_NOARGS), diff --git a/kitty_tests/fonts.py b/kitty_tests/fonts.py index f0f46e4c9..49eeead0f 100644 --- a/kitty_tests/fonts.py +++ b/kitty_tests/fonts.py @@ -3,6 +3,7 @@ import array import os +import sys import tempfile import unittest from collections.abc import Iterable @@ -13,6 +14,7 @@ from math import ceil from kitty.constants import is_macos, read_kitty_resource from kitty.fast_data_types import ( DECAWM, + Face, ParsedFontFeature, Screen, get_fallback_font, @@ -547,6 +549,85 @@ class Rendering(FontBaseTest): self.ae((s.cursor.x, s.cursor.y), (2, 4)) self.ae(str(s.line(s.cursor.y)), '\u2716\ufe0f') + def test_get_fallback_font_returns_new_reference(self): + # get_fallback_font() used to return a borrowed reference, which freed + # the Face while the FontGroup was still holding on to it. + for text in '\u4e2d\u0627\u05d0\u0905\u0e01\u10a0': + try: + face = get_fallback_font(text, False, False) + except ValueError: + continue + # one reference held by the FontGroup, one by face, one by the + # argument to getrefcount() + self.assertGreaterEqual(sys.getrefcount(face), 3, f'get_fallback_font() returned a borrowed reference for {text!r}') + del face + # the Face must still be usable via the FontGroup + self.assertIsInstance(get_fallback_font(text, False, False).postscript_name(), str) + break + else: + self.skipTest('No font on this system has a fallback font for any of the tested codepoints') + + @unittest.skipIf(is_macos, 'fontconfig is not used on macOS') + def test_fallback_font_matching(self): + # Fallback font matching narrows the candidates down using a cached, + # pre-sorted candidate list before asking fontconfig to score them, + # instead of scoring the entire font database for every codepoint. See + # https://github.com/kovidgoyal/kitty/issues/10496 + from kitty.fast_data_types import clear_fallback_font_cache, fc_match_fallback + + clear_fallback_font_cache() + faces = {} + + def has_chars(descriptor, text): + key = descriptor['path'], descriptor['index'] + if key not in faces: + faces[key] = Face(descriptor=descriptor) + face = faces[key] + return tuple(face.has_codepoint(ord(ch)) for ch in text) + + def cells_to_test(): + # a spread over every plane, plus the ranges most likely to need a + # fallback font: Private Use Area icons, Nerd Font icons, CJK, + # emoji, math and Braille + for cp in range(0, 0x110000, 0x1553): + yield chr(cp) + for start in (0xE000, 0xF0000, 0x4E00, 0x1F600, 0x2200, 0x2800): + for cp in range(start, start + 0x100, 0x11): + yield chr(cp) + # multi-codepoint cells, where the best font is the one missing the + # fewest codepoints + for cp in (0x53, 0x4E2D, 0x905, 0xE0B0, 0x1F600): + yield chr(cp) + '\u0301' + yield chr(cp) + '\u20e3' + + num_tested = num_changed = 0 + # prefer_color matters most, as fontconfig ranks FC_COLOR above coverage + for bold, italic, prefer_color in ((False, False, False), (True, True, False), (False, False, True), (True, False, True)): + kw = {'bold': bold, 'italic': italic, 'prefer_color': prefer_color} + for text in cells_to_test(): + if 0xD800 <= ord(text[0]) <= 0xDFFF: + continue + expected = fc_match_fallback(text, use_candidate_cache=False, **kw) + actual = fc_match_fallback(text, use_candidate_cache=True, **kw) + num_tested += 1 + if (expected['path'], expected['index']) == (actual['path'], actual['index']): + continue + # fontconfig sorts with an unstable sort, so among fonts it + # scores identically a different one can be picked. That is + # acceptable only as long as glyph coverage is unchanged. + num_changed += 1 + self.ae( + has_chars(actual, text), + has_chars(expected, text), + f'Fallback font for {text!r} with {kw} has different coverage: {actual["path"]} instead of {expected["path"]}', + ) + self.assertGreater(num_tested, 100) + # How often fonts fontconfig scores identically are ordered differently + # depends on the installed fonts, so this bound is deliberately loose: + # the meaningful check is the glyph coverage assertion above. Observed + # rates: 0% with a handful of fonts, 0.5% with 1600 families installed. + self.assertLess(num_changed, num_tested // 2, f'{num_changed} of {num_tested} fallback fonts changed') + @unittest.skipUnless(is_macos, 'Only macOS has a Last Resort font') def test_fallback_font_not_last_resort(self): # Ensure that the LastResort font is not reported as a fallback font on