mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-22 11:46:11 +03:00
font: web canvas face has to render to check presentation for cp
Since we have no way to detect our presentation (text/emoji), we need to actually render the glyph that is being requested to double-check that the glyph matches our supported presentation. We do this because the browser will render fallback fonts for a glyph if it can't find one in the named font.
This commit is contained in:
@ -286,12 +286,29 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool {
|
||||
|
||||
// Canvas always has the codepoint because we have no way of
|
||||
// really checking and we let the browser handle it.
|
||||
.web_canvas => {
|
||||
if (self.wc) |wc| {
|
||||
if (p) |desired| if (wc.presentation != desired) return false;
|
||||
}
|
||||
.web_canvas => if (self.wc) |wc| {
|
||||
// Fast-path if we have a specific presentation and we
|
||||
// don't match, then it is definitely not this face.
|
||||
if (p) |desired| if (wc.presentation != desired) return false;
|
||||
|
||||
return true;
|
||||
// Slow-path: we initialize the font, render it, and check
|
||||
// if it works and the presentation matches.
|
||||
var face = Face.initNamed(
|
||||
wc.alloc,
|
||||
wc.font_str,
|
||||
.{ .points = 12 },
|
||||
wc.presentation,
|
||||
) catch |err| {
|
||||
log.warn("failed to init face for codepoint check " ++
|
||||
"face={s} err={}", .{
|
||||
wc.font_str,
|
||||
err,
|
||||
});
|
||||
|
||||
return false;
|
||||
};
|
||||
defer face.deinit();
|
||||
return face.glyphIndex(cp) != null;
|
||||
},
|
||||
|
||||
.freetype => {},
|
||||
|
@ -90,12 +90,50 @@ pub const Face = struct {
|
||||
/// have access to the underlying tables anyways. We let the browser deal
|
||||
/// with bad codepoints.
|
||||
pub fn glyphIndex(self: Face, cp: u32) ?u32 {
|
||||
_ = self;
|
||||
// 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 jus say we don't support it.
|
||||
return null;
|
||||
};
|
||||
};
|
||||
if (p != self.presentation) return null;
|
||||
|
||||
return cp;
|
||||
}
|
||||
|
||||
/// Render a glyph using the glyph index. The rendered glyph is stored in the
|
||||
/// given texture atlas.
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Render a glyph using the glyph index. The rendered glyph is stored
|
||||
/// in the given texture atlas.
|
||||
pub fn renderGlyph(
|
||||
self: Face,
|
||||
alloc: Allocator,
|
||||
@ -105,7 +143,159 @@ pub const Face = struct {
|
||||
) !font.Glyph {
|
||||
_ = max_height;
|
||||
|
||||
// Encode our glyph into UTF-8 so we can build a JS string out of it.
|
||||
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
|
||||
.greyscale => 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,
|
||||
.advance_x = 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
self.metrics = .{
|
||||
.cell_width = cell_width,
|
||||
.cell_height = cell_height,
|
||||
.cell_baseline = cell_baseline,
|
||||
.underline_position = underline_position,
|
||||
.underline_thickness = underline_thickness,
|
||||
.strikethrough_position = underline_position,
|
||||
.strikethrough_thickness = underline_thickness,
|
||||
};
|
||||
|
||||
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
|
||||
var 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 utf8_len = try std.unicode.utf8Encode(@intCast(u21, glyph_index), &utf8);
|
||||
const glyph_str = js.string(utf8[0..utf8_len]);
|
||||
@ -116,7 +306,7 @@ pub const Face = struct {
|
||||
|
||||
// Get the width and height of the render
|
||||
const metrics = try measure_ctx.call(js.Object, "measureText", .{glyph_str});
|
||||
defer metrics.deinit();
|
||||
errdefer metrics.deinit();
|
||||
const width: u32 = @floatToInt(u32, @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
|
||||
@ -222,130 +412,16 @@ pub const Face = struct {
|
||||
|
||||
break :bitmap bitmap;
|
||||
};
|
||||
defer alloc.free(bitmap);
|
||||
errdefer alloc.free(bitmap);
|
||||
|
||||
// Convert the format of the bitmap if necessary
|
||||
const bitmap_formatted: []u8 = switch (atlas.format) {
|
||||
// Bitmap is already in RGBA
|
||||
.rgba => bitmap,
|
||||
|
||||
// Convert down to A8
|
||||
.greyscale => a8: {
|
||||
assert(@mod(bitmap.len, 4) == 0);
|
||||
var bitmap_a8 = try alloc.alloc(u8, bitmap.len / 4);
|
||||
errdefer alloc.free(bitmap_a8);
|
||||
var i: usize = 0;
|
||||
while (i < bitmap_a8.len) : (i += 1) {
|
||||
bitmap_a8[i] = bitmap[(i * 4) + 3];
|
||||
}
|
||||
|
||||
break :a8 bitmap_a8;
|
||||
},
|
||||
|
||||
else => return error.UnsupportedAtlasFormat,
|
||||
};
|
||||
defer if (bitmap_formatted.ptr != bitmap.ptr) alloc.free(bitmap_formatted);
|
||||
|
||||
// Put it in our atlas
|
||||
const region = try atlas.reserve(alloc, width, height);
|
||||
if (region.width > 0 and region.height > 0) atlas.set(region, bitmap_formatted);
|
||||
|
||||
return font.Glyph{
|
||||
return RenderedGlyph{
|
||||
.alloc = alloc,
|
||||
.metrics = metrics,
|
||||
.width = width,
|
||||
.height = height,
|
||||
// TODO: this can't be right
|
||||
.offset_x = 0,
|
||||
.offset_y = 0,
|
||||
.atlas_x = region.x,
|
||||
.atlas_y = region.y,
|
||||
.advance_x = 0,
|
||||
.bitmap = bitmap,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
self.metrics = .{
|
||||
.cell_width = cell_width,
|
||||
.cell_height = cell_height,
|
||||
.cell_baseline = cell_baseline,
|
||||
.underline_position = underline_position,
|
||||
.underline_thickness = underline_thickness,
|
||||
.strikethrough_position = underline_position,
|
||||
.strikethrough_thickness = underline_thickness,
|
||||
};
|
||||
|
||||
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
|
||||
var 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;
|
||||
}
|
||||
};
|
||||
|
||||
/// The wasm-compatible API.
|
||||
|
@ -129,7 +129,7 @@ pub const Wasm = struct {
|
||||
|
||||
var iter = self.runIterator(group, row);
|
||||
while (try iter.next(alloc)) |run| {
|
||||
log.info("y={} run={}", .{ y, run });
|
||||
log.info("y={} run={d} idx={}", .{ y, run.cells, run.font_index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user