diff --git a/kitty/fontconfig.c b/kitty/fontconfig.c index 388ccf8b1..a7847468e 100644 --- a/kitty/fontconfig.c +++ b/kitty/fontconfig.c @@ -458,6 +458,10 @@ specialize_font_descriptor(PyObject *base_descriptor, double font_sz_in_pts, dou if (axes) { if (PyDict_SetItemString(ans, "axes", axes) != 0) return NULL; } + PyObject *matrix = PyDict_GetItemString(base_descriptor, "matrix"); + if (matrix) { + if (PyDict_SetItemString(ans, "matrix", matrix) != 0) return NULL; + } PyObject *ff = PyDict_GetItemString(ans, "fontfeatures"); if (ff && PyList_GET_SIZE(ff)) { for (Py_ssize_t i = 0; i < PyList_GET_SIZE(ff); i++) { diff --git a/kitty/fonts/common.py b/kitty/fonts/common.py index fe3718832..d6e873990 100644 --- a/kitty/fonts/common.py +++ b/kitty/fonts/common.py @@ -401,7 +401,38 @@ def get_font_files(opts: Options) -> FontFiles: font = medium_font key = kd[(bold, italic)] ans[key] = font - return {'medium': ans['medium'], 'bold': ans['bold'], 'italic': ans['italic'], 'bi': ans['bi']} + + def apply_synthetic_matrix(font: Descriptor, bold: bool, italic: bool) -> Descriptor: + # fontconfig's FcFontList (used by find_best_match) omits FC_MATRIX from + # its object set, so a roman font found there carries no synthetic-italic + # shear and its "italic" renders upright. Fira Code is the case (it ships + # no italic), in both its static and variable builds. The italic intent + # exists only here at selection finalize, not at face construction, so + # recover the matrix now: for an italic slot whose chosen face is upright + # (no slant) and has no matrix yet, ask fc_match what fontconfig would do. + # fc_match returns a synthetic matrix only when there is no real italic to + # use (no italic face and no slanted named instance or variable slant + # axis); when a real italic exists it returns no matrix, so a font that is + # already italic, static or variable, is never double-slanted. Face construction applies the matrix + # via FT_Set_Transform; specialize_font_descriptor preserves it when the + # descriptor is sized for rendering. Only the matrix is taken, so + # selection is unchanged. Covers the four configured faces; fc_match + # re-matches by family name (see commit message). + if (italic and font['descriptor_type'] == 'fontconfig' + and not font.get('matrix') and not font.get('slant')): + from kitty.fast_data_types import FC_MONO + from kitty.fonts.fontconfig import fc_match + mtx = fc_match(font['family'], bold, italic, FC_MONO if is_monospace(font) else -1).get('matrix') + if mtx: + new_font = font.copy() + new_font['matrix'] = mtx + return new_font + return font + return { + 'medium': ans['medium'], 'bold': ans['bold'], + 'italic': apply_synthetic_matrix(ans['italic'], False, True), + 'bi': apply_synthetic_matrix(ans['bi'], True, True), + } def axis_values_are_equal(defaults: dict[str, float], a: dict[str, float], b: dict[str, float]) -> bool: diff --git a/kitty_tests/fonts.py b/kitty_tests/fonts.py index 6bd65c2c8..adf7cb085 100644 --- a/kitty_tests/fonts.py +++ b/kitty_tests/fonts.py @@ -173,6 +173,41 @@ class Selection(BaseTest): self.ae(face_from_descriptor(ff['medium']).applied_features(), {'dlig': 'dlig', 'test': 'test=3'}) self.ae(face_from_descriptor(ff['bold']).applied_features(), {'dlig': 'dlig', 'test': 'test=3'}) + def test_synthetic_italic_matrix(self): + # A roman-only font that find_best_match finds (e.g. Fira Code, which ships + # no italic face) must get fontconfig's synthetic-italic FC_MATRIX + # (90-synthetic.conf) attached, so its italic renders slanted rather than + # upright; real-italic faces must not. The shear value is fontconfig's, not + # ours, so assert the invariant (a non-identity matrix is present), not the + # exact tuple, for cross-config stability. + if is_macos: + self.skipTest('synthetic-italic FC_MATRIX is a fontconfig feature') + from kitty.fonts.fontconfig import FC_MONO, fc_match + names = set(all_fonts_map(True)['family_map']) | set(all_fonts_map(True)['variable_map']) + if family_name_to_key('fira code') not in names: + self.skipTest('Fira Code not installed') + # Probe fc_match directly so we can tell "environment lacks the rule" (skip) + # from "code did not attach the matrix" (fail). + if fc_match('Fira Code', False, True, FC_MONO).get('matrix') is None: + self.skipTest('fontconfig 90-synthetic.conf not active; no synthetic-italic matrix') + opts = Options() + opts.font_family = parse_font_spec('Fira Code') + ff = get_font_files(opts) + self.assertIsNone(ff['medium'].get('matrix')) # upright stays upright + mi = ff['italic'].get('matrix') + self.assertIsNotNone(mi) # roman, no italic -> sheared + self.assertNotEqual(mi[1], 0.0) # actually slanted, not identity + # Faces are built from a size-specialized descriptor at render time; the + # matrix must survive specialize_font_descriptor or the glyphs render + # upright despite the descriptor above being correct. + from kitty.fast_data_types import specialize_font_descriptor + sd = specialize_font_descriptor(dict(ff['italic']), 12.0, 96.0, 96.0) + self.ae(sd.get('matrix'), mi) + if family_name_to_key('liberation mono') in names: # real-italic control + opts.font_family = parse_font_spec('Liberation Mono') + self.assertIsNone(get_font_files(opts)['italic'].get('matrix')) + + def block_helpers(s, sprites, cell_width, cell_height): mr = {} actual = b''