a73x

src/font.zig

Ref:   Size: 12.8 KiB

const std = @import("std");
const config = @import("config");
const c = @cImport({
    @cInclude("fontconfig/fontconfig.h");
    @cInclude("ft2build.h");
    @cInclude("freetype/freetype.h");
});

pub const FontLookup = struct {
    path: [:0]u8,
    index: c_int,

    pub fn deinit(self: *FontLookup, alloc: std.mem.Allocator) void {
        alloc.free(self.path);
    }
};

pub fn lookupConfiguredFont(alloc: std.mem.Allocator) !FontLookup {
    if (c.FcInit() == c.FcFalse) return error.FcInitFailed;

    const pattern = c.FcPatternCreate() orelse return error.FcPatternCreate;
    defer c.FcPatternDestroy(pattern);

    _ = c.FcPatternAddString(pattern, c.FC_FAMILY, @ptrCast(config.font_family));
    _ = c.FcPatternAddInteger(pattern, c.FC_WEIGHT, c.FC_WEIGHT_REGULAR);
    _ = c.FcPatternAddInteger(pattern, c.FC_SLANT, c.FC_SLANT_ROMAN);

    _ = c.FcConfigSubstitute(null, pattern, c.FcMatchPattern);
    c.FcDefaultSubstitute(pattern);

    var result: c.FcResult = undefined;
    const matched = c.FcFontMatch(null, pattern, &result) orelse return error.FcFontMatchFailed;
    defer c.FcPatternDestroy(matched);

    var file_cstr: [*c]c.FcChar8 = null;
    if (c.FcPatternGetString(matched, c.FC_FILE, 0, &file_cstr) != c.FcResultMatch) {
        return error.FcGetFileFailed;
    }

    var index: c_int = 0;
    _ = c.FcPatternGetInteger(matched, c.FC_INDEX, 0, &index);

    const slice = std.mem.span(@as([*:0]const u8, @ptrCast(file_cstr)));
    const dup = try alloc.dupeZ(u8, slice);
    return .{ .path = dup, .index = index };
}

pub const Glyph = struct {
    codepoint: u21,
    width: u32,
    height: u32,
    bearing_x: i32,
    bearing_y: i32,
    advance_x: i32,
    bitmap: []u8, // R8, owned
};

pub const Face = struct {
    alloc: std.mem.Allocator,
    library: c.FT_Library,
    face: c.FT_Face,
    px_size: u32,

    pub fn init(alloc: std.mem.Allocator, path: [:0]const u8, index: c_int, px_size: u32) !Face {
        var library: c.FT_Library = null;
        if (c.FT_Init_FreeType(&library) != 0) return error.FtInitFailed;
        errdefer _ = c.FT_Done_FreeType(library);

        var face: c.FT_Face = null;
        if (c.FT_New_Face(library, path.ptr, index, &face) != 0) return error.FtNewFaceFailed;
        errdefer _ = c.FT_Done_Face(face);

        if (c.FT_Set_Pixel_Sizes(face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;

        return .{
            .alloc = alloc,
            .library = library,
            .face = face,
            .px_size = px_size,
        };
    }

    pub fn deinit(self: *Face) void {
        _ = c.FT_Done_Face(self.face);
        _ = c.FT_Done_FreeType(self.library);
    }

    pub fn reinit(
        self: *Face,
        path: [:0]const u8,
        index: c_int,
        px_size: u32,
    ) !void {
        _ = c.FT_Done_Face(self.face);
        self.face = null;

        var new_face: c.FT_Face = null;
        if (c.FT_New_Face(self.library, path.ptr, index, &new_face) != 0) return error.FtNewFaceFailed;
        errdefer _ = c.FT_Done_Face(new_face);

        if (c.FT_Set_Pixel_Sizes(new_face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;

        self.face = new_face;
        self.px_size = px_size;
    }

    pub fn rasterize(self: *Face, codepoint: u21) !Glyph {
        const glyph_index = c.FT_Get_Char_Index(self.face, codepoint);
        if (c.FT_Load_Glyph(self.face, glyph_index, c.FT_LOAD_RENDER) != 0) {
            return error.FtLoadGlyphFailed;
        }

        const slot = self.face.*.glyph;
        const bitmap = slot.*.bitmap;
        const w: u32 = bitmap.width;
        const h: u32 = bitmap.rows;

        const pixels = try self.alloc.alloc(u8, @as(usize, w) * @as(usize, h));

        if (w > 0 and h > 0) {
            // Apply Correction C10: handle nullable buffer + negative pitch
            const buffer = bitmap.buffer orelse return error.FtBitmapNull;
            const pitch: i32 = bitmap.pitch;
            const abs_pitch: u32 = @intCast(@abs(pitch));
            const top_down = pitch > 0;

            var y: u32 = 0;
            while (y < h) : (y += 1) {
                const src_y = if (top_down) y else (h - 1 - y);
                const src_row = buffer + @as(usize, src_y) * abs_pitch;
                const dst_row = pixels.ptr + @as(usize, y) * w;
                @memcpy(dst_row[0..w], src_row[0..w]);
            }
        }

        return .{
            .codepoint = codepoint,
            .width = w,
            .height = h,
            .bearing_x = slot.*.bitmap_left,
            .bearing_y = slot.*.bitmap_top,
            .advance_x = @intCast(slot.*.advance.x >> 6),
            .bitmap = pixels,
        };
    }

    pub fn freeGlyph(self: *Face, glyph: Glyph) void {
        self.alloc.free(glyph.bitmap);
    }

    pub fn cellWidth(self: *Face) u32 {
        const m_index = c.FT_Get_Char_Index(self.face, 'M');
        _ = c.FT_Load_Glyph(self.face, m_index, c.FT_LOAD_DEFAULT);
        return @intCast(self.face.*.glyph.*.advance.x >> 6);
    }

    pub fn cellHeight(self: *Face) u32 {
        const metrics = self.face.*.size.*.metrics;
        return @intCast((metrics.ascender - metrics.descender) >> 6);
    }

    pub fn baseline(self: *Face) u32 {
        const metrics = self.face.*.size.*.metrics;
        return @intCast(metrics.ascender >> 6);
    }
};

pub const GlyphUV = struct {
    u0: f32,
    v0: f32,
    u1: f32,
    v1: f32,
    width: u32,
    height: u32,
    bearing_x: i32,
    bearing_y: i32,
    advance_x: i32,
};

pub const Atlas = struct {
    alloc: std.mem.Allocator,
    width: u32,
    height: u32,
    pixels: []u8, // R8
    cursor_x: u32,
    cursor_y: u32,
    row_height: u32,
    cache: std.AutoHashMap(u21, GlyphUV),
    dirty: bool,
    last_uploaded_y: u32,
    needs_full_upload: bool,

    pub fn init(alloc: std.mem.Allocator, width: u32, height: u32) !Atlas {
        const pixels = try alloc.alloc(u8, @as(usize, width) * @as(usize, height));
        @memset(pixels, 0);
        pixels[0] = 255;
        return .{
            .alloc = alloc,
            .width = width,
            .height = height,
            .pixels = pixels,
            .cursor_x = 1,
            .cursor_y = 0,
            .row_height = 1,
            .cache = std.AutoHashMap(u21, GlyphUV).init(alloc),
            .dirty = true,
            .last_uploaded_y = 0,
            .needs_full_upload = true,
        };
    }

    pub fn deinit(self: *Atlas) void {
        self.alloc.free(self.pixels);
        self.cache.deinit();
    }

    pub fn reset(self: *Atlas) void {
        @memset(self.pixels, 0);
        self.pixels[0] = 255;
        self.cursor_x = 1;
        self.cursor_y = 0;
        self.row_height = 1;
        self.cache.clearRetainingCapacity();
        self.dirty = true;
        self.last_uploaded_y = 0;
        self.needs_full_upload = true;
    }

    pub fn cursorUV(self: *const Atlas) GlyphUV {
        return .{
            .u0 = 0,
            .v0 = 0,
            .u1 = 1.0 / @as(f32, @floatFromInt(self.width)),
            .v1 = 1.0 / @as(f32, @floatFromInt(self.height)),
            .width = 1,
            .height = 1,
            .bearing_x = 0,
            .bearing_y = 0,
            .advance_x = 1,
        };
    }

    pub fn getOrInsert(self: *Atlas, face: *Face, codepoint: u21) !GlyphUV {
        if (self.cache.get(codepoint)) |uv| return uv;

        const glyph = try face.rasterize(codepoint);
        defer face.freeGlyph(glyph);

        if (self.cursor_x + glyph.width > self.width) {
            self.cursor_x = 0;
            self.cursor_y += self.row_height;
            self.row_height = 0;
        }
        if (self.cursor_y + glyph.height > self.height) {
            return error.AtlasFull;
        }

        var y: u32 = 0;
        while (y < glyph.height) : (y += 1) {
            const src = glyph.bitmap.ptr + @as(usize, y) * glyph.width;
            const dst = self.pixels.ptr + (@as(usize, self.cursor_y + y) * self.width) + self.cursor_x;
            @memcpy(dst[0..glyph.width], src[0..glyph.width]);
        }

        const uv = GlyphUV{
            .u0 = @as(f32, @floatFromInt(self.cursor_x)) / @as(f32, @floatFromInt(self.width)),
            .v0 = @as(f32, @floatFromInt(self.cursor_y)) / @as(f32, @floatFromInt(self.height)),
            .u1 = @as(f32, @floatFromInt(self.cursor_x + glyph.width)) / @as(f32, @floatFromInt(self.width)),
            .v1 = @as(f32, @floatFromInt(self.cursor_y + glyph.height)) / @as(f32, @floatFromInt(self.height)),
            .width = glyph.width,
            .height = glyph.height,
            .bearing_x = glyph.bearing_x,
            .bearing_y = glyph.bearing_y,
            .advance_x = glyph.advance_x,
        };

        try self.cache.put(codepoint, uv);
        self.cursor_x += glyph.width;
        if (glyph.height > self.row_height) self.row_height = glyph.height;
        self.dirty = true;

        return uv;
    }
};

test "lookupConfiguredFont returns a valid configured font path" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    // Just check the file exists
    const file = try std.fs.openFileAbsolute(lookup.path, .{});
    file.close();
}

test "Face rasterizes glyph 'M'" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    const glyph = try face.rasterize('M');
    defer face.freeGlyph(glyph);
    try std.testing.expect(glyph.width > 0);
    try std.testing.expect(glyph.height > 0);
    try std.testing.expect(glyph.bitmap.len == @as(usize, glyph.width) * @as(usize, glyph.height));
}

test "Atlas packs multiple glyphs and returns UVs" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    var atlas = try Atlas.init(std.testing.allocator, 512, 512);
    defer atlas.deinit();

    const uv_m = try atlas.getOrInsert(&face, 'M');
    const uv_a = try atlas.getOrInsert(&face, 'a');

    try std.testing.expect(uv_m.u0 >= 0.0 and uv_m.u1 <= 1.0);
    try std.testing.expect(uv_a.u0 >= 0.0 and uv_a.u1 <= 1.0);

    const uv_m2 = try atlas.getOrInsert(&face, 'M');
    try std.testing.expectEqual(uv_m.u0, uv_m2.u0);
    try std.testing.expectEqual(uv_m.v0, uv_m2.v0);
}

test "Atlas reserves a white pixel for cursor rendering" {
    var atlas = try Atlas.init(std.testing.allocator, 16, 16);
    defer atlas.deinit();

    const uv = atlas.cursorUV();
    try std.testing.expectEqual(@as(u8, 255), atlas.pixels[0]);
    try std.testing.expectEqual(@as(f32, 0), uv.u0);
    try std.testing.expectEqual(@as(f32, 0), uv.v0);
}

test "Atlas.reset clears cache and starts fresh" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    var atlas = try Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    _ = try atlas.getOrInsert(&face, 'A');
    try std.testing.expect(atlas.cache.count() > 0);

    atlas.reset();
    try std.testing.expectEqual(@as(u32, 0), atlas.cache.count());
    try std.testing.expectEqual(@as(u8, 255), atlas.pixels[0]);
    try std.testing.expect(atlas.dirty);

    // Re-inserting the same glyph should succeed after reset.
    _ = try atlas.getOrInsert(&face, 'A');
}

test "Face.reinit switches px_size and produces different cell metrics" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();
    const small_cell = face.cellWidth();

    try face.reinit(lookup.path, lookup.index, 28);
    const large_cell = face.cellWidth();

    try std.testing.expect(large_cell > small_cell);
}

test "Atlas dirty tracking fields initialized correctly" {
    var atlas = try Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    try std.testing.expectEqual(@as(u32, 0), atlas.last_uploaded_y);
    try std.testing.expect(atlas.needs_full_upload);
}

test "Atlas dirty region covers new glyphs" {
    var atlas = try Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    const y_start = atlas.last_uploaded_y;
    const y_end = atlas.cursor_y + atlas.row_height;
    try std.testing.expectEqual(@as(u32, 0), y_start);
    try std.testing.expect(y_end > 0);
}

test "Atlas reset restores dirty tracking fields" {
    var atlas = try Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    atlas.last_uploaded_y = 50;
    atlas.needs_full_upload = false;

    atlas.reset();

    try std.testing.expectEqual(@as(u32, 0), atlas.last_uploaded_y);
    try std.testing.expect(atlas.needs_full_upload);
}