mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-20 10:46:07 +03:00
278 lines
8.9 KiB
Zig
278 lines
8.9 KiB
Zig
//! Face represents a single font face. A single font face has a single set
|
|
//! of properties associated with it such as style, weight, etc.
|
|
//!
|
|
//! A Face isn't typically meant to be used directly. It is usually used
|
|
//! via a Family in order to store it in an Atlas.
|
|
const Face = @This();
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const freetype = @import("freetype");
|
|
const harfbuzz = @import("harfbuzz");
|
|
const assert = std.debug.assert;
|
|
const testing = std.testing;
|
|
const Allocator = std.mem.Allocator;
|
|
const Atlas = @import("../Atlas.zig");
|
|
const Glyph = @import("main.zig").Glyph;
|
|
const Library = @import("main.zig").Library;
|
|
const Presentation = @import("main.zig").Presentation;
|
|
|
|
const log = std.log.scoped(.font_face);
|
|
|
|
/// Our font face.
|
|
face: freetype.Face,
|
|
|
|
/// 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,
|
|
|
|
/// If a DPI can't be calculated, this DPI is used. This is probably
|
|
/// wrong on modern devices so it is highly recommended you get the DPI
|
|
/// using whatever platform method you can.
|
|
pub const default_dpi = if (builtin.os.tag == .macos) 72 else 96;
|
|
|
|
/// The desired size for loading a font.
|
|
pub const DesiredSize = struct {
|
|
// Desired size in points
|
|
points: u16,
|
|
|
|
// The DPI of the screen so we can convert points to pixels.
|
|
xdpi: u16 = default_dpi,
|
|
ydpi: u16 = default_dpi,
|
|
|
|
// Converts points to pixels
|
|
pub fn pixels(self: DesiredSize) u16 {
|
|
// 1 point = 1/72 inch
|
|
return (self.points * self.ydpi) / 72;
|
|
}
|
|
};
|
|
|
|
/// Initialize a new font face with the given source in-memory.
|
|
pub fn init(lib: Library, source: [:0]const u8, size: DesiredSize) !Face {
|
|
const face = try lib.lib.initMemoryFace(source, 0);
|
|
errdefer face.deinit();
|
|
try face.selectCharmap(.unicode);
|
|
try setSize_(face, size);
|
|
|
|
const hb_font = try harfbuzz.freetype.createFont(face.handle);
|
|
errdefer hb_font.destroy();
|
|
|
|
return Face{
|
|
.face = face,
|
|
.hb_font = hb_font,
|
|
.presentation = if (face.hasColor()) .emoji else .text,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Face) void {
|
|
self.face.deinit();
|
|
self.hb_font.destroy();
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Change the size of the loaded font face. If you're using a texture
|
|
/// atlas, you should invalidate all the previous values if cached.
|
|
pub fn setSize(self: Face, size: DesiredSize) !void {
|
|
return try setSize_(self.face, size);
|
|
}
|
|
|
|
fn setSize_(face: freetype.Face, size: DesiredSize) !void {
|
|
// If we have fixed sizes, we just have to try to pick the one closest
|
|
// to what the user requested. Otherwise, we can choose an arbitrary
|
|
// pixel size.
|
|
if (!face.hasFixedSizes()) {
|
|
const size_26dot6 = @intCast(i32, size.points << 6); // mult by 64
|
|
try face.setCharSize(0, size_26dot6, size.xdpi, size.ydpi);
|
|
} else try selectSizeNearest(face, size.pixels());
|
|
}
|
|
|
|
/// Selects the fixed size in the loaded face that is closest to the
|
|
/// requested pixel size.
|
|
fn selectSizeNearest(face: freetype.Face, size: u32) !void {
|
|
var i: i32 = 0;
|
|
var best_i: i32 = 0;
|
|
var best_diff: i32 = 0;
|
|
while (i < face.handle.*.num_fixed_sizes) : (i += 1) {
|
|
const width = face.handle.*.available_sizes[@intCast(usize, i)].width;
|
|
const diff = @intCast(i32, size) - @intCast(i32, width);
|
|
if (i == 0 or diff < best_diff) {
|
|
best_diff = diff;
|
|
best_i = i;
|
|
}
|
|
}
|
|
|
|
try face.selectSize(best_i);
|
|
}
|
|
|
|
/// 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 {
|
|
return self.face.getCharIndex(cp);
|
|
}
|
|
|
|
/// Returns true if this font is colored. This can be used by callers to
|
|
/// determine what kind of atlas to pass in.
|
|
pub fn hasColor(self: Face) bool {
|
|
return self.face.hasColor();
|
|
}
|
|
|
|
/// Render a glyph using the glyph index. The rendered glyph is stored in the
|
|
/// given texture atlas.
|
|
pub fn renderGlyph(self: Face, alloc: Allocator, atlas: *Atlas, glyph_index: u32) !Glyph {
|
|
// If our glyph has color, we want to render the color
|
|
try self.face.loadGlyph(glyph_index, .{
|
|
.render = true,
|
|
.color = self.face.hasColor(),
|
|
});
|
|
|
|
const glyph = self.face.handle.*.glyph;
|
|
const bitmap_ft = glyph.*.bitmap;
|
|
|
|
// This bitmap is blank. I've seen it happen in a font, I don't know why.
|
|
// If it is empty, we just return a valid glyph struct that does nothing.
|
|
if (bitmap_ft.rows == 0) return Glyph{
|
|
.width = 0,
|
|
.height = 0,
|
|
.offset_x = 0,
|
|
.offset_y = 0,
|
|
.atlas_x = 0,
|
|
.atlas_y = 0,
|
|
.advance_x = 0,
|
|
};
|
|
|
|
// Ensure we know how to work with the font format. And assure that
|
|
// or color depth is as expected on the texture atlas. If format is null
|
|
// it means there is no native color format for our Atlas and we must try
|
|
// conversion.
|
|
const format: Atlas.Format = switch (bitmap_ft.pixel_mode) {
|
|
freetype.c.FT_PIXEL_MODE_GRAY => .greyscale,
|
|
freetype.c.FT_PIXEL_MODE_BGRA => .rgba,
|
|
else => {
|
|
log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode });
|
|
@panic("unsupported pixel mode");
|
|
},
|
|
};
|
|
assert(atlas.format == format);
|
|
|
|
const bitmap = bitmap_ft;
|
|
const tgt_w = bitmap.width;
|
|
const tgt_h = bitmap.rows;
|
|
|
|
const region = try atlas.reserve(alloc, tgt_w, tgt_h);
|
|
|
|
// If we have data, copy it into the atlas
|
|
if (region.width > 0 and region.height > 0) {
|
|
const depth = atlas.format.depth();
|
|
|
|
// We can avoid a buffer copy if our atlas width and bitmap
|
|
// width match and the bitmap pitch is just the width (meaning
|
|
// the data is tightly packed).
|
|
const needs_copy = !(tgt_w == bitmap.width and (bitmap.width * depth) == bitmap.pitch);
|
|
|
|
// If we need to copy the data, we copy it into a temporary buffer.
|
|
const buffer = if (needs_copy) buffer: {
|
|
var temp = try alloc.alloc(u8, tgt_w * tgt_h * depth);
|
|
var dst_ptr = temp;
|
|
var src_ptr = bitmap.buffer;
|
|
var i: usize = 0;
|
|
while (i < bitmap.rows) : (i += 1) {
|
|
std.mem.copy(u8, dst_ptr, src_ptr[0 .. bitmap.width * depth]);
|
|
dst_ptr = dst_ptr[tgt_w * depth ..];
|
|
src_ptr += @intCast(usize, bitmap.pitch);
|
|
}
|
|
break :buffer temp;
|
|
} else bitmap.buffer[0..(tgt_w * tgt_h * depth)];
|
|
defer if (buffer.ptr != bitmap.buffer) alloc.free(buffer);
|
|
|
|
// Write the glyph information into the atlas
|
|
assert(region.width == tgt_w);
|
|
assert(region.height == tgt_h);
|
|
atlas.set(region, buffer);
|
|
}
|
|
|
|
// Store glyph metadata
|
|
return Glyph{
|
|
.width = tgt_w,
|
|
.height = tgt_h,
|
|
.offset_x = glyph.*.bitmap_left,
|
|
.offset_y = glyph.*.bitmap_top,
|
|
.atlas_x = region.x,
|
|
.atlas_y = region.y,
|
|
.advance_x = f26dot6ToFloat(glyph.*.advance.x),
|
|
};
|
|
}
|
|
|
|
/// Convert 16.6 pixel format to pixels based on the scale factor of the
|
|
/// current font size.
|
|
pub fn unitsToPxY(self: Face, units: i32) i32 {
|
|
return @intCast(i32, freetype.mulFix(
|
|
units,
|
|
@intCast(i32, self.face.handle.*.size.*.metrics.y_scale),
|
|
) >> 6);
|
|
}
|
|
|
|
/// Convert 26.6 pixel format to f32
|
|
fn f26dot6ToFloat(v: freetype.c.FT_F26Dot6) f32 {
|
|
return @intToFloat(f32, v >> 6);
|
|
}
|
|
|
|
test {
|
|
const testFont = @import("test.zig").fontRegular;
|
|
const alloc = testing.allocator;
|
|
|
|
var lib = try Library.init();
|
|
defer lib.deinit();
|
|
|
|
var atlas = try Atlas.init(alloc, 512, .greyscale);
|
|
defer atlas.deinit(alloc);
|
|
|
|
var font = try init(lib, testFont, .{ .points = 12 });
|
|
defer font.deinit();
|
|
|
|
try testing.expectEqual(Presentation.text, font.presentation);
|
|
|
|
// Generate all visible ASCII
|
|
var i: u8 = 32;
|
|
while (i < 127) : (i += 1) {
|
|
_ = try font.renderGlyph(alloc, &atlas, font.glyphIndex(i).?);
|
|
}
|
|
}
|
|
|
|
test "color emoji" {
|
|
const alloc = testing.allocator;
|
|
const testFont = @import("test.zig").fontEmoji;
|
|
|
|
var lib = try Library.init();
|
|
defer lib.deinit();
|
|
|
|
var atlas = try Atlas.init(alloc, 512, .rgba);
|
|
defer atlas.deinit(alloc);
|
|
|
|
var font = try init(lib, testFont, .{ .points = 12 });
|
|
defer font.deinit();
|
|
|
|
try testing.expectEqual(Presentation.emoji, font.presentation);
|
|
|
|
_ = try font.renderGlyph(alloc, &atlas, font.glyphIndex('🥸').?);
|
|
}
|
|
|
|
test "mono to rgba" {
|
|
const alloc = testing.allocator;
|
|
const testFont = @import("test.zig").fontEmoji;
|
|
|
|
var lib = try Library.init();
|
|
defer lib.deinit();
|
|
|
|
var atlas = try Atlas.init(alloc, 512, .rgba);
|
|
defer atlas.deinit(alloc);
|
|
|
|
var font = try init(lib, testFont, .{ .points = 12 });
|
|
defer font.deinit();
|
|
|
|
// glyph 3 is mono in Noto
|
|
_ = try font.renderGlyph(alloc, &atlas, 3);
|
|
}
|