Underline Drawing Fixes (#9654)

Reworked the undercurl and dotted underline to use z2d, the undercurl is
almost identical (slightly better, it has rounded end caps now) but
without the really complicated code, and the dotted underline is greatly
improved since it now draws as anti-aliased dots that are evenly spaced
instead of rectangles that might have uneven spacing at the wrong font
size.

Also fixes #9394 since I added code that makes sure that none of the
underlines is drawn off of the edge of the canvas (the padding only goes
so far, after all).

|Size|Adjustments|Before|After|
|-|-|-|-|
|32|none|<img width="640" height="964" alt="image"
src="https://github.com/user-attachments/assets/20e44d89-5825-496e-b197-bd1ca6c685f7"
/>|<img width="640" height="964" alt="image"
src="https://github.com/user-attachments/assets/b1fb8499-26fd-42ec-8186-1677ab29048b"
/>|
|11|none|<img width="234" height="358" alt="image"
src="https://github.com/user-attachments/assets/5db89f15-128c-4780-826c-a4f59026a0af"
/>|<img width="234" height="358" alt="image"
src="https://github.com/user-attachments/assets/c9a2855a-284e-46d8-b824-0ada2a0ac386"
/>|
|9|underline +8, overline -8|<img width="206" height="294" alt="image"
src="https://github.com/user-attachments/assets/7b3a6d7e-9bb5-4ebb-8881-57878553a47d"
/>|<img width="206" height="294" alt="image"
src="https://github.com/user-attachments/assets/3a8dc97e-7f71-4c6b-821c-49262e6ff9a9"
/>|
pull/9659/head
Mitchell Hashimoto 2025-11-20 11:09:05 -10:00 committed by GitHub
commit 9955b43e0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 149 additions and 120 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,29 +81,57 @@ 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);
const gap_width = std.math.divCeil(
u32,
width -| (dot_count * dot_width),
dot_count,
) 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);
canvas.rect(.{
.x = @intCast(x),
.y = @intCast(metrics.underline_position),
.width = @intCast(rect_width),
.height = @intCast(metrics.underline_thickness),
}, .on);
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);
const float_thick: f64 = @floatFromInt(metrics.underline_thickness);
// The diameter will be sqrt2 * the usual underline thickness
// since otherwise dotted underlines look somewhat anemic.
const radius = std.math.sqrt1_2 * float_thick;
// 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 padding: f64 = @floatFromInt(canvas.padding_y);
const y = @min(
// The center of the underline stem.
float_pos + 0.5 * float_thick,
// The lowest we can go on the canvas and not get clipped.
float_height + padding - @ceil(radius),
);
const dot_count: f64 = @max(
@min(
// We should try to have enough dots that the
// space between them matches their diameter.
@ceil(float_width / (4 * radius)),
// And not enough that the space between
// each dot is less than their radius.
@floor(float_width / (3 * radius)),
// And definitely not enough that the space
// between them is less than a single pixel.
@floor(float_width / (2 * radius + 1)),
),
// And we must have at least one dot per cell.
1.0,
);
// What we essentially do is divide the cell in to
// dot_count areas with a dot centered in each one.
var x: f64 = (float_width / dot_count) / 2;
for (0..@as(usize, @intFromFloat(dot_count))) |_| {
try ctx.arc(x, y, radius, 0.0, std.math.tau);
try ctx.closePath();
x += float_width / dot_count;
}
try ctx.fill();
}
pub fn underline_dashed(
@ -98,19 +142,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 +174,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)))),
);
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.curveTo(
center + center * r,
top,
float_width - center * r,
bottom,
float_width,
bottom,
);
try ctx.stroke();
}
pub fn strikethrough(
@ -253,9 +264,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 +355,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);