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:
Mitchell Hashimoto
2022-12-07 19:20:04 -08:00
parent 2c5b5429e3
commit 0c9a9b1f91
3 changed files with 223 additions and 130 deletions

View File

@ -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 => {},

View File

@ -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.

View File

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