mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-10-20 20:22:10 +00:00
Fix #10496
This commit is contained in:
parent
8a101a6d91
commit
fd65a44c1e
7 changed files with 374 additions and 62 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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]: ...
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue