From 87c3de78f5365cc5b273f6097a11e19aec26a8f6 Mon Sep 17 00:00:00 2001 From: Stephen Gutekanst Date: Tue, 3 Oct 2023 04:33:26 -0700 Subject: [PATCH] gfx: font: begin adding text shaping via harfbuzz Helps hexops/mach#877 Signed-off-by: Stephen Gutekanst --- build.zig | 6 + build.zig.zon | 4 + src/gfx/font/main.zig | 204 ++++++++++++++++++++++++++++++++ src/gfx/font/native/Font.zig | 57 +++++++++ src/gfx/font/native/TextRun.zig | 48 ++++++++ src/gfx/main.zig | 22 ++-- src/main.zig | 1 - 7 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 src/gfx/font/main.zig create mode 100644 src/gfx/font/native/Font.zig create mode 100644 src/gfx/font/native/TextRun.zig diff --git a/build.zig b/build.zig index 83b70e47..f038af1b 100644 --- a/build.zig +++ b/build.zig @@ -34,6 +34,7 @@ pub fn build(b: *std.Build) !void { .target = target, .optimize = optimize, }); + const font_assets_dep = b.dependency("font_assets", .{}); const module = b.addModule("mach", .{ .source_file = .{ .path = sdkPath("/src/main.zig") }, @@ -45,6 +46,7 @@ pub fn build(b: *std.Build) !void { .{ .name = "mach-freetype", .module = mach_freetype_dep.module("mach-freetype") }, .{ .name = "mach-harfbuzz", .module = mach_freetype_dep.module("mach-harfbuzz") }, .{ .name = "mach-sysjs", .module = mach_sysjs_dep.module("mach-sysjs") }, + .{ .name = "font-assets", .module = font_assets_dep.module("font-assets") }, }, }); @@ -61,6 +63,10 @@ pub fn build(b: *std.Build) !void { unit_tests.addModule(e.key_ptr.*, e.value_ptr.*); } + // TODO: move link into a helper function shared between tests in App + @import("mach_freetype").linkFreetype(mach_freetype_dep.builder, unit_tests); + @import("mach_freetype").linkHarfbuzz(mach_freetype_dep.builder, unit_tests); + // Exposes a `test` step to the `zig build --help` menu, providing a way for the user to // request running the unit tests. const run_unit_tests = b.addRunArtifact(unit_tests); diff --git a/build.zig.zon b/build.zig.zon index 1f7552c3..c3c411e9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -30,5 +30,9 @@ .url = "https://pkg.machengine.org/mach-sysjs/bafd6c9b8fd5e7be1e8e24cfbd156703d6026aa8.tar.gz", .hash = "1220187935c4c5d4cf824927df28e858dcd06cb864bb5d6be4cd349d2836abb4aec4", }, + .font_assets = .{ + .url = "https://github.com/hexops/font-assets/archive/7b44dbabe209d574bb9400b6b31737d072afe9f8.tar.gz", + .hash = "122022d40902e7a5776995b5c46625a38617f4dc2eb30755df979f64f7a429bae5db", + }, }, } diff --git a/src/gfx/font/main.zig b/src/gfx/font/main.zig new file mode 100644 index 00000000..9a29470d --- /dev/null +++ b/src/gfx/font/main.zig @@ -0,0 +1,204 @@ +const mach = @import("../../main.zig"); +const testing = mach.testing; +const math = mach.math; +const vec2 = math.vec2; +const Vec2 = math.Vec2; + +pub const Font = FontInterface(if (@import("builtin").cpu.arch == .wasm32) { + @panic("TODO: implement wasm/Font.zig"); +} else @import("native/Font.zig")); + +pub const TextRun = TextRunInterface(if (@import("builtin").cpu.arch == .wasm32) +{ + @panic("TODO: implement wasm/TextRun.zig"); +} else @import("native/TextRun.zig")); + +fn FontInterface(comptime T: type) type { + assertDecl(T, "initBytes", fn (font_bytes: []const u8) anyerror!T); + assertDecl(T, "shape", fn (f: *const T, r: *TextRun) anyerror!void); + assertDecl(T, "deinit", fn (*const T) void); + return T; +} + +fn TextRunInterface(comptime T: type) type { + assertField(T, "font_size_px", u32); + assertField(T, "px_density", u8); + assertDecl(T, "init", fn () anyerror!T); + assertDecl(T, "addText", fn (s: *const T, []const u8) void); + assertDecl(T, "next", fn (s: *T) ?Glyph); + assertDecl(T, "deinit", fn (s: *const T) void); + return T; +} + +fn assertDecl(comptime T: anytype, comptime name: []const u8, comptime Decl: type) void { + if (!@hasDecl(T, name)) @compileError("Interface missing declaration: " ++ name ++ @typeName(Decl)); + const Found = @TypeOf(@field(T, name)); + if (Found != Decl) @compileError("Interface decl '" ++ name ++ "'\n\texpected type: " ++ @typeName(Decl) ++ "\n\t found type: " ++ @typeName(Found)); +} + +fn assertField(comptime T: anytype, comptime name: []const u8, comptime Field: type) void { + if (!@hasField(T, name)) @compileError("Interface missing field: ." ++ name ++ @typeName(Field)); + const Found = @TypeOf(@field(@as(T, undefined), name)); + if (Found != Field) @compileError("Interface field '" ++ name ++ "'\n\texpected type: " ++ @typeName(Field) ++ "\n\t found type: " ++ @typeName(Found)); +} + +/// The number of pixels per point, e.g. a 12pt font em box size multiplied by this number tells a +/// the em box size is 16px. +pub const px_per_pt = 4.0 / 3.0; + +pub const Glyph = struct { + glyph_index: u21, + cluster: u32, + advance: Vec2, + offset: Vec2, + + // TODO: https://github.com/hexops/mach/issues/1048 + // TODO: https://github.com/hexops/mach/issues/1049 + // + // pub fn eql(a: Glyph, b: Glyph) bool { + // if (a.glyph_index != b.glyph_index) return false; + // if (a.cluster != b.cluster) return false; + // // TODO: add Vec2.eql method + // if (a.advance.v[0] != b.advance.v[0] or a.advance.v[1] != b.advance.v[1]) return false; + // if (a.offset.v[0] != b.offset.v[0] or a.offset.v[1] != b.offset.v[1]) return false; + // return true; + // } +}; + +test { + const std = @import("std"); + std.testing.refAllDeclsRecursive(@This()); + + // Load a font + const font_bytes = @import("font-assets").fira_sans_regular_ttf; + const font = try Font.initBytes(font_bytes); + defer font.deinit(); + + // Create a text shaper + var run = try TextRun.init(); + run.font_size_px = 12 * px_per_pt; + run.px_density = 1; + + defer run.deinit(); + + const text = "h👩‍🚀️ello world!"; + run.addText(text); + try font.shape(&run); + + // TODO: https://github.com/hexops/mach/issues/1048 + // TODO: https://github.com/hexops/mach/issues/1049 + // + // try testing.expect(Glyph, .{ + // .glyph_index = 176, + // .cluster = 0, + // .advance = vec2(7.03, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 0, + // .cluster = 1, + // .advance = vec2(7.99, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 3, + // .cluster = 1, + // .advance = vec2(0.00, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 0, + // .cluster = 1, + // .advance = vec2(7.99, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 3, + // .cluster = 1, + // .advance = vec2(0.00, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 160, + // .cluster = 15, + // .advance = vec2(6.60, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 197, + // .cluster = 16, + // .advance = vec2(3.52, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 197, + // .cluster = 17, + // .advance = vec2(3.39, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 211, + // .cluster = 18, + // .advance = vec2(7.01, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 3, + // .cluster = 19, + // .advance = vec2(3.18, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 254, + // .cluster = 20, + // .advance = vec2(8.48, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 211, + // .cluster = 21, + // .advance = vec2(7.01, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 226, + // .cluster = 22, + // .advance = vec2(4.63, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 197, + // .cluster = 23, + // .advance = vec2(3.39, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 156, + // .cluster = 24, + // .advance = vec2(7.18, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + // try testing.expect(Glyph, .{ + // .glyph_index = 988, + // .cluster = 25, + // .advance = vec2(2.89, 0.00), + // .offset = vec2(0.00, 0.00), + // }).eql(run.next().?); + + // var cluster_start: usize = 0; + // while (run.next()) |glyph| { + // if (glyph.cluster != cluster_start) { + // const str = text[cluster_start..glyph.cluster]; + // cluster_start = glyph.cluster; + // std.debug.print("^ string: '{s}' (hex: {s})\n", .{ str, std.fmt.fmtSliceHexUpper(str) }); + // } + // std.debug.print(".{{ .glyph_index={}, .cluster={}, .advance=vec2({d:.2},{d:.2}), .offset=vec2({d:.2},{d:.2}), }}\n", .{ + // glyph.glyph_index, + // glyph.cluster, + // glyph.advance.x(), + // glyph.advance.y(), + // glyph.offset.x(), + // glyph.offset.y(), + // }); + // } +} diff --git a/src/gfx/font/native/Font.zig b/src/gfx/font/native/Font.zig new file mode 100644 index 00000000..f2ff714e --- /dev/null +++ b/src/gfx/font/native/Font.zig @@ -0,0 +1,57 @@ +const std = @import("std"); +const ft = @import("mach-freetype"); +const harfbuzz = @import("mach-harfbuzz"); +const TextRun = @import("TextRun.zig"); +const px_per_pt = @import("../main.zig").px_per_pt; + +const Font = @This(); + +var freetype_ready_mu: std.Thread.Mutex = .{}; +var freetype_ready: bool = false; +var freetype: ft.Library = undefined; + +face: ft.Face, + +pub fn initFreetype() !void { + freetype_ready_mu.lock(); + defer freetype_ready_mu.unlock(); + if (!freetype_ready) { + freetype = try ft.Library.init(); + freetype_ready = true; + } +} + +pub fn initBytes(font_bytes: []const u8) anyerror!Font { + try initFreetype(); + return .{ + .face = try freetype.createFaceMemory(font_bytes, 0), + }; +} + +pub fn shape(f: *const Font, r: *TextRun) anyerror!void { + // Guess text segment properties. + r.buffer.guessSegmentProps(); + // TODO: Optionally override specific text segment properties? + // buffer.setDirection(.ltr); + // buffer.setScript(.latin); + // buffer.setLanguage(harfbuzz.Language.fromString("en")); + + const hb_face = harfbuzz.Face.fromFreetypeFace(f.face); + const hb_font = harfbuzz.Font.init(hb_face); + defer hb_font.deinit(); + + const font_size_pt = @as(f32, @floatFromInt(r.font_size_px)) / px_per_pt; + hb_font.setScale(@as(i32, @intFromFloat(font_size_pt)) * 256, @as(i32, @intFromFloat(font_size_pt)) * 256); + hb_font.setPTEM(font_size_pt); + + // TODO: optionally pass shaping features? + hb_font.shape(r.buffer, null); + + r.index = 0; + r.infos = r.buffer.getGlyphInfos(); + r.positions = r.buffer.getGlyphPositions() orelse return error.OutOfMemory; +} + +pub fn deinit(f: *const Font) void { + f.face.deinit(); +} diff --git a/src/gfx/font/native/TextRun.zig b/src/gfx/font/native/TextRun.zig new file mode 100644 index 00000000..94bfe523 --- /dev/null +++ b/src/gfx/font/native/TextRun.zig @@ -0,0 +1,48 @@ +const std = @import("std"); +const harfbuzz = @import("mach-harfbuzz"); +const math = @import("../../../main.zig").math; +const vec2 = math.vec2; +const Vec2 = math.Vec2; +const Glyph = @import("../main.zig").Glyph; + +const TextRun = @This(); + +font_size_px: u32 = 16.0, +px_density: u8 = 1, + +// Internal / private fields. +buffer: harfbuzz.Buffer, +index: usize = 0, +infos: []harfbuzz.GlyphInfo = undefined, +positions: []harfbuzz.GlyphPosition = undefined, + +pub fn init() anyerror!TextRun { + return TextRun{ + .buffer = harfbuzz.Buffer.init() orelse return error.OutOfMemory, + }; +} + +pub fn addText(s: *const TextRun, utf8_text: []const u8) void { + s.buffer.addUTF8(utf8_text, 0, null); +} + +pub fn next(s: *TextRun) ?Glyph { + if (s.index >= s.infos.len) return null; + const info = s.infos[s.index]; + const pos = s.positions[s.index]; + s.index += 1; + return Glyph{ + .glyph_index = @intCast(info.codepoint), + // TODO: should we expose this? Is there a browser equivalent? do we need it? + // .var1 = @intCast(info.var1), + // .var2 = @intCast(info.var2), + .cluster = info.cluster, + .advance = vec2(@floatFromInt(pos.x_advance), @floatFromInt(pos.y_advance)).div(&Vec2.splat(256.0)), + .offset = vec2(@floatFromInt(pos.x_offset), @floatFromInt(pos.y_offset)).div(&Vec2.splat(256.0)), + }; +} + +pub fn deinit(s: *const TextRun) void { + s.buffer.deinit(); + return; +} diff --git a/src/gfx/main.zig b/src/gfx/main.zig index aaea9f23..1782f042 100644 --- a/src/gfx/main.zig +++ b/src/gfx/main.zig @@ -1,11 +1,17 @@ -pub const util = @import("util.zig"); +pub const util = @import("util.zig"); // TODO: banish 2-level deep namespaces pub const Sprite = @import("Sprite.zig"); pub const Atlas = @import("atlas/Atlas.zig"); pub const Text = @import("Text.zig"); -pub const FontRenderer = @import("font.zig").FontRenderer; -pub const RGBA32 = @import("font.zig").RGBA32; -pub const Glyph = @import("font.zig").Glyph; -pub const GlyphMetrics = @import("font.zig").GlyphMetrics; + +// TODO: integrate font rendering +// pub const RGBA32 = @import("font.zig").RGBA32; +// pub const FontRenderer = @import("font.zig").FontRenderer; +// pub const Glyph = @import("font.zig").Glyph; +// pub const GlyphMetrics = @import("font.zig").GlyphMetrics; + +pub const Font = @import("font/main.zig").Font; +pub const TextRun = @import("font/main.zig").TextRun; +pub const Glyph = @import("font/main.zig").Glyph; test { const std = @import("std"); @@ -13,9 +19,9 @@ test { // std.testing.refAllDeclsRecursive(@This()); std.testing.refAllDeclsRecursive(util); // std.testing.refAllDeclsRecursive(Sprite); + std.testing.refAllDeclsRecursive(Atlas); // std.testing.refAllDeclsRecursive(Text); - std.testing.refAllDeclsRecursive(FontRenderer); - std.testing.refAllDeclsRecursive(RGBA32); + std.testing.refAllDeclsRecursive(Font); + std.testing.refAllDeclsRecursive(TextRun); std.testing.refAllDeclsRecursive(Glyph); - std.testing.refAllDeclsRecursive(GlyphMetrics); } diff --git a/src/main.zig b/src/main.zig index f5b2f3a5..5ee21448 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,6 +30,5 @@ test { _ = gfx; _ = math; _ = testing; - std.testing.refAllDeclsRecursive(Atlas); std.testing.refAllDeclsRecursive(math); }