font/sprite: rework undercurl, fix out of bounds underlines

Use z2d to draw the undercurl instead of the manual raster code we had
before- the code was cool but unnecessarily complicated. Plus z2d lets
us have rounded caps on the undercurl which is neat.

Also make sure we won't draw off the canvas with our underlines-- the
canvas has padding but it's not infinite.
pull/9654/head
Qwerasd 2025-11-20 12:44:01 -07:00
parent 410d79b151
commit 81a6c24186
2 changed files with 128 additions and 109 deletions

View File

@ -30,6 +30,7 @@ metrics: font.Metrics,
pub const DrawFnError =
Allocator.Error ||
z2d.Path.Error ||
z2d.painter.FillError ||
z2d.painter.StrokeError ||
error{

View File

@ -20,11 +20,19 @@ pub fn underline(
metrics: font.Metrics,
) !void {
_ = cp;
_ = height;
// We can go beyond the height of the cell a bit, but
// we want to be sure never to exceed the height of the
// canvas, which extends a quarter cell below the cell
// height.
const y = @min(
metrics.underline_position,
height +| canvas.padding_y -| metrics.underline_thickness,
);
canvas.rect(.{
.x = 0,
.y = @intCast(metrics.underline_position),
.y = @intCast(y),
.width = @intCast(width),
.height = @intCast(metrics.underline_thickness),
}, .on);
@ -38,20 +46,28 @@ pub fn underline_double(
metrics: font.Metrics,
) !void {
_ = cp;
_ = height;
// We can go beyond the height of the cell a bit, but
// we want to be sure never to exceed the height of the
// canvas, which extends a quarter cell below the cell
// height.
const y = @min(
metrics.underline_position,
height +| canvas.padding_y -| 2 * metrics.underline_thickness,
);
// We place one underline above the underline position, and one below
// by one thickness, creating a "negative" underline where the single
// underline would be placed.
canvas.rect(.{
.x = 0,
.y = @intCast(metrics.underline_position -| metrics.underline_thickness),
.y = @intCast(y -| metrics.underline_thickness),
.width = @intCast(width),
.height = @intCast(metrics.underline_thickness),
}, .on);
canvas.rect(.{
.x = 0,
.y = @intCast(metrics.underline_position +| metrics.underline_thickness),
.y = @intCast(y +| metrics.underline_thickness),
.width = @intCast(width),
.height = @intCast(metrics.underline_thickness),
}, .on);
@ -65,12 +81,32 @@ pub fn underline_dotted(
metrics: font.Metrics,
) !void {
_ = cp;
_ = height;
// TODO: Rework this now that we can go out of bounds, just
// make sure that adjacent versions of this glyph align.
const dot_width = @max(metrics.underline_thickness, 3);
const dot_count = @max((width / dot_width) / 2, 1);
// We can go beyond the height of the cell a bit, but
// we want to be sure never to exceed the height of the
// canvas, which extends a quarter cell below the cell
// height.
const y = @min(
metrics.underline_position,
height +| canvas.padding_y -| metrics.underline_thickness,
);
const dot_width = @max(
// Dots should be at least as thick as the underline.
metrics.underline_thickness,
// At least as thick as a quarter of the cell, since
// less than that starts to look a little bit silly.
metrics.cell_width / 4,
// And failing all else, be at least 1 pixel wide.
1,
);
const dot_count = @max(
// We should try to have enough dots that the
// space between them is the same as their size.
(width / dot_width) / 2,
// And we must have at least one dot per cell.
1,
);
const gap_width = std.math.divCeil(
u32,
width -| (dot_count * dot_width),
@ -78,13 +114,11 @@ pub fn underline_dotted(
) catch return error.MathError;
var i: u32 = 0;
while (i < dot_count) : (i += 1) {
// Ensure we never go out of bounds for the rect
const x = @min(i * (dot_width + gap_width), width - 1);
const rect_width = @min(width - x, dot_width);
const x = i * (dot_width + gap_width);
canvas.rect(.{
.x = @intCast(x),
.y = @intCast(metrics.underline_position),
.width = @intCast(rect_width),
.y = @intCast(y),
.width = @intCast(dot_width),
.height = @intCast(metrics.underline_thickness),
}, .on);
}
@ -98,19 +132,25 @@ pub fn underline_dashed(
metrics: font.Metrics,
) !void {
_ = cp;
_ = height;
// We can go beyond the height of the cell a bit, but
// we want to be sure never to exceed the height of the
// canvas, which extends a quarter cell below the cell
// height.
const y = @min(
metrics.underline_position,
height +| canvas.padding_y -| metrics.underline_thickness,
);
const dash_width = width / 3 + 1;
const dash_count = (width / dash_width) + 1;
var i: u32 = 0;
while (i < dash_count) : (i += 2) {
// Ensure we never go out of bounds for the rect
const x = @min(i * dash_width, width - 1);
const rect_width = @min(width - x, dash_width);
const x = i * dash_width;
canvas.rect(.{
.x = @intCast(x),
.y = @intCast(metrics.underline_position),
.width = @intCast(rect_width),
.y = @intCast(y),
.width = @intCast(dash_width),
.height = @intCast(metrics.underline_thickness),
}, .on);
}
@ -124,105 +164,66 @@ pub fn underline_curly(
metrics: font.Metrics,
) !void {
_ = cp;
_ = height;
// TODO: Rework this using z2d, this is pretty cool code and all but
// it doesn't need to be highly optimized and z2d path drawing
// code would be clearer and nicer to have.
var ctx = canvas.getContext();
defer ctx.deinit();
const float_width: f64 = @floatFromInt(width);
const float_height: f64 = @floatFromInt(height);
const float_pos: f64 = @floatFromInt(metrics.underline_position);
// Because of we way we draw the undercurl, we end up making it around 1px
// thicker than it should be, to fix this we just reduce the thickness by 1.
//
// We use a minimum thickness of 0.414 because this empirically produces
// the nicest undercurls at 1px underline thickness; thinner tends to look
// too thin compared to straight underlines and has artefacting.
const float_thick: f64 = @max(
0.414,
@as(f64, @floatFromInt(metrics.underline_thickness -| 1)),
ctx.line_width = @floatFromInt(metrics.underline_thickness);
// Rounded caps, adjacent underlines will have these overlap and so not be
// visible, but it makes the ends look cleaner.
ctx.line_cap_mode = .round;
// Empirically this looks good.
const amplitude = float_width / std.math.pi;
// Make sure we don't exceed the drawable area. This can still be outside
// of the cell by some amount (one quarter of the height), but we don't
// want underlines to disappear for fonts with bad metadata or when users
// set their underline position way too low.
const padding: f64 = @floatFromInt(canvas.padding_y);
const top: f64 = @min(
float_pos,
// The lowest we can draw this and not get clipped.
float_height + padding - amplitude - ctx.line_width,
);
const bottom = top + amplitude;
// Calculate the wave period for a single character
// `2 * pi...` = 1 peak per character
// `4 * pi...` = 2 peaks per character
const wave_period = 2 * std.math.pi / float_width;
// Curvature multiplier.
// To my eye, 0.4 creates a nice smooth wiggle.
const r = 0.4;
// The full amplitude of the wave can be from the bottom to the
// underline position. We also calculate our mid y point of the wave
const half_amplitude = 1.0 / wave_period;
const y_mid: f64 = half_amplitude + float_thick * 0.5 + 1;
const center = 0.5 * float_width;
// Offset to move the undercurl up slightly.
const y_off: u32 = @intFromFloat(half_amplitude * 0.5);
// This is used in calculating the offset curve estimate below.
const offset_factor = @min(1.0, float_thick * 0.5 * wave_period) * @min(
1.0,
half_amplitude * wave_period,
// We create a single cycle of a wave that peaks at the center of the cell.
try ctx.moveTo(0, bottom);
try ctx.curveTo(
center * r,
bottom,
center - center * r,
top,
center,
top,
);
// follow Xiaolin Wu's antialias algorithm to draw the curve
var x: u32 = 0;
while (x < width) : (x += 1) {
// We sample the wave function at the *middle* of each
// pixel column, to ensure that it renders symmetrically.
const t: f64 = (@as(f64, @floatFromInt(x)) + 0.5) * wave_period;
// Use the slope at this location to add thickness to
// the line on this column, counteracting the thinning
// caused by the slope.
//
// This is not the exact offset curve for a sine wave,
// but it's a decent enough approximation.
//
// How did I derive this? I stared at Desmos and fiddled
// with numbers for an hour until it was good enough.
const t_u: f64 = t + std.math.pi;
const slope_factor_u: f64 =
(@sin(t_u) * @sin(t_u) * offset_factor) /
((1.0 + @cos(t_u / 2) * @cos(t_u / 2) * 2) * wave_period);
const slope_factor_l: f64 =
(@sin(t) * @sin(t) * offset_factor) /
((1.0 + @cos(t / 2) * @cos(t / 2) * 2) * wave_period);
const cosx: f64 = @cos(t);
// This will be the center of our stroke.
const y: f64 = y_mid + half_amplitude * cosx;
// The upper pixel and lower pixel are
// calculated relative to the center.
const y_u: f64 = y - float_thick * 0.5 - slope_factor_u;
const y_l: f64 = y + float_thick * 0.5 + slope_factor_l;
const y_upper: u32 = @intFromFloat(@floor(y_u));
const y_lower: u32 = @intFromFloat(@ceil(y_l));
const alpha_u: u8 = @intFromFloat(
@round(255 * (1.0 - @abs(y_u - @floor(y_u)))),
try ctx.curveTo(
center + center * r,
top,
float_width - center * r,
bottom,
float_width,
bottom,
);
const alpha_l: u8 = @intFromFloat(
@round(255 * (1.0 - @abs(y_l - @ceil(y_l)))),
);
// upper and lower bounds
canvas.pixel(
@intCast(x),
@intCast(metrics.underline_position +| y_upper -| y_off),
@enumFromInt(alpha_u),
);
canvas.pixel(
@intCast(x),
@intCast(metrics.underline_position +| y_lower -| y_off),
@enumFromInt(alpha_l),
);
// fill between upper and lower bound
var y_fill: u32 = y_upper + 1;
while (y_fill < y_lower) : (y_fill += 1) {
canvas.pixel(
@intCast(x),
@intCast(metrics.underline_position +| y_fill -| y_off),
.on,
);
}
}
try ctx.stroke();
}
pub fn strikethrough(
@ -253,9 +254,18 @@ pub fn overline(
_ = cp;
_ = height;
// We can go beyond the top of the cell a bit, but we
// want to be sure never to exceed the height of the
// canvas, which extends a quarter cell above the top
// of the cell.
const y = @max(
metrics.overline_position,
-@as(i32, @intCast(canvas.padding_y)),
);
canvas.rect(.{
.x = 0,
.y = @intCast(metrics.overline_position),
.y = y,
.width = @intCast(width),
.height = @intCast(metrics.overline_thickness),
}, .on);
@ -335,11 +345,19 @@ pub fn cursor_underline(
metrics: font.Metrics,
) !void {
_ = cp;
_ = height;
// We can go beyond the height of the cell a bit, but
// we want to be sure never to exceed the height of the
// canvas, which extends a quarter cell below the cell
// height.
const y = @min(
metrics.underline_position,
height +| canvas.padding_y -| metrics.underline_thickness,
);
canvas.rect(.{
.x = 0,
.y = @intCast(metrics.underline_position),
.y = @intCast(y),
.width = @intCast(width),
.height = @intCast(metrics.cursor_thickness),
}, .on);