font/shaper: eliminate grapheme candidate allocations (#13584)

RunIterator allocated a list of font candidates for every
multi-codepoint grapheme, then scanned it for the first font covering
the entire cluster.

Instead, check the primary and additional font candidates as they're
discovered. This preserves their order while removing the temporary
array and avoids additional lookups when the primary font supports the
full grapheme.
This commit is contained in:
Mitchell Hashimoto
2026-08-03 15:56:34 -07:00
committed by GitHub
2 changed files with 55 additions and 33 deletions

View File

@@ -862,6 +862,41 @@ test "shape emoji width long" {
try testing.expectEqual(@as(usize, 1), count);
}
test "shaper selects font for entire grapheme" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var testdata = try testShaper(alloc);
defer testdata.deinit();
var t = try terminal.Terminal.init(io, alloc, .{ .cols = 5, .rows = 3 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("#\u{20E3}"); // Combining enclosing keycap
const primary = (try testdata.grid.getIndex(alloc, '#', .regular, null)).?;
try testing.expect(testdata.grid.hasCodepoint(primary, '#', null));
try testing.expect(!testdata.grid.hasCodepoint(primary, 0x20E3, null));
const additional = (try testdata.grid.getIndex(alloc, 0x20E3, .regular, null)).?;
try testing.expect(testdata.grid.hasCodepoint(additional, '#', null));
try testing.expect(testdata.grid.hasCodepoint(additional, 0x20E3, null));
var state: terminal.RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
var it = testdata.shaper.runIterator(.{
.grid = testdata.grid,
.cells = state.row_data.get(0).cells.slice(),
});
const run = (try it.next(alloc)).?;
try testing.expectEqual(additional, run.font_index);
}
test "shape variation selector VS15" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -348,47 +348,34 @@ pub const RunIterator = struct {
// we just return whatever index for the cell codepoint.
if (!cell.hasGrapheme()) return primary;
// If this is a grapheme, we need to find a font that supports
// all of the codepoints in the grapheme.
var candidates: std.ArrayList(font.Collection.Index) = try .initCapacity(
alloc,
graphemes.len + 1,
);
defer candidates.deinit(alloc);
candidates.appendAssumeCapacity(primary);
// Try the primary font followed by the font selected for each
// additional codepoint until one supports the entire grapheme.
candidate: for (0..graphemes.len + 1) |i| {
const idx = if (i == 0) primary else idx: {
const cp = graphemes[i - 1];
for (graphemes) |cp| {
// Ignore Emoji ZWJs
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue;
// Ignore Emoji ZWJs
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue :candidate;
// Find a font that supports this codepoint. If none support this
// then the whole grapheme can't be rendered so we return null.
//
// We explicitly do not require the additional grapheme components
// to support the base presentation, since it is common for emoji
// fonts to support the base emoji with emoji presentation but not
// certain ZWJ-combined characters like the male and female signs.
const idx = try self.opts.grid.getIndex(
alloc,
cp,
style,
null,
) orelse return null;
candidates.appendAssumeCapacity(idx);
}
// We explicitly do not require the additional grapheme components
// to support the base presentation, since it is common for emoji
// fonts to support the base emoji with emoji presentation but not
// certain ZWJ-combined characters like the male and female signs.
break :idx try self.opts.grid.getIndex(
alloc,
cp,
style,
null,
) orelse return null;
};
// We need to find a candidate that has ALL of our codepoints
for (candidates.items) |idx| {
if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue;
for (graphemes) |cp| {
// Ignore Emoji ZWJs
if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue;
if (!self.opts.grid.hasCodepoint(idx, cp, null)) break;
} else {
// If the while completed, then we have a candidate that
// supports all of our codepoints.
return idx;
if (!self.opts.grid.hasCodepoint(idx, cp, null)) continue :candidate;
}
return idx;
}
return null;