ghostty/src/font/CodepointResolver.zig
Mitchell Hashimoto 80c0ba8758 font: when presentation isn't found, always fallback to any
Fixes #1808

When resolving a codepoint, we first attempt to find the default
presentation (if an explicit one is not given), but we were not falling
back to "any" in case that presentation was not found.
2024-05-30 14:22:42 -07:00

532 lines
20 KiB
Zig

//! CodepointResolver maps a codepoint to a font. It is more dynamic
//! than "Collection" since it supports mapping codepoint ranges to
//! specific fonts, searching for fallback fonts, and more.
//!
//! To initialize the codepoint resolver, manually initialize using
//! Zig initialization syntax: .{}-style. Set the fields you want set,
//! and begin using the resolver.
//!
//! Deinit must still be called on the resolver to free any memory
//! allocated during use. All functions that take allocators should use
//! the same allocator.
const CodepointResolver = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const ziglyph = @import("ziglyph");
const font = @import("main.zig");
const Atlas = font.Atlas;
const CodepointMap = font.CodepointMap;
const Collection = font.Collection;
const Discover = font.Discover;
const DiscoveryDescriptor = font.discovery.Descriptor;
const Face = font.Face;
const Glyph = font.Glyph;
const Library = font.Library;
const Presentation = font.Presentation;
const RenderOptions = font.face.RenderOptions;
const SpriteFace = font.SpriteFace;
const Style = font.Style;
const log = std.log.scoped(.font_codepoint_resolver);
/// The underlying collection of fonts. This will be modified as
/// new fonts are found via the resolver. The resolver takes ownership
/// of the collection and will deinit it when it is deinitialized.
collection: Collection,
/// The set of statuses and whether they're enabled or not. This defaults
/// to true. This can be changed at runtime with no ill effect.
styles: StyleStatus = StyleStatus.initFill(true),
/// If discovery is available, we'll look up fonts where we can't find
/// the codepoint. This can be set after initialization.
discover: ?*Discover = null,
/// A map of codepoints to font requests for codepoint-level overrides.
/// The memory associated with the map is owned by the caller and is not
/// modified or freed by Group.
codepoint_map: ?CodepointMap = null,
/// The descriptor cache is used to cache the descriptor to font face
/// mapping for codepoint maps.
descriptor_cache: DescriptorCache = .{},
/// Set this to a non-null value to enable sprite glyph drawing. If this
/// isn't enabled we'll just fall through to trying to use regular fonts
/// to render sprite glyphs. But more than likely, if this isn't set then
/// terminal rendering will look wrong.
sprite: ?SpriteFace = null,
pub fn deinit(self: *CodepointResolver, alloc: Allocator) void {
self.collection.deinit(alloc);
self.descriptor_cache.deinit(alloc);
}
/// Looks up the font that should be used for a specific codepoint.
/// The font index is valid as long as font faces aren't removed. This
/// isn't cached; it is expected that downstream users handle caching if
/// that is important.
///
/// Optionally, a presentation format can be specified. This presentation
/// format will be preferred but if it can't be found in this format,
/// any format will be accepted. If presentation is null, the UCD
/// (Unicode Character Database) will be used to determine the default
/// presentation for the codepoint.
/// a code point.
///
/// An allocator is required because certain functionality (codepoint
/// mapping, fallback fonts, etc.) may require memory allocation. Curiously,
/// this function cannot error! If an error occurs for any reason, including
/// memory allocation, the associated functionality is ignored and the
/// resolver attempts to use a different method to satisfy the codepoint.
/// This behavior is intentional to make the resolver apply best-effort
/// logic to satisfy the codepoint since its better to render something
/// than nothing.
///
/// This logic is relatively complex so the exact algorithm is documented
/// here. If this gets out of sync with the code, ask questions.
///
/// 1. If a font style is requested that is disabled (i.e. bold),
/// we start over with the regular font style. The regular font style
/// cannot be disabled, but it can be replaced with a stylized font
/// face.
///
/// 2. If there is a codepoint override for the codepoint, we satisfy
/// that requirement if we can, no matter what style or presentation.
///
/// 3. If this is a sprite codepoint (such as an underline), then the
/// sprite font always is the result.
///
/// 4. If the exact style and presentation request can be satisfied by
/// one of our loaded fonts, we return that value. We search loaded
/// fonts in the order they're added to the group, so the caller must
/// set the priority order.
///
/// 5. If the style isn't regular, we restart this process at this point
/// but with the regular style. This lets us fall back to regular with
/// our loaded fonts before trying a fallback. We'd rather show a regular
/// version of a codepoint from a loaded font than find a new font in
/// the correct style because styles in other fonts often change
/// metrics like glyph widths.
///
/// 6. If the style is regular, and font discovery is enabled, we look
/// for a fallback font to satisfy our request.
///
/// 7. Finally, as a last resort, we fall back to restarting this whole
/// process with a regular font face satisfying ANY presentation for
/// the codepoint. If this fails, we return null.
///
pub fn getIndex(
self: *CodepointResolver,
alloc: Allocator,
cp: u32,
style: Style,
p: ?Presentation,
) ?Collection.Index {
// If we've disabled a font style, then fall back to regular.
if (style != .regular and !self.styles.get(style)) {
return self.getIndex(alloc, cp, .regular, p);
}
// Codepoint overrides.
if (self.getIndexCodepointOverride(alloc, cp)) |idx_| {
if (idx_) |idx| return idx;
} else |err| {
log.warn("codepoint override failed codepoint={} err={}", .{ cp, err });
}
// If we have sprite drawing enabled, check if our sprite face can
// handle this.
if (self.sprite) |sprite| {
if (sprite.hasCodepoint(cp, p)) {
return Collection.Index.initSpecial(.sprite);
}
}
// Build our presentation mode. If we don't have an explicit presentation
// given then we use the UCD (Unicode Character Database) to determine
// the default presentation. Note there is some inefficiency here because
// we'll do this muliple times if we recurse, but this is a cached function
// call higher up (GroupCache) so this should be rare.
const p_mode: Collection.PresentationMode = if (p) |v| .{ .explicit = v } else .{
.default = if (ziglyph.emoji.isEmojiPresentation(@intCast(cp)))
.emoji
else
.text,
};
// If we can find the exact value, then return that.
if (self.collection.getIndex(cp, style, p_mode)) |value| return value;
// If we're not a regular font style, try looking for a regular font
// that will satisfy this request. Blindly looking for unmatched styled
// fonts to satisfy one codepoint results in some ugly rendering.
if (style != .regular) {
if (self.getIndex(alloc, cp, .regular, p)) |value| return value;
}
// If we are regular, try looking for a fallback using discovery.
if (style == .regular and font.Discover != void) {
log.debug("searching for a fallback font for cp={X}", .{cp});
if (self.discover) |disco| discover: {
const load_opts = self.collection.load_options orelse
break :discover;
var disco_it = disco.discoverFallback(alloc, &self.collection, .{
.codepoint = cp,
.size = load_opts.size.points,
.bold = style == .bold or style == .bold_italic,
.italic = style == .italic or style == .bold_italic,
.monospace = false,
}) catch break :discover;
defer disco_it.deinit();
while (true) {
var deferred_face = (disco_it.next() catch |err| {
log.warn("fallback search failed with error err={}", .{err});
break;
}) orelse break;
// Discovery is supposed to only return faces that have our
// codepoint but we can't search presentation in discovery so
// we have to check it here.
const face: Collection.Entry = .{ .fallback_deferred = deferred_face };
if (!face.hasCodepoint(cp, p_mode)) {
deferred_face.deinit();
continue;
}
var buf: [256]u8 = undefined;
log.info("found codepoint 0x{X} in fallback face={s}", .{
cp,
deferred_face.name(&buf) catch "<error>",
});
return self.collection.add(alloc, style, face) catch {
deferred_face.deinit();
break :discover;
};
}
log.debug("no fallback face found for cp={X}", .{cp});
}
}
// If this is regular with any matching presentation, then we are done
// there is nothing more we can do. Otherwise we fall through and do
// an any presentation search.
if (style == .regular and p_mode == .any) return null;
// For non-regular fonts, we fall back to regular with any presentation
return self.collection.getIndex(cp, .regular, .{ .any = {} });
}
/// Checks if the codepoint is in the map of codepoint overrides,
/// finds the override font, and returns it.
fn getIndexCodepointOverride(
self: *CodepointResolver,
alloc: Allocator,
cp: u32,
) !?Collection.Index {
// If discovery is disabled then we can't do codepoint overrides
// since the override is based on discovery to find the font.
if (comptime font.Discover == void) return null;
// Get our codepoint map. If we have no map set then we have no
// codepoint overrides and we're done.
const map = self.codepoint_map orelse return null;
// If we have a codepoint too large or isn't in the map, then we
// don't have an override. The map returns a descriptor that can be
// used with font discovery to search for a matching font.
const cp_u21 = std.math.cast(u21, cp) orelse return null;
const desc = map.get(cp_u21) orelse return null;
// Fast path: the descriptor is already loaded. This means that we
// already did the search before and we have an exact font for this
// codepoint.
const idx_: ?Collection.Index = self.descriptor_cache.get(desc) orelse idx: {
// Slow path: we have to find this descriptor and load the font
const discover = self.discover orelse return null;
var disco_it = try discover.discover(alloc, desc);
defer disco_it.deinit();
const face = (try disco_it.next()) orelse {
log.warn(
"font lookup for codepoint map failed codepoint={} err=FontNotFound",
.{cp},
);
// Add null to the cache so we don't do a lookup again later.
try self.descriptor_cache.put(alloc, desc, null);
return null;
};
// Add the font to our list of fonts so we can get an index for it,
// and ensure the index is stored in the descriptor cache for next time.
const idx = try self.collection.add(
alloc,
.regular,
.{ .deferred = face },
);
try self.descriptor_cache.put(alloc, desc, idx);
break :idx idx;
};
// The descriptor cache will populate null if the descriptor is not found
// to avoid expensive discoveries later, so if it is null then we already
// searched and found nothing.
const idx = idx_ orelse return null;
// We need to verify that this index has the codepoint we want.
if (self.collection.hasCodepoint(idx, cp, .{ .any = {} })) {
log.debug("codepoint override based on config codepoint={} family={s}", .{
cp,
desc.family orelse "",
});
return idx;
}
return null;
}
/// Returns the presentation for a specific font index. This is useful for
/// determining what atlas is needed.
pub fn getPresentation(
self: *CodepointResolver,
index: Collection.Index,
glyph_index: u32,
) !Presentation {
if (index.special()) |sp| return switch (sp) {
.sprite => .text,
};
const face = try self.collection.getFace(index);
return if (face.isColorGlyph(glyph_index)) .emoji else .text;
}
/// Render a glyph by glyph index into the given font atlas and return
/// metadata about it.
///
/// This performs no caching, it is up to the caller to cache calls to this
/// if they want. This will also not resize the atlas if it is full.
///
/// IMPORTANT: this renders by /glyph index/ and not by /codepoint/. The caller
/// is expected to translate codepoints to glyph indexes in some way. The most
/// trivial way to do this is to get the Face and call glyphIndex. If you're
/// doing text shaping, the text shaping library (i.e. HarfBuzz) will automatically
/// determine glyph indexes for a text run.
pub fn renderGlyph(
self: *CodepointResolver,
alloc: Allocator,
atlas: *Atlas,
index: Collection.Index,
glyph_index: u32,
opts: RenderOptions,
) !Glyph {
// Special-case fonts are rendered directly.
if (index.special()) |sp| switch (sp) {
.sprite => return try self.sprite.?.renderGlyph(
alloc,
atlas,
glyph_index,
opts,
),
};
const face = try self.collection.getFace(index);
const glyph = try face.renderGlyph(alloc, atlas, glyph_index, opts);
// log.warn("GLYPH={}", .{glyph});
return glyph;
}
/// Packed array of booleans to indicate if a style is enabled or not.
pub const StyleStatus = std.EnumArray(Style, bool);
/// Map of descriptors to faces. This is used with manual codepoint maps
/// to ensure that we don't load the same font multiple times.
///
/// Note that the current implementation will load the same font multiple
/// times if the font used for a codepoint map is identical to a font used
/// for a regular style. That's just an inefficient choice made now because
/// the implementation is simpler and codepoint maps matching a regular
/// font is a rare case.
const DescriptorCache = std.HashMapUnmanaged(
DiscoveryDescriptor,
?Collection.Index,
struct {
const KeyType = DiscoveryDescriptor;
pub fn hash(ctx: @This(), k: KeyType) u64 {
_ = ctx;
return k.hashcode();
}
pub fn eql(ctx: @This(), a: KeyType, b: KeyType) bool {
// Note that this means its possible to have two different
// descriptors match when there is a hash collision so we
// should button this up later.
return ctx.hash(a) == ctx.hash(b);
}
},
std.hash_map.default_max_load_percentage,
);
test getIndex {
const testing = std.testing;
const alloc = testing.allocator;
const testFont = @import("test.zig").fontRegular;
const testEmoji = @import("test.zig").fontEmoji;
const testEmojiText = @import("test.zig").fontEmojiText;
var lib = try Library.init();
defer lib.deinit();
var c = try Collection.init(alloc);
c.load_options = .{ .library = lib };
{
errdefer c.deinit(alloc);
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
if (comptime !font.options.backend.hasCoretext()) {
// Coretext doesn't support Noto's format
_ = try c.add(
alloc,
.regular,
.{ .loaded = try Face.init(
lib,
testEmoji,
.{ .size = .{ .points = 12 } },
) },
);
}
_ = try c.add(
alloc,
.regular,
.{ .loaded = try Face.init(
lib,
testEmojiText,
.{ .size = .{ .points = 12 } },
) },
);
}
var r: CodepointResolver = .{ .collection = c };
defer r.deinit(alloc);
// Should find all visible ASCII
var i: u32 = 32;
while (i < 127) : (i += 1) {
const idx = r.getIndex(alloc, i, .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx);
}
// Try emoji
{
const idx = r.getIndex(alloc, '🥸', .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(Collection.Index.IndexInt, 1), idx.idx);
}
// Try text emoji
{
const idx = r.getIndex(alloc, 0x270C, .regular, .text).?;
try testing.expectEqual(Style.regular, idx.style);
const text_idx = if (comptime font.options.backend.hasCoretext()) 1 else 2;
try testing.expectEqual(@as(Collection.Index.IndexInt, text_idx), idx.idx);
}
{
const idx = r.getIndex(alloc, 0x270C, .regular, .emoji).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(Collection.Index.IndexInt, 1), idx.idx);
}
// Box glyph should be null since we didn't set a box font
{
try testing.expect(r.getIndex(alloc, 0x1FB00, .regular, null) == null);
}
}
test "getIndex disabled font style" {
const testing = std.testing;
const alloc = testing.allocator;
const testFont = @import("test.zig").fontRegular;
var atlas_greyscale = try font.Atlas.init(alloc, 512, .greyscale);
defer atlas_greyscale.deinit(alloc);
var lib = try Library.init();
defer lib.deinit();
var c = try Collection.init(alloc);
c.load_options = .{ .library = lib };
_ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
_ = try c.add(alloc, .bold, .{ .loaded = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
_ = try c.add(alloc, .italic, .{ .loaded = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
var r: CodepointResolver = .{ .collection = c };
defer r.deinit(alloc);
r.styles.set(.bold, false); // Disable bold
// Regular should work fine
{
const idx = r.getIndex(alloc, 'A', .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx);
}
// Bold should go to regular
{
const idx = r.getIndex(alloc, 'A', .bold, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx);
}
// Italic should still work
{
const idx = r.getIndex(alloc, 'A', .italic, null).?;
try testing.expectEqual(Style.italic, idx.style);
try testing.expectEqual(@as(Collection.Index.IndexInt, 0), idx.idx);
}
}
test "getIndex box glyph" {
const testing = std.testing;
const alloc = testing.allocator;
var lib = try Library.init();
defer lib.deinit();
const c = try Collection.init(alloc);
var r: CodepointResolver = .{
.collection = c,
.sprite = .{ .width = 18, .height = 36, .thickness = 2 },
};
defer r.deinit(alloc);
// Should find a box glyph
const idx = r.getIndex(alloc, 0x2500, .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@intFromEnum(Collection.Index.Special.sprite), idx.idx);
}