Compare commits

...

12 Commits

Author SHA1 Message Date
Kovid Goyal
a685af8c35 version 0.47.4 2026-06-15 08:11:46 +05:30
Kovid Goyal
ba6b652850 Merge branch 'color-emoji-test-robust' of https://github.com/Strykar/kitty 2026-06-14 21:06:44 +05:30
Strykar
fc23ef57ec fonts: make the color emoji regression test environment-independent
The coverage threshold (0.5 in fbd4e5ad1, lowered to 0.3 in d4106ef2d to
get CI green) is environment-dependent: a correctly sized emoji covers
~0.84 of its cell on one box but ~0.39 in CI, while the buggy render is
~0.28, so the margin is thin and font/cell dependent.

Render the same font two ways at one size instead: via its fontconfig
descriptor (which carries the size-fixup matrix) and via its file path
(which does not). They must come out the same size; the bug shrinks the
descriptor one. Only the matrix differs, so the check no longer depends
on the environment or the emoji artwork.
2026-06-14 21:00:12 +05:30
Kovid Goyal
d4106ef2db Lower threshold for color emoji shrinkage test as at least in CI the font results in a smaller emoji 2026-06-14 20:48:59 +05:30
Kovid Goyal
9393e29793 Update changelog 2026-06-14 20:08:53 +05:30
Kovid Goyal
e4bce6485b Merge branch 'fix-10144' of https://github.com/Strykar/kitty 2026-06-14 20:07:56 +05:30
Strykar
fbd4e5ad1f freetype: do not apply the fontconfig size-fixup matrix to color glyphs
ee937bdd1b routed FC_MATRIX through the cairo font matrix so synthetic
slant reaches color glyphs. But FC_MATRIX is also how fontconfig encodes
the pixel-size fixup of fixed-size faces. Noto Color Emoji is a ~109px
bitmap strike and fontconfig hands consumers a matrix scaling it to the
requested size (factor = requested_px / strike_px). cairo_set_font_size()
already brings the strike to the requested size, so feeding that matrix
into cairo_set_font_matrix() in apply_cairo_font_size() scales it down
again by the fixup factor. At terminal cell sizes that is a large shrink,
up to ~9x for small cells (easing to 1x as the cell nears the strike), so
color emoji render as a dot; fit_cairo_glyph() only shrinks, so it never
grows them back.

Only the shear carries synthetic slant; the diagonal is the size, which
cairo_set_font_size() and fit_cairo_glyph() already handle. Apply only
the shear and fall through to cairo_set_font_size() when there is no
shear. Pure-slant matrices are unchanged. This carries only the shear to
color glyphs, so a non-uniform diagonal scale from a hand-built FC_MATRIX
is dropped on that path; the stock fixup is uniform, so dropping it is
the intended behaviour.

Add a regression test that renders a color emoji and checks it fills its
cells, skipped unless a fixed-size color font with a fontconfig fixup
matrix is present.

Fixes #10144
2026-06-14 15:14:04 +05:30
Kovid Goyal
48ee061454 Update changelog 2026-06-13 12:50:05 +05:30
Kovid Goyal
aa4e94cef5 Merge branch 'render-time-matrix' of https://github.com/Strykar/kitty 2026-06-13 12:49:05 +05:30
Kovid Goyal
8a39929c15 Bump sqlite for CVE 2026-06-12 19:54:22 +05:30
Strykar
8be2a10b29 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.
2026-06-11 00:57:59 +05:30
Strykar
779a49acde 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.
2026-06-10 13:57:30 +05:30
7 changed files with 124 additions and 15 deletions

View File

@@ -78,11 +78,11 @@
},
{
"name": "sqlite 3.53.0",
"name": "sqlite 3.53.2",
"unix": {
"file_extension": "tar.gz",
"hash": "sha256:851e9b38192fe2ceaa65e0baa665e7fa06230c3d9bd1a6a9662d02380d73365a",
"urls": ["https://www.sqlite.org/2026/{name}-autoconf-3530000.{file_extension}"]
"hash": "sha256:588ad51949419a56ebe81fe56193d510c559eb94c9a57748387860b5d3069316",
"urls": ["https://www.sqlite.org/2026/{name}-autoconf-3530200.{file_extension}"]
}
},

View File

@@ -173,6 +173,13 @@ consumption to do the same tasks.
Detailed list of changes
-------------------------------------
0.47.4 [2026-06-15]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Linux: Fix a regression in the previous release that broke rendering of bitmap color fonts (:pull:`10145`)
- Linux: Allow fake italics defined via a matrix in fontconfig settings to work for fonts like Fira Code that do not ship with an italic face (:pull:`10120`)
0.47.3 [2026-06-12]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -22,7 +22,7 @@ class Version(NamedTuple):
appname: str = 'kitty'
kitty_face = '🐱'
version: Version = Version(0, 47, 3)
version: Version = Version(0, 47, 4)
str_version: str = '.'.join(map(str, version))
_plat = sys.platform.lower()
is_macos: bool = 'darwin' in _plat

View File

@@ -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++) {

View File

@@ -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:

View File

@@ -878,18 +878,26 @@ apply_cairo_font_size(Face *self, unsigned sz_px) {
// on self->face in face_from_descriptor. cairo owns FT_Set_Transform on
// its face and derives it from the font matrix on every render
// (_cairo_ft_unscaled_font_set_scale in cairo-ft-font.c), so the only
// channel that reaches glyph rasterization is the cairo font matrix
// itself. Encode FC_MATRIX there.
// FT_Matrix is xx,xy,yx,yy (row-major); cairo_matrix_init takes
// xx,yx,xy,yy. Same matrix, transposed argument order.
if (!self->has_matrix) { cairo_set_font_size(self->cairo.cr, sz_px); return; }
// channel that reaches glyph rasterization is the cairo font matrix.
//
// FC_MATRIX is overloaded. Besides synthetic slant, fontconfig also encodes
// the pixel size fixup of fixed-size faces here, as a pure diagonal scale
// (Noto Color Emoji is matched with [0.1147 0; 0 0.1147]). The color path
// already sizes glyphs with cairo_set_font_size() + fit_cairo_glyph(), and
// fit_cairo_glyph() only shrinks, so feeding the fixup scale in here shrinks
// color emoji with no way to recover (#10144). Honor only the shear that
// carries synthetic slant and leave the size to cairo_set_font_size().
if (!self->has_matrix || self->matrix.xx == 0 || self->matrix.yy == 0) {
cairo_set_font_size(self->cairo.cr, sz_px); return;
}
double shear_xy = (double)self->matrix.xy / (double)self->matrix.xx;
double shear_yx = (double)self->matrix.yx / (double)self->matrix.yy;
if (shear_xy == 0 && shear_yx == 0) { cairo_set_font_size(self->cairo.cr, sz_px); return; }
// FT_Matrix is xx,xy,yx,yy (row-major); cairo_matrix_init takes xx,yx,xy,yy.
// The diagonal is unit scale (size is handled above); apply only the shear.
double s = (double)sz_px;
double xx = self->matrix.xx / 65536.0;
double xy = self->matrix.xy / 65536.0;
double yx = self->matrix.yx / 65536.0;
double yy = self->matrix.yy / 65536.0;
cairo_matrix_t m;
cairo_matrix_init(&m, xx * s, yx * s, xy * s, yy * s, 0, 0);
cairo_matrix_init(&m, s, shear_yx * s, shear_xy * s, s, 0, 0);
cairo_set_font_matrix(self->cairo.cr, &m);
}

View File

@@ -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''
@@ -355,6 +390,30 @@ class Rendering(FontBaseTest):
self.assertGreater(w, 64)
self.assertGreater(h, 64)
def test_color_emoji_not_shrunk(self):
# Regression test for https://github.com/kovidgoyal/kitty/issues/10144.
# fontconfig gives fixed-size color faces (e.g. Noto Color Emoji) a
# pixel-size fixup encoded as FC_MATRIX. That scale must not reach the
# cairo font matrix used for color glyphs; applying it shrinks color emoji
# to a dot (ee937bdd1b). Render the same font two ways at the same size:
# from its fontconfig descriptor, which carries the fixup matrix, and from
# its file path, which does not. A correct build renders both at the same
# size; the bug shrinks the descriptor one. Comparing the two is
# environment-independent since only the matrix differs.
if is_macos:
self.skipTest('FC_MATRIX is a fontconfig feature, not used on macOS')
from kitty.fonts.fontconfig import fc_match
desc = dict(fc_match('emoji', False, False, 0))
if not (desc.get('color') and desc.get('matrix')):
self.skipTest('no fixed-size color emoji font with a fontconfig fixup matrix')
with_matrix = face_from_descriptor(desc)
with_matrix.set_size(64, 96, 96)
without_matrix = create_face(desc['path'])
without_matrix.set_size(64, 96, 96)
_, mw, mh = with_matrix.render_codepoint(0x1F40D)
_, rw, rh = without_matrix.render_codepoint(0x1F40D)
self.assertGreater(mh, 0.5 * rh, f'color emoji shrunk by FC_MATRIX: {mh}px vs {rh}px (#10144)')
def test_shaping(self):
def ss(text, font=None):