From ae0f62c3bf75aa13c1916c9d1f37092869765dc8 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 19:20:06 +0200 Subject: [PATCH] feat(gui): M3 - TrueType rasterizer with fontconfig font discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the M2 stub rasterizer with a real font rasterizer: - build.zig.zon: add TrueType as a lazy path dependency - build.zig: swap stub_rasterizer_mod for truetype_rasterizer_mod; link fontconfig + libc on Linux; TrueType dep is lazy (falls back gracefully) - src/gui/rasterizer/truetype.zig: andrewrk/TrueType rasterizer; loadFont uses fontconfig to locate the font file, derives cell dimensions from vertical metrics (ascent−descent) and 'M' advance width; render blits the A8 glyph bitmap into the caller-provided staging buffer with correct baseline placement (ascent_px + off_y) and double-wide support (+cell_w x-offset for kind=.right); arena allocator per render call - src/gui/rasterizer/font_finder.zig: OS dispatcher (Linux only for now) - src/gui/rasterizer/font_finder/linux.zig: fontconfig C API - FcFontMatch resolves a font name pattern to an absolute file path Requires: libfontconfig-dev (already present alongside libgl-dev etc.) Co-Authored-By: Claude Sonnet 4.6 --- build.zig | 17 +++- build.zig.zon | 4 + src/gui/rasterizer/font_finder.zig | 9 ++ src/gui/rasterizer/font_finder/linux.zig | 28 ++++++ src/gui/rasterizer/truetype.zig | 118 +++++++++++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/gui/rasterizer/font_finder.zig create mode 100644 src/gui/rasterizer/font_finder/linux.zig create mode 100644 src/gui/rasterizer/truetype.zig diff --git a/build.zig b/build.zig index f8df2a99..65c97662 100644 --- a/build.zig +++ b/build.zig @@ -554,18 +554,29 @@ pub fn build_exe( const gui_glyph_cache_mod = b.createModule(.{ .root_source_file = b.path("src/gui/GlyphIndexCache.zig") }); const gui_xterm_mod = b.createModule(.{ .root_source_file = b.path("src/gui/xterm.zig") }); - const stub_rasterizer_mod = b.createModule(.{ - .root_source_file = b.path("src/gui/rasterizer/stub.zig"), + const tt_dep = b.lazyDependency("TrueType", .{ + .target = target, + .optimize = optimize_deps, + }) orelse break :blk tui_renderer_mod; + + const truetype_rasterizer_mod = b.createModule(.{ + .root_source_file = b.path("src/gui/rasterizer/truetype.zig"), + .target = target, .imports = &.{ + .{ .name = "TrueType", .module = tt_dep.module("TrueType") }, .{ .name = "xy", .module = gui_xy_mod }, }, }); + if (target.result.os.tag == .linux) { + truetype_rasterizer_mod.linkSystemLibrary("fontconfig", .{}); + truetype_rasterizer_mod.link_libc = true; + } const gpu_mod = b.createModule(.{ .root_source_file = b.path("src/gui/gpu/gpu.zig"), .imports = &.{ .{ .name = "sokol", .module = sokol_mod }, - .{ .name = "rasterizer", .module = stub_rasterizer_mod }, + .{ .name = "rasterizer", .module = truetype_rasterizer_mod }, .{ .name = "xy", .module = gui_xy_mod }, .{ .name = "Cell", .module = gui_cell_mod }, .{ .name = "GlyphIndexCache", .module = gui_glyph_cache_mod }, diff --git a/build.zig.zon b/build.zig.zon index c044a80e..7c33ca3a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -50,6 +50,10 @@ .path = "../flow-deps/sokol-zig", .lazy = true, }, + .TrueType = .{ + .path = "../flow-deps/TrueType", + .lazy = true, + }, .diffz = .{ .url = "git+https://github.com/ziglibs/diffz.git#fbdf690b87db6b1142bbce6d4906f90b09ce60bb", .hash = "diffz-0.0.1-G2tlIezMAQBwGNGDs7Hn_N25dWSjEzgR_FAx9GFAvCuZ", diff --git a/src/gui/rasterizer/font_finder.zig b/src/gui/rasterizer/font_finder.zig new file mode 100644 index 00000000..4bb42041 --- /dev/null +++ b/src/gui/rasterizer/font_finder.zig @@ -0,0 +1,9 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub fn findFont(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + return switch (builtin.os.tag) { + .linux => @import("font_finder/linux.zig").find(allocator, name), + else => error.FontFinderNotSupported, + }; +} diff --git a/src/gui/rasterizer/font_finder/linux.zig b/src/gui/rasterizer/font_finder/linux.zig new file mode 100644 index 00000000..ffa824ee --- /dev/null +++ b/src/gui/rasterizer/font_finder/linux.zig @@ -0,0 +1,28 @@ +const std = @import("std"); +const fc = @cImport({ + @cInclude("fontconfig/fontconfig.h"); +}); + +pub fn find(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + const config = fc.FcInitLoadConfigAndFonts() orelse return error.FontconfigInit; + defer fc.FcConfigDestroy(config); + + const name_z = try allocator.dupeZ(u8, name); + defer allocator.free(name_z); + + const pat = fc.FcNameParse(name_z.ptr) orelse return error.FontPatternParse; + defer fc.FcPatternDestroy(pat); + + _ = fc.FcConfigSubstitute(config, pat, fc.FcMatchPattern); + fc.FcDefaultSubstitute(pat); + + var result: fc.FcResult = undefined; + const font = fc.FcFontMatch(config, pat, &result) orelse return error.FontNotFound; + defer fc.FcPatternDestroy(font); + + var file: [*c]fc.FcChar8 = undefined; + if (fc.FcPatternGetString(font, fc.FC_FILE, 0, &file) != fc.FcResultMatch) + return error.FontPathNotFound; + + return allocator.dupe(u8, std.mem.sliceTo(file, 0)); +} diff --git a/src/gui/rasterizer/truetype.zig b/src/gui/rasterizer/truetype.zig new file mode 100644 index 00000000..d4281e1f --- /dev/null +++ b/src/gui/rasterizer/truetype.zig @@ -0,0 +1,118 @@ +const std = @import("std"); +const TrueType = @import("TrueType"); +const XY = @import("xy").XY; +const font_finder = @import("font_finder.zig"); + +const Self = @This(); + +pub const GlyphKind = enum { + single, + left, + right, +}; + +pub const Font = struct { + cell_size: XY(u16), + scale: f32 = 0, + ascent_px: i32 = 0, + tt: ?TrueType = null, +}; + +pub const Fonts = struct {}; + +allocator: std.mem.Allocator, +font_data: std.ArrayListUnmanaged([]u8), + +pub fn init(allocator: std.mem.Allocator) !Self { + return .{ + .allocator = allocator, + .font_data = .empty, + }; +} + +pub fn deinit(self: *Self) void { + for (self.font_data.items) |data| { + self.allocator.free(data); + } + self.font_data.deinit(self.allocator); +} + +pub fn loadFont(self: *Self, name: []const u8, size_px: u16) !Font { + const path = try font_finder.findFont(self.allocator, name); + defer self.allocator.free(path); + + const data = try std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024 * 1024); + errdefer self.allocator.free(data); + + const tt = try TrueType.load(data); + + const scale = tt.scaleForPixelHeight(@floatFromInt(size_px)); + const vm = tt.verticalMetrics(); + + const ascent_px: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(vm.ascent)) * scale)); + const descent_px: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(vm.descent)) * scale)); + const cell_h: u16 = @intCast(@max(ascent_px - descent_px, 1)); + + const m_glyph = tt.codepointGlyphIndex('M'); + const m_hmetrics = tt.glyphHMetrics(m_glyph); + const cell_w_f: f32 = @as(f32, @floatFromInt(m_hmetrics.advance_width)) * scale; + const cell_w: u16 = @intFromFloat(@ceil(cell_w_f)); + + try self.font_data.append(self.allocator, data); + + return Font{ + .tt = tt, // TrueType holds a slice into `data` which is now owned by self.font_data + .cell_size = .{ .x = cell_w, .y = cell_h }, + .scale = scale, + .ascent_px = ascent_px, + }; +} + +pub fn render( + self: *const Self, + font: Font, + codepoint: u21, + kind: GlyphKind, + staging_buf: []u8, +) void { + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const tt = font.tt orelse return; + + var pixels = std.ArrayListUnmanaged(u8){}; + + const glyph = tt.codepointGlyphIndex(codepoint); + const dims = tt.glyphBitmap(alloc, &pixels, glyph, font.scale, font.scale) catch return; + + if (dims.width == 0 or dims.height == 0) return; + + const buf_w: i32 = switch (kind) { + .single => @intCast(font.cell_size.x), + .left, .right => @as(i32, @intCast(font.cell_size.x)) * 2, + }; + const buf_h: i32 = @intCast(font.cell_size.y); + + const x_offset: i32 = switch (kind) { + .single, .left => 0, + .right => @intCast(font.cell_size.x), + }; + + for (0..dims.height) |row| { + const dst_y: i32 = font.ascent_px + @as(i32, dims.off_y) + @as(i32, @intCast(row)); + if (dst_y < 0 or dst_y >= buf_h) continue; + + for (0..dims.width) |col| { + const dst_x: i32 = x_offset + @as(i32, dims.off_x) + @as(i32, @intCast(col)); + if (dst_x < 0 or dst_x >= buf_w) continue; + + const src_idx = row * dims.width + col; + const dst_idx: usize = @intCast(dst_y * buf_w + dst_x); + + if (src_idx < pixels.items.len and dst_idx < staging_buf.len) { + staging_buf[dst_idx] = pixels.items[src_idx]; + } + } + } +}