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);
}