From fbd4e5ad1fc4d87c7b0a771360241b6a4224d4b4 Mon Sep 17 00:00:00 2001 From: Strykar <2946372+Strykar@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:41:56 +0530 Subject: [PATCH] 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 --- kitty/freetype.c | 28 ++++++++++++++++++---------- kitty_tests/fonts.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/kitty/freetype.c b/kitty/freetype.c index c1e6a189b..d30eef340 100644 --- a/kitty/freetype.c +++ b/kitty/freetype.c @@ -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); } diff --git a/kitty_tests/fonts.py b/kitty_tests/fonts.py index adf7cb085..9e8278f7f 100644 --- a/kitty_tests/fonts.py +++ b/kitty_tests/fonts.py @@ -390,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 encodes the pixel-size fixup of fixed-size color faces (such + # as Noto Color Emoji, a ~109px bitmap strike) as FC_MATRIX. That scale + # must not reach the cairo font matrix, where glyph size is owned by + # cairo_set_font_size() + fit_cairo_glyph(); applying it there shrinks the + # glyph by the fixup factor (requested_px / strike_px), and fit_cairo_glyph() + # only shrinks so it never grows it back (ee937bdd1b). The shrink ranges + # from ~9x at tiny cells to ~1x near the strike; at 48pt/96dpi (~64px) the + # bug gives ~0.28 coverage versus ~0.84 when correct. + if is_macos: + self.skipTest('this is a fontconfig FC_MATRIX issue, not present on macOS') + from kitty.fonts.fontconfig import fc_match + emoji = fc_match('emoji', False, False, 0) + if not (emoji.get('color') and emoji.get('matrix')): + # Only a fixed-size color face that fontconfig gives a fixup matrix can + # trigger this. A scalable COLR emoji font has none, so a pass there + # would be a false negative rather than a real check. + self.skipTest('no fixed-size color emoji font with a fontconfig fixup matrix') + cells = render_string('\U0001F40D', 'monospace', 48.0, 96.0)[2] + pixels = array.array('I', b''.join(cells)) + coverage = sum(1 for p in pixels if p) / max(len(pixels), 1) + self.assertGreater(coverage, 0.5, f'color emoji coverage {coverage:.2f} too low, likely shrunk (#10144)') + def test_shaping(self): def ss(text, font=None):