Unified metrics fixes (#2959)

Fix a couple issues that arose from the unified metrics stuff.

One is that cursor sprites weren't rendering wide for wide characters-
I've extracted cursor rendering to a separate file so it's not mixed in
with Box any more, it should be correct now.

The other issue is that I was assuming most fonts had sane `sTypo*`
metrics for vertical sizing, but I was very wrong, so I've replaced the
extraction of the vertical metrics with code that performs a similar
process to what FreeType does to determine a font's ascent. This fixes
the problem, here's an example with a font with the issue:
|Before|After|
|-|-|
|<img width="752" alt="image"
src="https://github.com/user-attachments/assets/71752b0c-35d7-4c35-b5bb-301149d906a7"
/>|<img width="752" alt="image"
src="https://github.com/user-attachments/assets/2f9ae8ce-9f3a-4701-b0fe-e032da7e2246"
/>|
This commit is contained in:
Mitchell Hashimoto
2024-12-13 10:08:25 -08:00
committed by GitHub
6 changed files with 215 additions and 85 deletions

View File

@ -217,6 +217,7 @@ pub const SfntTag = enum(c_int) {
.os2 => c.TT_OS2,
.head => c.TT_Header,
.post => c.TT_Postscript,
.hhea => c.TT_HoriHeader,
else => unreachable, // As-needed...
};
}

View File

@ -536,6 +536,7 @@ pub const Face = struct {
InvalidPostTable,
InvalidOS2Table,
OS2VersionNotSupported,
InvalidHheaTable,
};
fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics {
@ -563,7 +564,7 @@ pub const Face = struct {
const len = data.getLength();
break :post opentype.Post.init(ptr[0..len]) catch |err| {
return switch (err) {
error.EndOfStream => error.InvalidOS2Table,
error.EndOfStream => error.InvalidPostTable,
};
};
};
@ -583,13 +584,73 @@ pub const Face = struct {
};
};
// Read the 'hhea' table out of the font data.
const hhea: opentype.Hhea = hhea: {
const tag = macos.text.FontTableTag.init("hhea");
const data = ct_font.copyTable(tag) orelse return error.CopyTableError;
defer data.release();
const ptr = data.getPointer();
const len = data.getLength();
break :hhea opentype.Hhea.init(ptr[0..len]) catch |err| {
return switch (err) {
error.EndOfStream => error.InvalidHheaTable,
};
};
};
const units_per_em: f64 = @floatFromInt(head.unitsPerEm);
const px_per_em: f64 = ct_font.getSize();
const px_per_unit: f64 = px_per_em / units_per_em;
const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit;
const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit;
const line_gap = @as(f64, @floatFromInt(os2.sTypoLineGap)) * px_per_unit;
const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: {
const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender);
const os2_descent: f64 = @floatFromInt(os2.sTypoDescender);
const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap);
// If the font says to use typo metrics, trust it.
if (os2.fsSelection.use_typo_metrics) {
break :vertical_metrics .{
os2_ascent * px_per_unit,
os2_descent * px_per_unit,
os2_line_gap * px_per_unit,
};
}
// Otherwise we prefer the height metrics from 'hhea' if they
// are available, or else OS/2 sTypo* metrics, and if all else
// fails then we use OS/2 usWin* metrics.
//
// This is not "standard" behavior, but it's our best bet to
// account for fonts being... just weird. It's pretty much what
// FreeType does to get its generic ascent and descent metrics.
if (hhea.ascender != 0 or hhea.descender != 0) {
const hhea_ascent: f64 = @floatFromInt(hhea.ascender);
const hhea_descent: f64 = @floatFromInt(hhea.descender);
const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap);
break :vertical_metrics .{
hhea_ascent * px_per_unit,
hhea_descent * px_per_unit,
hhea_line_gap * px_per_unit,
};
}
if (os2_ascent != 0 or os2_descent != 0) {
break :vertical_metrics .{
os2_ascent * px_per_unit,
os2_descent * px_per_unit,
os2_line_gap * px_per_unit,
};
}
const win_ascent: f64 = @floatFromInt(os2.usWinAscent);
const win_descent: f64 = @floatFromInt(os2.usWinDescent);
break :vertical_metrics .{
win_ascent * px_per_unit,
win_descent * px_per_unit,
0.0,
};
};
// Some fonts have degenerate 'post' tables where the underline
// thickness (and often position) are 0. We consider them null

View File

@ -631,6 +631,9 @@ pub const Face = struct {
// Read the 'OS/2' table out of the font data.
const os2 = face.getSfntTable(.os2) orelse return error.CopyTableError;
// Read the 'hhea' table out of the font data.
const hhea = face.getSfntTable(.hhea) orelse return error.CopyTableError;
// Some fonts don't actually have an OS/2 table, which
// we need in order to do the metrics calculations, in
// such cases FreeType sets the version to 0xFFFF
@ -640,9 +643,56 @@ pub const Face = struct {
const px_per_em: f64 = @floatFromInt(size_metrics.y_ppem);
const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em));
const ascent = @as(f64, @floatFromInt(os2.sTypoAscender)) * px_per_unit;
const descent = @as(f64, @floatFromInt(os2.sTypoDescender)) * px_per_unit;
const line_gap = @as(f64, @floatFromInt(os2.sTypoLineGap)) * px_per_unit;
const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: {
const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender);
const os2_descent: f64 = @floatFromInt(os2.sTypoDescender);
const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap);
// If the font says to use typo metrics, trust it.
// (The USE_TYPO_METRICS bit is bit 7)
if (os2.fsSelection & (1 << 7) != 0) {
break :vertical_metrics .{
os2_ascent * px_per_unit,
os2_descent * px_per_unit,
os2_line_gap * px_per_unit,
};
}
// Otherwise we prefer the height metrics from 'hhea' if they
// are available, or else OS/2 sTypo* metrics, and if all else
// fails then we use OS/2 usWin* metrics.
//
// This is not "standard" behavior, but it's our best bet to
// account for fonts being... just weird. It's pretty much what
// FreeType does to get its generic ascent and descent metrics.
if (hhea.Ascender != 0 or hhea.Descender != 0) {
const hhea_ascent: f64 = @floatFromInt(hhea.Ascender);
const hhea_descent: f64 = @floatFromInt(hhea.Descender);
const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap);
break :vertical_metrics .{
hhea_ascent * px_per_unit,
hhea_descent * px_per_unit,
hhea_line_gap * px_per_unit,
};
}
if (os2_ascent != 0 or os2_descent != 0) {
break :vertical_metrics .{
os2_ascent * px_per_unit,
os2_descent * px_per_unit,
os2_line_gap * px_per_unit,
};
}
const win_ascent: f64 = @floatFromInt(os2.usWinAscent);
const win_descent: f64 = @floatFromInt(os2.usWinDescent);
break :vertical_metrics .{
win_ascent * px_per_unit,
win_descent * px_per_unit,
0.0,
};
};
// Some fonts have degenerate 'post' tables where the underline
// thickness (and often position) are 0. We consider them null

View File

@ -214,26 +214,11 @@ pub fn renderGlyph(
) !font.Glyph {
const metrics = self.metrics;
// Some codepoints (such as a few cursors) should not
// grow when the cell height is adjusted to be larger.
// And we also will need to adjust the vertical position.
const height, const dy = adjust: {
const h = metrics.cell_height;
if (unadjustedCodepoint(cp)) {
if (metrics.original_cell_height) |original| {
if (h > original) {
break :adjust .{ original, (h - original) / 2 };
}
}
}
break :adjust .{ h, 0 };
};
// Create the canvas we'll use to draw
var canvas = try font.sprite.Canvas.init(
alloc,
metrics.cell_width,
height,
metrics.cell_height,
);
defer canvas.deinit(alloc);
@ -246,15 +231,11 @@ pub fn renderGlyph(
// Our coordinates start at the BOTTOM for our renderers so we have to
// specify an offset of the full height because we rendered a full size
// cell.
//
// If we have an adjustment (see above) to the cell height that we need
// to account for, we subtract half the difference (dy) to keep the glyph
// centered.
const offset_y = @as(i32, @intCast(metrics.cell_height - dy));
const offset_y = @as(i32, @intCast(metrics.cell_height));
return font.Glyph{
.width = metrics.cell_width,
.height = height,
.height = metrics.cell_height,
.offset_x = 0,
.offset_y = offset_y,
.atlas_x = region.x,
@ -263,19 +244,6 @@ pub fn renderGlyph(
};
}
/// Returns true if this codepoint should be rendered with the
/// width/height set to unadjusted values.
pub fn unadjustedCodepoint(cp: u32) bool {
return switch (cp) {
@intFromEnum(Sprite.cursor_rect),
@intFromEnum(Sprite.cursor_hollow_rect),
@intFromEnum(Sprite.cursor_bar),
=> true,
else => false,
};
}
fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void {
_ = alloc;
switch (cp) {
@ -1656,12 +1624,6 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
.right = true,
}, .light),
// Not official box characters but special characters we hide
// in the high bits of a unicode codepoint.
@intFromEnum(Sprite.cursor_rect) => self.draw_cursor_rect(canvas),
@intFromEnum(Sprite.cursor_hollow_rect) => self.draw_cursor_hollow_rect(canvas),
@intFromEnum(Sprite.cursor_bar) => self.draw_cursor_bar(canvas),
else => return error.InvalidCodepoint,
}
}
@ -2842,42 +2804,6 @@ fn draw_dash_vertical(
}
}
fn draw_cursor_rect(self: Box, canvas: *font.sprite.Canvas) void {
// The cursor should fit itself to the canvas it's given, since if
// the cell height is adjusted upwards it will be given a canvas
// with the original un-adjusted height, so we can't use the height
// from the metrics.
const height: u32 = @intCast(canvas.sfc.getHeight());
self.rect(canvas, 0, 0, self.metrics.cell_width, height);
}
fn draw_cursor_hollow_rect(self: Box, canvas: *font.sprite.Canvas) void {
// The cursor should fit itself to the canvas it's given, since if
// the cell height is adjusted upwards it will be given a canvas
// with the original un-adjusted height, so we can't use the height
// from the metrics.
const height: u32 = @intCast(canvas.sfc.getHeight());
const thick_px = Thickness.super_light.height(self.metrics.cursor_thickness);
self.rect(canvas, 0, 0, self.metrics.cell_width, thick_px);
self.rect(canvas, 0, 0, thick_px, height);
self.rect(canvas, self.metrics.cell_width -| thick_px, 0, self.metrics.cell_width, height);
self.rect(canvas, 0, height -| thick_px, self.metrics.cell_width, height);
}
fn draw_cursor_bar(self: Box, canvas: *font.sprite.Canvas) void {
// The cursor should fit itself to the canvas it's given, since if
// the cell height is adjusted upwards it will be given a canvas
// with the original un-adjusted height, so we can't use the height
// from the metrics.
const height: u32 = @intCast(canvas.sfc.getHeight());
const thick_px = Thickness.light.height(self.metrics.cursor_thickness);
self.rect(canvas, 0, 0, thick_px, height);
}
fn vline_middle(self: Box, canvas: *font.sprite.Canvas, thickness: Thickness) void {
const thick_px = thickness.height(self.metrics.box_thickness);
self.vline(canvas, 0, self.metrics.cell_height, (self.metrics.cell_width -| thick_px) / 2, thick_px);

View File

@ -21,6 +21,7 @@ const Sprite = font.sprite.Sprite;
const Box = @import("Box.zig");
const Powerline = @import("Powerline.zig");
const underline = @import("underline.zig");
const cursor = @import("cursor.zig");
const log = std.log.scoped(.font_sprite);
@ -123,6 +124,35 @@ pub fn renderGlyph(
break :powerline try f.renderGlyph(alloc, atlas, cp);
},
.cursor => cursor: {
// Cursors should be drawn with the original cell height if
// it has been adjusted larger, so they don't get stretched.
const height, const dy = adjust: {
const h = metrics.cell_height;
if (metrics.original_cell_height) |original| {
if (h > original) {
break :adjust .{ original, (h - original) / 2 };
}
}
break :adjust .{ h, 0 };
};
var g = try cursor.renderGlyph(
alloc,
atlas,
@enumFromInt(cp),
width,
height,
metrics.cursor_thickness,
);
// Keep the cursor centered in the cell if it's shorter.
g.offset_y += @intCast(dy);
break :cursor g;
},
};
}
@ -133,6 +163,7 @@ const Kind = enum {
overline,
strikethrough,
powerline,
cursor,
pub fn init(cp: u32) ?Kind {
return switch (cp) {
@ -153,7 +184,7 @@ const Kind = enum {
.cursor_rect,
.cursor_hollow_rect,
.cursor_bar,
=> .box,
=> .cursor,
},
// == Box fonts ==

View File

@ -0,0 +1,61 @@
//! This file renders cursor sprites.
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const font = @import("../main.zig");
const Sprite = font.sprite.Sprite;
/// Draw a cursor.
pub fn renderGlyph(
alloc: Allocator,
atlas: *font.Atlas,
sprite: Sprite,
width: u32,
height: u32,
thickness: u32,
) !font.Glyph {
// Make a canvas of the desired size
var canvas = try font.sprite.Canvas.init(alloc, width, height);
defer canvas.deinit(alloc);
// Draw the appropriate sprite
switch (sprite) {
Sprite.cursor_rect => canvas.rect(.{
.x = 0,
.y = 0,
.width = width,
.height = height,
}, .on),
Sprite.cursor_hollow_rect => {
// left
canvas.rect(.{ .x = 0, .y = 0, .width = thickness, .height = height }, .on);
// right
canvas.rect(.{ .x = width -| thickness, .y = 0, .width = thickness, .height = height }, .on);
// top
canvas.rect(.{ .x = 0, .y = 0, .width = width, .height = thickness }, .on);
// bottom
canvas.rect(.{ .x = 0, .y = height -| thickness, .width = width, .height = thickness }, .on);
},
Sprite.cursor_bar => canvas.rect(.{
.x = 0,
.y = 0,
.width = thickness,
.height = height,
}, .on),
else => unreachable,
}
// Write the drawing to the atlas
const region = try canvas.writeAtlas(alloc, atlas);
return font.Glyph{
.width = width,
.height = height,
.offset_x = 0,
.offset_y = @intCast(height),
.atlas_x = region.x,
.atlas_y = region.y,
.advance_x = @floatFromInt(width),
};
}