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