From 779a49acde85153ac027320d1e4b9dfd0a33a908 Mon Sep 17 00:00:00 2001 From: Strykar <2946372+Strykar@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:57:30 +0530 Subject: [PATCH 1/2] fonts: preserve the font matrix when specializing a descriptor specialize_font_descriptor() re-resolves a descriptor by file, index, size and dpi to pick up size dependent fields. The re-match carries no slant request, so fontconfig cannot re-derive a synthetic italic matrix, and only index, named_style and axes were copied back from the base descriptor. Any FC_MATRIX on the descriptor was therefore lost on every sized face build, so the face was constructed without it and FT_Set_Transform was never called, rendering the glyphs upright. Descriptors can carry FC_MATRIX since b3e7c3e ("Read FC_MATRIX from fontconfig"). Copy the matrix back like the other selection derived fields the re-match cannot reproduce. --- kitty/fontconfig.c | 4 ++++ 1 file changed, 4 insertions(+) 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++) { From 8be2a10b29f0af34675b448542d63776c885f8f7 Mon Sep 17 00:00:00 2001 From: Strykar <2946372+Strykar@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:54:37 +0530 Subject: [PATCH 2/2] fonts: attach synthetic-italic FC_MATRIX to found roman faces fontconfig's FcFontList omits FC_MATRIX from its object set (kitty/fontconfig.c), so a roman font that find_best_match finds there (e.g. Fira Code, which ships no italic, in both its static and variable builds) carries no synthetic-italic shear and its "italic" renders upright. A family that is not found is substituted, and when the substitute resolves through the listed faces those descriptors are equally matrix-less, so this attach covers them too. Only raw fc_match descriptors (runtime glyph-fallback faces via create_fallback_face, and find_best_match's last-resort return) already carry the matrix from substitution. The italic intent for the configured faces exists only during selection, not at face construction, so attach the matrix at the end of get_font_files: for an italic slot whose chosen face is upright and has no matrix, 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), so a font that is already italic, static or variable, is never double-slanted. Face construction applies the matrix via FT_Set_Transform; the previous commit makes it survive the size specialization step the render path builds faces from. Only the matrix is taken, so selection is unchanged. FontConfigPattern declared matrix as a required key, but pattern_as_dict sets it only when the pattern has one, so declare it NotRequired. With that and narrowing on descriptor_type the attach needs no cast. Add a regression test (test_synthetic_italic_matrix): a roman no-italic font gets a non-identity matrix on its italic slot while a real-italic control does not, and the matrix survives specialize_font_descriptor. It asserts the invariant rather than the exact shear (the value is fontconfig's, version-dependent) and skips when the synthetic rule is inactive. Covers the four configured faces. Limitation: fc_match re-matches by family name, so under an uncommon config (a multi-face family key plus a user per-font FC_MATRIX rule keyed on width/style) it can attach a matrix computed for a different face; the 90-synthetic shear this targets is weight-independent and unaffected. A production version should re-match the selected face by path+index+slant. --- kitty/fast_data_types.pyi | 2 +- kitty/fonts/common.py | 33 ++++++++++++++++++++++++++++++++- kitty_tests/fonts.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 1d70c5255..371ee122f 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -423,7 +423,7 @@ class FontConfigPattern(TypedDict): scalable: bool outline: bool color: bool - matrix: tuple[float, float, float, float] + matrix: NotRequired[tuple[float, float, float, float]] variable: bool named_instance: bool 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''