diff --git a/pkg/freetype/face.zig b/pkg/freetype/face.zig index 2bd4a33c7..94c3e2931 100644 --- a/pkg/freetype/face.zig +++ b/pkg/freetype/face.zig @@ -1,7 +1,9 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const c = @import("c.zig"); const errors = @import("errors.zig"); const Library = @import("Library.zig"); +const Tag = @import("tag.zig").Tag; const Error = errors.Error; const intToError = errors.intToError; @@ -24,6 +26,12 @@ pub const Face = struct { return c.FT_HAS_COLOR(self.handle); } + /// A macro that returns true whenever a face object contains an ‘sbix’ + /// OpenType table and outline glyphs. + pub fn hasSBIX(self: Face) bool { + return c.FT_HAS_SBIX(self.handle); + } + /// A macro that returns true whenever a face object contains some /// multiple masters. pub fn hasMultipleMasters(self: Face) bool { @@ -102,6 +110,40 @@ pub const Face = struct { return if (intToError(res)) |_| name else |err| err; } + /// Load any SFNT font table into client memory. + pub fn loadSfntTable( + self: Face, + alloc: Allocator, + tag: Tag, + ) (Allocator.Error || Error)!?[]u8 { + const tag_c: c_ulong = @intCast(@as(u32, @bitCast(tag))); + + // Get the length of the table in bytes + var len: c_ulong = 0; + var res = c.FT_Load_Sfnt_Table(self.handle, tag_c, 0, null, &len); + _ = intToError(res) catch |err| return err; + + // If our length is zero we don't have a table. + if (len == 0) return null; + + // Allocate a buffer to hold the table and load it + const buf = try alloc.alloc(u8, len); + errdefer alloc.free(buf); + res = c.FT_Load_Sfnt_Table(self.handle, tag_c, 0, buf.ptr, &len); + _ = intToError(res) catch |err| return err; + + return buf; + } + + /// Check whether a given SFNT table is available in a face. + pub fn hasSfntTable(self: Face, tag: Tag) bool { + const tag_c: c_ulong = @intCast(@as(u32, @bitCast(tag))); + var len: c_ulong = 0; + const res = c.FT_Load_Sfnt_Table(self.handle, tag_c, 0, null, &len); + _ = intToError(res) catch return false; + return len != 0; + } + /// Retrieve the font variation descriptor for a font. pub fn getMMVar(self: Face) Error!*c.FT_MM_Var { var result: *c.FT_MM_Var = undefined; diff --git a/pkg/freetype/main.zig b/pkg/freetype/main.zig index 4adfeeaf4..bfa5e6bc1 100644 --- a/pkg/freetype/main.zig +++ b/pkg/freetype/main.zig @@ -4,6 +4,7 @@ pub const Library = @import("Library.zig"); pub usingnamespace @import("computations.zig"); pub usingnamespace @import("errors.zig"); pub usingnamespace @import("face.zig"); +pub usingnamespace @import("tag.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/pkg/freetype/tag.zig b/pkg/freetype/tag.zig new file mode 100644 index 000000000..32dab2a01 --- /dev/null +++ b/pkg/freetype/tag.zig @@ -0,0 +1,17 @@ +/// FT_Tag +pub const Tag = packed struct(u32) { + d: u8, + c: u8, + b: u8, + a: u8, + + pub fn init(v: *const [4]u8) Tag { + return .{ .a = v[0], .b = v[1], .c = v[2], .d = v[3] }; + } + + /// Converts the ID to a string. The return value is only valid + /// for the lifetime of the self pointer. + pub fn str(self: Tag) [4]u8 { + return .{ self.a, self.b, self.c, self.d }; + } +}; diff --git a/pkg/macos/foundation/base.zig b/pkg/macos/foundation/base.zig index bfd436115..24673ea2f 100644 --- a/pkg/macos/foundation/base.zig +++ b/pkg/macos/foundation/base.zig @@ -16,3 +16,20 @@ pub const Range = extern struct { return @bitCast(c.CFRangeMake(@intCast(loc), @intCast(len))); } }; + +pub const FourCharCode = packed struct(u32) { + d: u8, + c: u8, + b: u8, + a: u8, + + pub fn init(v: *const [4]u8) FourCharCode { + return .{ .a = v[0], .b = v[1], .c = v[2], .d = v[3] }; + } + + /// Converts the ID to a string. The return value is only valid + /// for the lifetime of the self pointer. + pub fn str(self: FourCharCode) [4]u8 { + return .{ self.a, self.b, self.c, self.d }; + } +}; diff --git a/pkg/macos/foundation/data.zig b/pkg/macos/foundation/data.zig index 7c9b7d6cb..cbac0db54 100644 --- a/pkg/macos/foundation/data.zig +++ b/pkg/macos/foundation/data.zig @@ -20,9 +20,13 @@ pub const Data = opaque { foundation.CFRelease(self); } - pub fn getPointer(self: *Data) *const anyopaque { + pub fn getPointer(self: *Data) [*]const u8 { return @ptrCast(c.CFDataGetBytePtr(@ptrCast(self))); } + + pub fn getLength(self: *Data) usize { + return @intCast(c.CFDataGetLength(@ptrCast(self))); + } }; test { diff --git a/pkg/macos/text/font.zig b/pkg/macos/text/font.zig index f4db72d6c..10f8c23ca 100644 --- a/pkg/macos/text/font.zig +++ b/pkg/macos/text/font.zig @@ -67,6 +67,14 @@ pub const Font = opaque { return @ptrCast(@constCast(c.CTFontCopyDefaultCascadeListForLanguages(@ptrCast(self), null))); } + pub fn copyTable(self: *Font, tag: FontTableTag) ?*foundation.Data { + return @constCast(@ptrCast(c.CTFontCopyTable( + @ptrCast(self), + @intFromEnum(tag), + c.kCTFontTableOptionNoOptions, + ))); + } + pub fn getGlyphCount(self: *Font) usize { return @intCast(c.CTFontGetGlyphCount(@ptrCast(self))); } @@ -195,6 +203,16 @@ pub const FontOrientation = enum(c_uint) { vertical = c.kCTFontOrientationVertical, }; +pub const FontTableTag = enum(u32) { + svg = c.kCTFontTableSVG, + _, + + pub fn init(v: *const [4]u8) FontTableTag { + const raw: u32 = @bitCast(foundation.FourCharCode.init(v)); + return @enumFromInt(raw); + } +}; + test { const testing = std.testing; diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index 543378c4f..e695e5a74 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -291,13 +291,17 @@ fn getIndexCodepointOverride( /// 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) !Presentation { +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 face.presentation; + return if (face.isColorGlyph(glyph_index)) .emoji else .text; } /// Render a glyph by glyph index into the given font atlas and return diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 38f4b92f8..86231b839 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -190,16 +190,23 @@ pub fn autoItalicize(self: *Collection, alloc: Allocator) !void { const list = self.faces.get(.regular); if (list.items.len == 0) return; - // Find our first font that is text. This will force - // loading any deferred faces but we only load them until - // we find a text face. A text face is almost always the - // first face in the list. + // Find our first regular face that has text glyphs. for (0..list.items.len) |i| { const face = try self.getFace(.{ .style = .regular, .idx = @intCast(i), }); - if (face.presentation == .text) break :regular face; + + // We have two conditionals here. The color check is obvious: + // we want to auto-italicize a normal text font. The second + // check is less obvious... for mixed color/non-color fonts, we + // accept the regular font if it has basic ASCII. This may not + // be strictly correct (especially with international fonts) but + // it's a reasonable heuristic and the first case will match 99% + // of the time. + if (!face.hasColor() or face.glyphIndex('A') != null) { + break :regular face; + } } // No regular text face found. @@ -344,7 +351,13 @@ pub const Entry = union(enum) { }, .loaded => |face| switch (p_mode) { - .explicit => |p| face.presentation == p and face.glyphIndex(cp) != null, + .explicit => |p| explicit: { + const index = face.glyphIndex(cp) orelse break :explicit false; + break :explicit switch (p) { + .text => !face.isColorGlyph(index), + .emoji => face.isColorGlyph(index), + }; + }, .default, .any => face.glyphIndex(cp) != null, }, @@ -357,7 +370,13 @@ pub const Entry = union(enum) { .fallback_loaded => |face| switch (p_mode) { .explicit, .default, - => |p| face.presentation == p and face.glyphIndex(cp) != null, + => |p| explicit: { + const index = face.glyphIndex(cp) orelse break :explicit false; + break :explicit switch (p) { + .text => !face.isColorGlyph(index), + .emoji => face.isColorGlyph(index), + }; + }, .any => face.glyphIndex(cp) != null, }, }; @@ -371,7 +390,7 @@ pub const PresentationMode = union(enum) { explicit: Presentation, /// The codepoint has no explicit presentation and we should use - /// the presentation from the UCd. + /// the presentation from the UCD. default: Presentation, /// The codepoint can be any presentation. diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index 8051895a4..fc7c81378 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -281,6 +281,12 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool { => { // If we are using coretext, we check the loaded CT font. if (self.ct) |ct| { + // This presentation check isn't as detailed as isColorGlyph + // because forced presentation modes are only used for emoji and + // emoji should always have color glyphs set. This can be + // more correct by using the isColorGlyph logic but I'd want + // to find a font that actualy requires this so we can write + // a test for it before changing it. if (p) |desired_p| { const traits = ct.font.getSymbolicTraits(); const actual_p: Presentation = if (traits.color_glyphs) .emoji else .text; diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 9c0f5ca9c..e99d39078 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -253,7 +253,7 @@ pub fn renderGlyph( if (gop.found_existing) return gop.value_ptr.*; // Get the presentation to determine what atlas to use - const p = try self.resolver.getPresentation(index); + const p = try self.resolver.getPresentation(index, glyph_index); const atlas: *font.Atlas = switch (p) { .text => &self.atlas_greyscale, .emoji => &self.atlas_color, diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 72c0a6d9e..6d07f1fa4 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator; const macos = @import("macos"); const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); +const opentype = @import("../opentype.zig"); const quirks = @import("../../quirks.zig"); const log = std.log.scoped(.font_face); @@ -17,15 +18,18 @@ pub const Face = struct { /// if we're using Harfbuzz. hb_font: if (harfbuzz_shaper) harfbuzz.Font else void, - /// The presentation for this font. - presentation: font.Presentation, - /// Metrics for this font face. These are useful for renderers. metrics: font.face.Metrics, /// Set quirks.disableDefaultFontFeatures quirks_disable_default_font_features: bool = false, + /// If the face can possibly be colored, then this is the state + /// used to check for color information. This is null if the font + /// can't possibly be colored (i.e. doesn't have SVG, sbix, etc + /// tables). + color: ?ColorState = null, + /// True if our build is using Harfbuzz. If we're not, we can avoid /// some Harfbuzz-specific code paths. const harfbuzz_shaper = font.options.backend.hasHarfbuzz(); @@ -94,28 +98,20 @@ pub const Face = struct { } else {}; errdefer if (comptime harfbuzz_shaper) hb_font.destroy(); + const color: ?ColorState = if (traits.color_glyphs) + try ColorState.init(ct_font) + else + null; + errdefer if (color) |v| v.deinit(); + var result: Face = .{ .font = ct_font, .hb_font = hb_font, - .presentation = if (traits.color_glyphs) .emoji else .text, .metrics = metrics, + .color = color, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); - // If our presentation is emoji, we also check for the presence of - // emoji codepoints. This forces fonts with colorized glyphs that aren't - // emoji font to be treated as text. Long term, this isn't what we want - // but this fixes some bugs in the short term. See: - // https://github.com/mitchellh/ghostty/issues/1768 - // - // Longer term, we'd like to detect mixed color/non-color fonts and - // handle them correctly by rendering the color glyphs as color and the - // non-color glyphs as text. - if (result.presentation == .emoji and result.glyphIndex('🥸') == null) { - log.warn("font has colorized glyphs but isn't emoji, treating as text", .{}); - result.presentation = .text; - } - // In debug mode, we output information about available variation axes, // if they exist. if (comptime builtin.mode == .Debug) { @@ -167,6 +163,7 @@ pub const Face = struct { pub fn deinit(self: *Face) void { self.font.release(); if (comptime harfbuzz_shaper) self.hb_font.destroy(); + if (self.color) |v| v.deinit(); self.* = undefined; } @@ -226,6 +223,19 @@ pub const Face = struct { self.* = face; } + /// Returns true if the face has any glyphs that are colorized. + /// To determine if an individual glyph is colorized you must use + /// isColorGlyph. + pub fn hasColor(self: *const Face) bool { + return self.color != null; + } + + /// Returns true if the given glyph ID is colorized. + pub fn isColorGlyph(self: *const Face, glyph_id: u32) bool { + const c = self.color orelse return false; + return c.isColorGlyph(glyph_id); + } + /// Returns the glyph index for the given Unicode code point. If this /// face doesn't support this glyph, null is returned. pub fn glyphIndex(self: Face, cp: u32) ?u32 { @@ -296,7 +306,7 @@ pub const Face = struct { depth: u32, space: *macos.graphics.ColorSpace, context_opts: c_uint, - } = if (self.presentation == .text) .{ + } = if (!self.isColorGlyph(glyph_index)) .{ .color = false, .depth = 1, .space = try macos.graphics.ColorSpace.createDeviceGray(), @@ -570,6 +580,91 @@ pub const Face = struct { return result; } + + /// Copy the font table data for the given tag. + pub fn copyTable(self: Face, alloc: Allocator, tag: *const [4]u8) !?[]u8 { + const data = self.font.copyTable(macos.text.FontTableTag.init(tag)) orelse + return null; + defer data.release(); + + const buf = try alloc.alloc(u8, data.getLength()); + errdefer alloc.free(buf); + + const ptr = data.getPointer(); + @memcpy(buf, ptr[0..buf.len]); + + return buf; + } +}; + +/// The state associated with a font face that may have colorized glyphs. +/// This is used to determine if a specific glyph ID is colorized. +const ColorState = struct { + /// True if there is an sbix font table. For now, the mere presence + /// of an sbix font table causes us to assume the glyph is colored. + /// We can improve this later. + sbix: bool, + + /// The SVG font table data (if any), which we can use to determine + /// if a glyph is present in the SVG table. + svg: ?opentype.SVG, + svg_data: ?*macos.foundation.Data, + + pub fn init(f: *macos.text.Font) !ColorState { + // sbix is true if the table exists in the font data at all. + // In the future we probably want to actually parse it and + // check for glyphs. + const sbix: bool = sbix: { + const tag = macos.text.FontTableTag.init("sbix"); + const data = f.copyTable(tag) orelse break :sbix false; + data.release(); + break :sbix data.getLength() > 0; + }; + + // Read the SVG table out of the font data. + const svg: ?struct { + svg: opentype.SVG, + data: *macos.foundation.Data, + } = svg: { + const tag = macos.text.FontTableTag.init("SVG "); + const data = f.copyTable(tag) orelse break :svg null; + errdefer data.release(); + const ptr = data.getPointer(); + const len = data.getLength(); + break :svg .{ + .svg = try opentype.SVG.init(ptr[0..len]), + .data = data, + }; + }; + + return .{ + .sbix = sbix, + .svg = if (svg) |v| v.svg else null, + .svg_data = if (svg) |v| v.data else null, + }; + } + + pub fn deinit(self: *const ColorState) void { + if (self.svg_data) |v| v.release(); + } + + /// Returns true if the given glyph ID is colored. + pub fn isColorGlyph(self: *const ColorState, glyph_id: u32) bool { + // Our font system uses 32-bit glyph IDs for special values but + // actual fonts only contain 16-bit glyph IDs so if we can't cast + // into it it must be false. + const glyph_u16 = std.math.cast(u16, glyph_id) orelse return false; + + // sbix is always true for now + if (self.sbix) return true; + + // if we have svg data, check it + if (self.svg) |svg| { + if (svg.hasGlyph(glyph_u16)) return true; + } + + return false; + } }; test { @@ -589,8 +684,6 @@ test { var face = try Face.initFontCopy(ct_font, .{ .size = .{ .points = 12 } }); defer face.deinit(); - try testing.expectEqual(font.Presentation.text, face.presentation); - // Generate all visible ASCII var i: u8 = 32; while (i < 127) : (i += 1) { @@ -612,8 +705,6 @@ test "name" { var face = try Face.initFontCopy(ct_font, .{ .size = .{ .points = 12 } }); defer face.deinit(); - try testing.expectEqual(font.Presentation.text, face.presentation); - var buf: [1024]u8 = undefined; const font_name = try face.name(&buf); try testing.expect(std.mem.eql(u8, font_name, "Menlo")); @@ -632,11 +723,11 @@ test "emoji" { var face = try Face.initFontCopy(ct_font, .{ .size = .{ .points = 18 } }); defer face.deinit(); - // Presentation - try testing.expectEqual(font.Presentation.emoji, face.presentation); - // Glyph index check - try testing.expect(face.glyphIndex('🥸') != null); + { + const id = face.glyphIndex('🥸').?; + try testing.expect(face.isColorGlyph(id)); + } } test "in-memory" { @@ -653,8 +744,6 @@ test "in-memory" { var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); defer face.deinit(); - try testing.expectEqual(font.Presentation.text, face.presentation); - // Generate all visible ASCII var i: u8 = 32; while (i < 127) : (i += 1) { @@ -677,8 +766,6 @@ test "variable" { var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); defer face.deinit(); - try testing.expectEqual(font.Presentation.text, face.presentation); - // Generate all visible ASCII var i: u8 = 32; while (i < 127) : (i += 1) { @@ -701,8 +788,6 @@ test "variable set variation" { var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); defer face.deinit(); - try testing.expectEqual(font.Presentation.text, face.presentation); - try face.setVariations(&.{ .{ .id = font.face.Variation.Id.init("wght"), .value = 400 }, }, .{ .size = .{ .points = 12 } }); @@ -715,7 +800,24 @@ test "variable set variation" { } } -test "mixed color/non-color font treated as text" { +test "svg font table" { + const testing = std.testing; + const alloc = testing.allocator; + const testFont = @import("../test.zig").fontJuliaMono; + + var lib = try font.Library.init(); + defer lib.deinit(); + + var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + defer face.deinit(); + + const table = (try face.copyTable(alloc, "SVG ")).?; + defer alloc.free(table); + + try testing.expect(table.len > 0); +} + +test "glyphIndex colored vs text" { const testing = std.testing; const testFont = @import("../test.zig").fontJuliaMono; @@ -725,5 +827,15 @@ test "mixed color/non-color font treated as text" { var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); defer face.deinit(); - try testing.expect(face.presentation == .text); + { + const glyph = face.glyphIndex('A').?; + try testing.expectEqual(4, glyph); + try testing.expect(!face.isColorGlyph(glyph)); + } + + { + const glyph = face.glyphIndex(0xE800).?; + try testing.expectEqual(11482, glyph); + try testing.expect(face.isColorGlyph(glyph)); + } } diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 858f14fca..346d8aa8d 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -15,7 +15,6 @@ const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const Glyph = font.Glyph; const Library = font.Library; -const Presentation = font.Presentation; const convert = @import("freetype_convert.zig"); const fastmem = @import("../../fastmem.zig"); const quirks = @import("../../quirks.zig"); @@ -32,10 +31,6 @@ pub const Face = struct { /// Harfbuzz font corresponding to this face. hb_font: harfbuzz.Font, - /// The presentation for this font. This is a heuristic since fonts don't have - /// a way to declare this. We just assume a font with color is an emoji font. - presentation: Presentation, - /// Metrics for this font face. These are useful for renderers. metrics: font.face.Metrics, @@ -67,17 +62,10 @@ pub const Face = struct { .lib = lib.lib, .face = face, .hb_font = hb_font, - .presentation = if (face.hasColor()) .emoji else .text, .metrics = calcMetrics(face, opts.metric_modifiers), }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); - // See coretext.zig which has a similar check for this. - if (result.presentation == .emoji and result.glyphIndex('🥸') == null) { - log.warn("font has colorized glyphs but isn't emoji, treating as text", .{}); - result.presentation = .text; - } - // In debug mode, we output information about available variation axes, // if they exist. if (comptime builtin.mode == .Debug) mm: { @@ -219,10 +207,34 @@ pub const Face = struct { /// Returns true if this font is colored. This can be used by callers to /// determine what kind of atlas to pass in. - fn hasColor(self: Face) bool { + pub fn hasColor(self: Face) bool { return self.face.hasColor(); } + /// Returns true if the given glyph ID is colorized. + pub fn isColorGlyph(self: *const Face, glyph_id: u32) bool { + // sbix table is always true for now + if (self.face.hasSBIX()) return true; + + // CBDT/CBLC tables always imply colorized glyphs. + // These are used by Noto. + if (self.face.hasSfntTable(freetype.Tag.init("CBDT"))) return true; + if (self.face.hasSfntTable(freetype.Tag.init("CBLC"))) return true; + + // Otherwise, load the glyph and see what format it is in. + self.face.loadGlyph(glyph_id, .{ + .render = true, + .color = self.face.hasColor(), + .no_bitmap = !self.face.hasColor(), + }) catch return false; + + // If the glyph is SVG we assume colorized + const glyph = self.face.handle.*.glyph; + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_SVG) return true; + + return false; + } + /// Render a glyph using the glyph index. The rendered glyph is stored in the /// given texture atlas. pub fn renderGlyph( @@ -631,6 +643,11 @@ pub const Face = struct { const div = @as(f32, @floatFromInt(mul)) / 64; return @ceil(div); } + + /// Copy the font table data for the given tag. + pub fn copyTable(self: Face, alloc: Allocator, tag: *const [4]u8) !?[]u8 { + return try self.face.loadSfntTable(alloc, freetype.Tag.init(tag)); + } }; test { @@ -650,8 +667,6 @@ test { ); defer ft_font.deinit(); - try testing.expectEqual(Presentation.text, ft_font.presentation); - // Generate all visible ASCII var i: u8 = 32; while (i < 127) : (i += 1) { @@ -686,10 +701,15 @@ test "color emoji" { ); defer ft_font.deinit(); - try testing.expectEqual(Presentation.emoji, ft_font.presentation); - _ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, .{}); + // Make sure this glyph has color + { + try testing.expect(ft_font.hasColor()); + const glyph_id = ft_font.glyphIndex('🥸').?; + try testing.expect(ft_font.isColorGlyph(glyph_id)); + } + // resize { const glyph = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, .{ @@ -763,3 +783,19 @@ test "mono to rgba" { // glyph 3 is mono in Noto _ = try ft_font.renderGlyph(alloc, &atlas, 3, .{}); } + +test "svg font table" { + const alloc = testing.allocator; + const testFont = @import("../test.zig").fontJuliaMono; + + var lib = try font.Library.init(); + defer lib.deinit(); + + var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + defer face.deinit(); + + const table = (try face.copyTable(alloc, "SVG ")).?; + defer alloc.free(table); + + try testing.expectEqual(430, table.len); +} diff --git a/src/font/main.zig b/src/font/main.zig index a287d9a06..cbcb696d2 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -12,6 +12,7 @@ pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); pub const Metrics = face.Metrics; +pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); pub const Shaper = shape.Shaper; pub const ShaperCache = shape.Cache; @@ -159,7 +160,7 @@ pub const Style = enum(u3) { bold_italic = 3, }; -/// The presentation for a an emoji. +/// The presentation for an emoji. pub const Presentation = enum(u1) { text = 0, // U+FE0E emoji = 1, // U+FEOF diff --git a/src/font/opentype.zig b/src/font/opentype.zig new file mode 100644 index 000000000..798df5b2c --- /dev/null +++ b/src/font/opentype.zig @@ -0,0 +1,7 @@ +const svg = @import("opentype/svg.zig"); + +pub const SVG = svg.SVG; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/font/opentype/svg.zig b/src/font/opentype/svg.zig new file mode 100644 index 000000000..985e58bec --- /dev/null +++ b/src/font/opentype/svg.zig @@ -0,0 +1,113 @@ +const std = @import("std"); +const assert = std.debug.assert; +const font = @import("../main.zig"); + +/// SVG glyphs description table. +/// +/// This struct is focused purely on the operations we need for Ghostty, +/// namely to be able to look up whether an glyph ID is present in the SVG +/// table or not. This struct isn't meant to be a general purpose SVG table +/// reader. +/// +/// References: +/// - https://www.w3.org/2013/10/SVG_in_OpenType/#thesvg +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/svg +pub const SVG = struct { + /// The start and end glyph IDs (inclusive) that are present in the + /// table. This is used to very quickly include/exclude a glyph from + /// the table. + start_glyph_id: u16, + end_glyph_id: u16, + + /// All records in the table. + records: []const [12]u8, + + pub fn init(data: []const u8) !SVG { + var fbs = std.io.fixedBufferStream(data); + const reader = fbs.reader(); + + // Version + if (try reader.readInt(u16, .big) != 0) { + return error.SVGVersionNotSupported; + } + + // Offset + const offset = try reader.readInt(u32, .big); + + // Seek to the offset to get our document list + try fbs.seekTo(offset); + + // Get our document records along with the start/end glyph range. + const len = try reader.readInt(u16, .big); + const records: [*]const [12]u8 = @ptrCast(data[try fbs.getPos()..]); + const start_range = try glyphRange(&records[0]); + const end_range = if (len == 1) start_range else try glyphRange(&records[(len - 1)]); + + return .{ + .start_glyph_id = start_range[0], + .end_glyph_id = end_range[1], + .records = records[0..len], + }; + } + + pub fn hasGlyph(self: SVG, glyph_id: u16) bool { + // Fast path: outside the table range + if (glyph_id < self.start_glyph_id or glyph_id > self.end_glyph_id) { + return false; + } + + // Fast path, matches the start/end glyph IDs + if (glyph_id == self.start_glyph_id or glyph_id == self.end_glyph_id) { + return true; + } + + // Slow path: binary search our records + return std.sort.binarySearch( + [12]u8, + glyph_id, + self.records, + {}, + compareGlyphId, + ) != null; + } + + fn compareGlyphId(_: void, glyph_id: u16, record: [12]u8) std.math.Order { + const start, const end = glyphRange(&record) catch return .lt; + if (glyph_id < start) { + return .lt; + } else if (glyph_id > end) { + return .gt; + } else { + return .eq; + } + } + + fn glyphRange(record: []const u8) !struct { u16, u16 } { + var fbs = std.io.fixedBufferStream(record); + const reader = fbs.reader(); + return .{ + try reader.readInt(u16, .big), + try reader.readInt(u16, .big), + }; + } +}; + +test "SVG" { + const testing = std.testing; + const alloc = testing.allocator; + const testFont = @import("../test.zig").fontJuliaMono; + + var lib = try font.Library.init(); + defer lib.deinit(); + + var face = try font.Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + defer face.deinit(); + + const table = (try face.copyTable(alloc, "SVG ")).?; + defer alloc.free(table); + + const svg = try SVG.init(table); + try testing.expectEqual(11482, svg.start_glyph_id); + try testing.expectEqual(11482, svg.end_glyph_id); + try testing.expect(svg.hasGlyph(11482)); +}