mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00

We can reintroduce `advance` if we ever want to do proportional string drawing, but we don't use it anywhere right now. And we also don't need `sprite` anymore since that was just there to disable constraints for sprites back when we did them on the GPU.
563 lines
20 KiB
Zig
563 lines
20 KiB
Zig
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const assert = std.debug.assert;
|
|
const testing = std.testing;
|
|
const Allocator = std.mem.Allocator;
|
|
const js = @import("zig-js");
|
|
const font = @import("../main.zig");
|
|
|
|
const log = std.log.scoped(.font_face);
|
|
|
|
pub const Face = struct {
|
|
/// See graphemes field for more details.
|
|
const grapheme_start: u32 = 0x10FFFF + 1;
|
|
const grapheme_end: u32 = std.math.maxInt(u32);
|
|
|
|
/// The web canvas face makes use of an allocator when interacting
|
|
/// with the JS environment.
|
|
alloc: Allocator,
|
|
|
|
/// The CSS "font" attribute, excluding size.
|
|
font_str: []const u8,
|
|
|
|
/// The size we currently have set.
|
|
size: font.face.DesiredSize,
|
|
|
|
/// The presentation for this font.
|
|
presentation: font.Presentation,
|
|
|
|
/// Metrics for this font face. These are useful for renderers.
|
|
metrics: font.Metrics,
|
|
|
|
/// The canvas element that we will reuse to render glyphs
|
|
canvas: js.Object,
|
|
|
|
/// The map to store multi-codepoint grapheme clusters that are rendered.
|
|
/// We use 1 above the maximum unicode codepoint up to the max 32-bit
|
|
/// unsigned integer to store the "glyph index" for graphemes.
|
|
grapheme_to_glyph: std.StringHashMapUnmanaged(u32) = .{},
|
|
glyph_to_grapheme: std.AutoHashMapUnmanaged(u32, []u8) = .{},
|
|
grapheme_next: u32 = grapheme_start,
|
|
|
|
/// Initialize a web canvas font with a "raw" value. The "raw" value can
|
|
/// be any valid value for a CSS "font" property EXCLUDING the size. The
|
|
/// size is always added via the `size` parameter.
|
|
///
|
|
/// The raw value is copied so the caller can free it after it is gone.
|
|
///
|
|
/// The presentation is given here directly because the browser gives
|
|
/// us no easy way to determine the presentation we want for this font.
|
|
/// Callers should just tell us what to expect.
|
|
pub fn initNamed(
|
|
alloc: Allocator,
|
|
raw: []const u8,
|
|
size: font.face.DesiredSize,
|
|
presentation: font.Presentation,
|
|
) !Face {
|
|
// Copy our font string because we're going to have to reuse it.
|
|
const font_str = try alloc.dupe(u8, raw);
|
|
errdefer alloc.free(font_str);
|
|
|
|
// Create our canvas that we're going to continue to reuse.
|
|
const doc = try js.global.get(js.Object, "document");
|
|
defer doc.deinit();
|
|
const canvas = try doc.call(js.Object, "createElement", .{js.string("canvas")});
|
|
errdefer canvas.deinit();
|
|
|
|
var result = Face{
|
|
.alloc = alloc,
|
|
.font_str = font_str,
|
|
.size = size,
|
|
.presentation = presentation,
|
|
|
|
.canvas = canvas,
|
|
|
|
// We're going to calculate these right after initialization.
|
|
.metrics = undefined,
|
|
};
|
|
try result.calcMetrics();
|
|
|
|
log.debug("face initialized: {s}", .{raw});
|
|
return result;
|
|
}
|
|
|
|
pub fn deinit(self: *Face) void {
|
|
self.alloc.free(self.font_str);
|
|
self.grapheme_to_glyph.deinit(self.alloc);
|
|
{
|
|
var it = self.glyph_to_grapheme.valueIterator();
|
|
while (it.next()) |value| self.alloc.free(value.*);
|
|
self.glyph_to_grapheme.deinit(self.alloc);
|
|
}
|
|
self.canvas.deinit();
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Resize the font in-place. If this succeeds, the caller is responsible
|
|
/// for clearing any glyph caches, font atlas data, etc.
|
|
pub fn setSize(self: *Face, size: font.face.DesiredSize) !void {
|
|
const old = self.size;
|
|
self.size = size;
|
|
errdefer self.size = old;
|
|
try self.calcMetrics();
|
|
}
|
|
|
|
/// Returns the glyph index for the given Unicode code point. For canvas,
|
|
/// we support every glyph and the ID is just the codepoint since we don't
|
|
/// have access to the underlying tables anyways. We let the browser deal
|
|
/// with bad codepoints.
|
|
pub fn glyphIndex(self: Face, cp: u32) ?u32 {
|
|
// If this is a multi-codepoint grapheme then we only check if
|
|
// we actually know about it.
|
|
if (cp >= grapheme_start) {
|
|
if (!self.glyph_to_grapheme.contains(cp)) return null;
|
|
}
|
|
|
|
// Render the glyph to determine if it is colored or not. We
|
|
// have to do this because the browser will always try to render
|
|
// whatever we give it and we have no API to determine color.
|
|
//
|
|
// We don't want to say yes to the wrong presentation because
|
|
// it will go into the wrong Atlas.
|
|
const p: font.Presentation = if (cp <= 255) .text else p: {
|
|
break :p self.glyphPresentation(cp) catch {
|
|
// In this case, we assume we are unable to render
|
|
// this glyph and therefore just say we don't support it.
|
|
return null;
|
|
};
|
|
};
|
|
if (p != self.presentation) return null;
|
|
|
|
return cp;
|
|
}
|
|
|
|
/// This determines the presentation of the glyph by literally
|
|
/// inspecting the image data to look for any color. This isn't
|
|
/// super performant but we don't have a better choice given the
|
|
/// canvas APIs.
|
|
fn glyphPresentation(
|
|
self: Face,
|
|
cp: u32,
|
|
) !font.Presentation {
|
|
// Render the glyph
|
|
var render = try self.renderGlyphInternal(self.alloc, cp);
|
|
defer render.deinit();
|
|
|
|
// Inspect the image data for any non-zeros in the RGB value.
|
|
// NOTE(perf): this is an easy candidate for SIMD.
|
|
var i: usize = 0;
|
|
while (i < render.bitmap.len) : (i += 4) {
|
|
if (render.bitmap[i] > 0 or
|
|
render.bitmap[i + 1] > 0 or
|
|
render.bitmap[i + 2] > 0) return .emoji;
|
|
}
|
|
|
|
return .text;
|
|
}
|
|
|
|
/// Returns the glyph index for the given grapheme cluster. The same
|
|
/// cluster will always map to the same glyph index. This does not render
|
|
/// the grapheme at this time, only reserves the index.
|
|
pub fn graphemeGlyphIndex(self: *Face, cluster: []const u8) error{OutOfMemory}!u32 {
|
|
// If we already have this stored then return it
|
|
const gop = try self.grapheme_to_glyph.getOrPut(self.alloc, cluster);
|
|
if (gop.found_existing) return gop.value_ptr.*;
|
|
errdefer _ = self.grapheme_to_glyph.remove(cluster);
|
|
|
|
// We don't have it stored. Ensure we have space to store. The
|
|
// next will be "0" if we're out of space due to unsigned int wrapping.
|
|
if (self.grapheme_next == 0) return error.OutOfMemory;
|
|
|
|
// Copy the cluster for our reverse mapping
|
|
const copy = try self.alloc.dupe(u8, cluster);
|
|
errdefer self.alloc.free(copy);
|
|
|
|
// Grow space for the reverse mapping
|
|
try self.glyph_to_grapheme.ensureUnusedCapacity(self.alloc, 1);
|
|
|
|
// Store it
|
|
gop.value_ptr.* = self.grapheme_next;
|
|
self.glyph_to_grapheme.putAssumeCapacity(self.grapheme_next, copy);
|
|
|
|
self.grapheme_next +%= 1;
|
|
return gop.value_ptr.*;
|
|
}
|
|
|
|
/// 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: *font.Atlas,
|
|
glyph_index: u32,
|
|
opts: font.face.RenderOptions,
|
|
) !font.Glyph {
|
|
_ = opts;
|
|
|
|
var render = try self.renderGlyphInternal(alloc, glyph_index);
|
|
defer render.deinit();
|
|
|
|
// Convert the format of the bitmap if necessary
|
|
const bitmap_formatted: []u8 = switch (atlas.format) {
|
|
// Bitmap is already in RGBA
|
|
.rgba => render.bitmap,
|
|
|
|
// Convert down to A8
|
|
.grayscale => a8: {
|
|
assert(@mod(render.bitmap.len, 4) == 0);
|
|
var bitmap_a8 = try alloc.alloc(u8, render.bitmap.len / 4);
|
|
errdefer alloc.free(bitmap_a8);
|
|
var i: usize = 0;
|
|
while (i < bitmap_a8.len) : (i += 1) {
|
|
bitmap_a8[i] = render.bitmap[(i * 4) + 3];
|
|
}
|
|
|
|
break :a8 bitmap_a8;
|
|
},
|
|
|
|
else => return error.UnsupportedAtlasFormat,
|
|
};
|
|
defer if (bitmap_formatted.ptr != render.bitmap.ptr) {
|
|
alloc.free(bitmap_formatted);
|
|
};
|
|
|
|
// Put it in our atlas
|
|
const region = try atlas.reserve(alloc, render.width, render.height);
|
|
if (region.width > 0 and region.height > 0) {
|
|
atlas.set(region, bitmap_formatted);
|
|
}
|
|
|
|
return font.Glyph{
|
|
.width = render.width,
|
|
.height = render.height,
|
|
// TODO: this can't be right
|
|
.offset_x = 0,
|
|
.offset_y = 0,
|
|
.atlas_x = region.x,
|
|
.atlas_y = region.y,
|
|
};
|
|
}
|
|
|
|
/// Calculate the metrics associated with a given face.
|
|
fn calcMetrics(self: *Face) !void {
|
|
const ctx = try self.context();
|
|
defer ctx.deinit();
|
|
|
|
// Cell width is the width of our M text
|
|
const cell_width: f32 = cell_width: {
|
|
const metrics = try ctx.call(js.Object, "measureText", .{js.string("M")});
|
|
defer metrics.deinit();
|
|
|
|
// We prefer the bounding box since it is tighter but certain
|
|
// text such as emoji do not have a bounding box set so we use
|
|
// the full run width instead.
|
|
const bounding_right = try metrics.get(f32, "actualBoundingBoxRight");
|
|
if (bounding_right > 0) break :cell_width bounding_right;
|
|
break :cell_width try metrics.get(f32, "width");
|
|
};
|
|
|
|
// To get the cell height we render a high and low character and get
|
|
// the total of the ascent and descent. This should equal our
|
|
// pixel height but this is a more surefire way to get it.
|
|
const height_metrics = try ctx.call(js.Object, "measureText", .{js.string("M_")});
|
|
defer height_metrics.deinit();
|
|
const asc = try height_metrics.get(f32, "actualBoundingBoxAscent");
|
|
const desc = try height_metrics.get(f32, "actualBoundingBoxDescent");
|
|
const cell_height = asc + desc;
|
|
const cell_baseline = desc;
|
|
|
|
// There isn't a declared underline position for canvas measurements
|
|
// so we just go 1 under the cell height to match freetype logic
|
|
// at this time (our freetype logic).
|
|
const underline_position = cell_height - 1;
|
|
const underline_thickness: f32 = 1;
|
|
|
|
const result = font.Metrics{
|
|
.cell_width = @intFromFloat(cell_width),
|
|
.cell_height = @intFromFloat(cell_height),
|
|
.cell_baseline = @intFromFloat(cell_baseline),
|
|
.underline_position = @intFromFloat(underline_position),
|
|
.underline_thickness = @intFromFloat(underline_thickness),
|
|
.strikethrough_position = @intFromFloat(underline_position),
|
|
.strikethrough_thickness = @intFromFloat(underline_thickness),
|
|
};
|
|
|
|
self.metrics = result;
|
|
log.debug("metrics font={s} value={}", .{ self.font_str, self.metrics });
|
|
}
|
|
|
|
/// Returns the 2d context configured for drawing
|
|
fn context(self: Face) !js.Object {
|
|
// This will return the same context on subsequent calls so it
|
|
// is important to reset it.
|
|
const ctx = try self.canvas.call(js.Object, "getContext", .{js.string("2d")});
|
|
errdefer ctx.deinit();
|
|
|
|
// Clear the canvas
|
|
{
|
|
const width = try self.canvas.get(f64, "width");
|
|
const height = try self.canvas.get(f64, "height");
|
|
try ctx.call(void, "clearRect", .{ 0, 0, width, height });
|
|
}
|
|
|
|
// Set our context font
|
|
const font_val = try std.fmt.allocPrint(
|
|
self.alloc,
|
|
"{d}px {s}",
|
|
.{ self.size.points, self.font_str },
|
|
);
|
|
defer self.alloc.free(font_val);
|
|
try ctx.set("font", js.string(font_val));
|
|
|
|
// If the font property didn't change, then the font set didn't work.
|
|
// We do this check because it is very easy to put an invalid font
|
|
// in and this at least makes it show up in the logs.
|
|
const check = try ctx.getAlloc(js.String, self.alloc, "font");
|
|
defer self.alloc.free(check);
|
|
if (!std.mem.eql(u8, font_val, check)) {
|
|
log.warn("canvas font didn't set, fonts may be broken, expected={s} got={s}", .{
|
|
font_val,
|
|
check,
|
|
});
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
/// An internal (web-canvas-only) format for rendered glyphs
|
|
/// since we do render passes in multiple different situations.
|
|
const RenderedGlyph = struct {
|
|
alloc: Allocator,
|
|
metrics: js.Object,
|
|
width: u32,
|
|
height: u32,
|
|
bitmap: []u8,
|
|
|
|
pub fn deinit(self: *RenderedGlyph) void {
|
|
self.metrics.deinit();
|
|
self.alloc.free(self.bitmap);
|
|
self.* = undefined;
|
|
}
|
|
};
|
|
|
|
/// Shared logic for rendering a glyph.
|
|
fn renderGlyphInternal(
|
|
self: Face,
|
|
alloc: Allocator,
|
|
glyph_index: u32,
|
|
) !RenderedGlyph {
|
|
// Encode our glyph to UTF-8 so we can build a JS string out of it.
|
|
var utf8: [4]u8 = undefined;
|
|
const glyph_str = glyph_str: {
|
|
// If we are a normal glyph then we are a single codepoint and
|
|
// we just UTF8 encode it as-is.
|
|
if (glyph_index < grapheme_start) {
|
|
const utf8_len = try std.unicode.utf8Encode(@intCast(glyph_index), &utf8);
|
|
break :glyph_str js.string(utf8[0..utf8_len]);
|
|
}
|
|
|
|
// We are a multi-codepoint glyph so we have to read the glyph
|
|
// from the map and it is already utf8 encoded.
|
|
const slice = self.glyph_to_grapheme.get(glyph_index) orelse
|
|
return error.UnknownGraphemeCluster;
|
|
break :glyph_str js.string(slice);
|
|
};
|
|
|
|
// Get our drawing context
|
|
const measure_ctx = try self.context();
|
|
defer measure_ctx.deinit();
|
|
|
|
// Get the width and height of the render
|
|
const metrics = try measure_ctx.call(js.Object, "measureText", .{glyph_str});
|
|
errdefer metrics.deinit();
|
|
const width: u32 = @as(u32, @intFromFloat(@ceil(width: {
|
|
// We prefer the bounding box since it is tighter but certain
|
|
// text such as emoji do not have a bounding box set so we use
|
|
// the full run width instead.
|
|
const bounding_right = try metrics.get(f32, "actualBoundingBoxRight");
|
|
if (bounding_right > 0) break :width bounding_right;
|
|
break :width try metrics.get(f32, "width");
|
|
}))) + 1;
|
|
|
|
const left = try metrics.get(f32, "actualBoundingBoxLeft");
|
|
const asc = try metrics.get(f32, "actualBoundingBoxAscent");
|
|
const desc = try metrics.get(f32, "actualBoundingBoxDescent");
|
|
|
|
// On Firefox on Linux, the bounding box is broken in some cases for
|
|
// ideographic glyphs (such as emoji). We detect this and behave
|
|
// differently.
|
|
const broken_bbox = asc + desc < 0.001;
|
|
|
|
// Height is our ascender + descender for this char
|
|
const height = if (!broken_bbox) @as(u32, @intFromFloat(@ceil(asc + desc))) + 1 else width;
|
|
|
|
// Note: width and height both get "+ 1" added to them above. This
|
|
// is important so that there is a 1px border around the glyph to avoid
|
|
// any clipping in the atlas.
|
|
|
|
// Resize canvas to match the glyph size exactly
|
|
{
|
|
try self.canvas.set("width", width);
|
|
try self.canvas.set("height", height);
|
|
|
|
const width_str = try std.fmt.allocPrint(alloc, "{d}px", .{width});
|
|
defer alloc.free(width_str);
|
|
const height_str = try std.fmt.allocPrint(alloc, "{d}px", .{height});
|
|
defer alloc.free(height_str);
|
|
|
|
const style = try self.canvas.get(js.Object, "style");
|
|
defer style.deinit();
|
|
try style.set("width", js.string(width_str));
|
|
try style.set("height", js.string(height_str));
|
|
}
|
|
|
|
// Reload our context since we resized the canvas
|
|
const ctx = try self.context();
|
|
defer ctx.deinit();
|
|
|
|
// For the broken bounding box case we render ideographic baselines
|
|
// so that we just render the glyph fully in the box with no offsets.
|
|
if (broken_bbox) {
|
|
try ctx.set("textBaseline", js.string("ideographic"));
|
|
}
|
|
|
|
// Draw background
|
|
try ctx.set("fillStyle", js.string("transparent"));
|
|
try ctx.call(void, "fillRect", .{
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
});
|
|
|
|
// Draw glyph
|
|
try ctx.set("fillStyle", js.string("black"));
|
|
try ctx.call(void, "fillText", .{
|
|
glyph_str,
|
|
left + 1,
|
|
if (!broken_bbox) asc + 1 else @as(f32, @floatFromInt(height)),
|
|
});
|
|
|
|
// Read the image data and get it into a []u8 on our side
|
|
const bitmap: []u8 = bitmap: {
|
|
// Read the raw bitmap data and get the "data" value which is a
|
|
// Uint8ClampedArray.
|
|
const data = try ctx.call(js.Object, "getImageData", .{ 0, 0, width, height });
|
|
defer data.deinit();
|
|
const src_array = try data.get(js.Object, "data");
|
|
defer src_array.deinit();
|
|
|
|
// Allocate our local memory to copy the data to.
|
|
const len = try src_array.get(u32, "length");
|
|
const bitmap = try alloc.alloc(u8, @intCast(len));
|
|
errdefer alloc.free(bitmap);
|
|
|
|
// Create our target Uint8Array that we can use to copy from src.
|
|
const mem_array = mem_array: {
|
|
// Get our runtime memory
|
|
const mem = try js.runtime.get(js.Object, "memory");
|
|
defer mem.deinit();
|
|
const buf = try mem.get(js.Object, "buffer");
|
|
defer buf.deinit();
|
|
|
|
// Construct our array to peer into our memory
|
|
const Uint8Array = try js.global.get(js.Object, "Uint8Array");
|
|
defer Uint8Array.deinit();
|
|
const mem_array = try Uint8Array.new(.{ buf, bitmap.ptr });
|
|
errdefer mem_array.deinit();
|
|
|
|
break :mem_array mem_array;
|
|
};
|
|
defer mem_array.deinit();
|
|
|
|
// Copy
|
|
try mem_array.call(void, "set", .{src_array});
|
|
|
|
break :bitmap bitmap;
|
|
};
|
|
errdefer alloc.free(bitmap);
|
|
|
|
return RenderedGlyph{
|
|
.alloc = alloc,
|
|
.metrics = metrics,
|
|
.width = width,
|
|
.height = height,
|
|
.bitmap = bitmap,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// The wasm-compatible API.
|
|
pub const Wasm = struct {
|
|
const wasm = @import("../../os/wasm.zig");
|
|
const alloc = wasm.alloc;
|
|
|
|
export fn face_new(ptr: [*]const u8, len: usize, pts: u16, p: u16) ?*Face {
|
|
return face_new_(ptr, len, pts, p) catch null;
|
|
}
|
|
|
|
fn face_new_(ptr: [*]const u8, len: usize, pts: f32, presentation: u16) !*Face {
|
|
var face = try Face.initNamed(
|
|
alloc,
|
|
ptr[0..len],
|
|
.{ .points = pts },
|
|
@enumFromInt(presentation),
|
|
);
|
|
errdefer face.deinit();
|
|
|
|
const result = try alloc.create(Face);
|
|
errdefer alloc.destroy(result);
|
|
result.* = face;
|
|
return result;
|
|
}
|
|
|
|
export fn face_free(ptr: ?*Face) void {
|
|
if (ptr) |v| {
|
|
v.deinit();
|
|
alloc.destroy(v);
|
|
}
|
|
}
|
|
|
|
/// Resulting pointer must be freed using the global "free".
|
|
export fn face_render_glyph(
|
|
face: *Face,
|
|
atlas: *font.Atlas,
|
|
codepoint: u32,
|
|
) ?*font.Glyph {
|
|
return face_render_glyph_(face, atlas, codepoint) catch |err| {
|
|
log.warn("error rendering glyph err={}", .{err});
|
|
return null;
|
|
};
|
|
}
|
|
|
|
export fn face_debug_canvas(face: *Face) void {
|
|
face_debug_canvas_(face) catch |err| {
|
|
log.warn("error adding debug canvas err={}", .{err});
|
|
};
|
|
}
|
|
|
|
fn face_debug_canvas_(face: *Face) !void {
|
|
const doc = try js.global.get(js.Object, "document");
|
|
defer doc.deinit();
|
|
|
|
const elem = try doc.call(
|
|
?js.Object,
|
|
"getElementById",
|
|
.{js.string("face-canvas")},
|
|
) orelse return error.CanvasContainerNotFound;
|
|
defer elem.deinit();
|
|
|
|
try elem.call(void, "append", .{face.canvas});
|
|
}
|
|
|
|
fn face_render_glyph_(face: *Face, atlas: *font.Atlas, codepoint: u32) !*font.Glyph {
|
|
const glyph = try face.renderGlyph(alloc, atlas, codepoint, .{});
|
|
|
|
const result = try alloc.create(font.Glyph);
|
|
errdefer alloc.destroy(result);
|
|
_ = try wasm.toHostOwned(result);
|
|
result.* = glyph;
|
|
return result;
|
|
}
|
|
};
|