font/shaper: skip user font-features for fallback faces

User-configured font features (e.g. cv10, dlig) were being applied to
all fonts including fallback fonts. This caused unexpected behavior such
as CJK fallback glyphs changing when a Latin-only feature was enabled.

Font features are now only applied to the primary font. Fallback fonts
get only the default features (e.g. liga). Applies to both the CoreText
and HarfBuzz shapers.

Fixes #11464
pull/11490/head
Jesus Vazquez 2026-03-14 13:13:48 +01:00
parent e75f8956c5
commit a91873a5b6
3 changed files with 164 additions and 10 deletions

View File

@ -230,8 +230,9 @@ language: ?[:0]const u8 = null,
///
/// The syntax is fairly loose, but invalid settings will be silently ignored.
///
/// The font feature will apply to all fonts rendered by Ghostty. A future
/// enhancement will allow targeting specific faces.
/// Font features are only applied to the primary font family. Fallback
/// fonts (fonts automatically selected when the primary font doesn't
/// contain a needed glyph) use only the default font features.
///
/// To disable programming ligatures, use `-calt` since this is the typical
/// feature name for programming ligatures. To look into what font features

View File

@ -47,6 +47,9 @@ pub const Shaper = struct {
features: *macos.foundation.Dictionary,
/// A version of the features dictionary with the default features excluded.
features_no_default: *macos.foundation.Dictionary,
/// A version of the features dictionary with only the default features
/// (no user-configured features). Used for fallback fonts.
features_default_only: *macos.foundation.Dictionary,
/// The shared memory used for shaping results.
cell_buf: CellBuf,
@ -168,6 +171,8 @@ pub const Shaper = struct {
errdefer features.release();
const features_no_default = try makeFeaturesDict(feats);
errdefer features_no_default.release();
const features_default_only = try makeFeaturesDict(&default_features);
errdefer features_default_only.release();
var run_state = RunState.init();
errdefer run_state.deinit(alloc);
@ -221,6 +226,7 @@ pub const Shaper = struct {
.run_state = run_state,
.features = features,
.features_no_default = features_no_default,
.features_default_only = features_default_only,
.typesetter_attr_dict = typesetter_attr_dict,
.cached_fonts = .{},
.cached_font_grid = 0,
@ -235,6 +241,7 @@ pub const Shaper = struct {
self.run_state.deinit(self.alloc);
self.features.release();
self.features_no_default.release();
self.features_default_only.release();
self.typesetter_attr_dict.release();
{
@ -610,9 +617,12 @@ pub const Shaper = struct {
defer grid.lock.unlockShared();
const face = try grid.resolver.collection.getFace(index);
const entry = try grid.resolver.collection.getEntry(index);
const original = face.font;
const attrs = if (face.quirks_disable_default_font_features)
const attrs = if (entry.fallback)
self.features_default_only
else if (face.quirks_disable_default_font_features)
self.features_no_default
else
self.features;
@ -2515,6 +2525,93 @@ test "shape high plane sprite font codepoint" {
try testing.expectEqual(null, try it.next(alloc));
}
test "shape fallback font ignores user features" {
// Regression test for https://github.com/ghostty-org/ghostty/issues/11464
//
// font-feature should only apply to the primary font, not fallback fonts.
// We test this by enabling `dlig` (discretionary ligatures) and shaping
// ">=" which both Inconsolata and JetBrainsMono can ligate into a single
// glyph. When Inconsolata is the primary font, dlig should apply and
// produce a ligature (1 glyph from 2 chars). When Inconsolata is a
// fallback font, dlig should NOT apply (2 glyphs from 2 chars).
const testing = std.testing;
const alloc = testing.allocator;
// Glyph indices for Inconsolata-Regular.ttf (embedded in src/font/res/).
const inconsolata_greater_than = 626; // ">"
const inconsolata_equals = 624; // "="
const inconsolata_greater_equal_lig = 875; // ">=" dlig ligature
// First: shape ">=" with Inconsolata as PRIMARY font (dlig enabled).
// This should produce a ligature: 2 input cells 1 output glyph.
{
var testdata = try testShaper(alloc);
defer testdata.deinit();
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice(">=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
const run = (try it.next(alloc)).?;
try testing.expectEqual(@as(usize, 2), run.cells);
const cells = try shaper.shape(run);
// dlig applied ligature 1 glyph (Inconsolata's ">=" ligature)
try testing.expectEqual(@as(usize, 1), cells.len);
try testing.expectEqual(inconsolata_greater_equal_lig, cells[0].glyph_index);
}
// Second: shape ">=" where NotoEmoji is primary and Inconsolata is fallback.
// NotoEmoji lacks ">=" glyphs, so the resolver falls through to Inconsolata.
// dlig is enabled in config but should NOT be applied to fallback fonts,
// so this should NOT produce a ligature: 2 input cells 2 output glyphs.
{
var testdata = try testShaperPrimaryAndFallback(alloc, .{
.primary = font.embedded.emoji_text,
.fallback = font.embedded.inconsolata,
.features = &.{"dlig"},
});
defer testdata.deinit();
var t = try terminal.Terminal.init(alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice(">=");
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var shaper = &testdata.shaper;
var it = shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
const run = (try it.next(alloc)).?;
try testing.expectEqual(@as(usize, 2), run.cells);
const cells = try shaper.shape(run);
// dlig NOT applied to fallback no ligature individual ">" and "=" glyphs
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(inconsolata_greater_than, cells[0].glyph_index);
try testing.expectEqual(inconsolata_equals, cells[1].glyph_index);
}
}
const TestShaper = struct {
alloc: Allocator,
shaper: Shaper,
@ -2676,3 +2773,57 @@ fn testShaperWithDiscoveredFont(alloc: Allocator, font_req: [:0]const u8) !TestS
.lib = lib,
};
}
/// Sets up a collection with a primary font and a fallback font,
/// with configurable font features.
fn testShaperPrimaryAndFallback(
alloc: Allocator,
opts: struct {
primary: [:0]const u8,
fallback: [:0]const u8,
features: []const []const u8 = &.{},
},
) !TestShaper {
var lib = try Library.init(alloc);
errdefer lib.deinit();
var c = Collection.init();
c.load_options = .{ .library = lib };
_ = try c.add(alloc, try .init(
lib,
opts.primary,
.{ .size = .{ .points = 12 } },
), .{
.style = .regular,
.fallback = false,
.size_adjustment = .none,
});
_ = try c.add(alloc, try .init(
lib,
opts.fallback,
.{ .size = .{ .points = 12 } },
), .{
.style = .regular,
.fallback = true,
.size_adjustment = .none,
});
const grid_ptr = try alloc.create(SharedGrid);
errdefer alloc.destroy(grid_ptr);
grid_ptr.* = try .init(alloc, .{ .collection = c });
errdefer grid_ptr.*.deinit(alloc);
var shaper = try Shaper.init(alloc, .{
.features = opts.features,
});
errdefer shaper.deinit();
return TestShaper{
.alloc = alloc,
.shaper = shaper,
.grid = grid_ptr,
.lib = lib,
};
}

View File

@ -138,14 +138,16 @@ pub const Shaper = struct {
defer run.grid.lock.unlock();
const face = try run.grid.resolver.collection.getFace(run.font_index);
const i = if (!face.quirks_disable_default_font_features) 0 else i: {
// If we are disabling default font features we just offset
// our features by the hardcoded items because always
// add those at the beginning.
break :i default_features.len;
};
const entry = try run.grid.resolver.collection.getEntry(run.font_index);
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]);
// If we are disabling default font features we just offset
// our features by the hardcoded items because we always
// add those at the beginning.
const i = if (!face.quirks_disable_default_font_features) 0 else default_features.len;
// Fallback fonts only get default features, not user-configured ones.
const end = if (entry.fallback) default_features.len else self.hb_feats.len;
harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..end]);
}
// If our buffer is empty, we short-circuit the rest of the work