feat(font): port font metrics, atlas and the sprite font

Ports ZIG/src/font/{Metrics,Atlas,Glyph}.zig and the whole sprite font
(font/sprite/**): box drawing, block elements, braille, branch/git
graph symbols, geometric shapes, powerline, and the symbols for legacy
computing (+ supplement, incl. the octants table).

Ghostty draws sprites with z2d; we use tiny-skia for path coverage,
composited onto an alpha8 canvas, while following the Zig geometry
(positions, thickness, rounding). Comptime discovery of draw functions
is replaced by explicit, overlap-checked codepoint range tables.

Upstream reference sheets are vendored in testdata/ and compared with a
tolerance: braille and U+1CD00 are pixel-identical; other sheets differ
only in antialiasing on thin diagonals/curves. Review renders are written
to target/sprite_sheets/.

The crate temporarily drops its config/unicode/terminal deps until the
rest of the font engine is ported (Phase 3).

Test Plan:
- cargo test -p ghostty-font (49 tests incl. reference sheet comparison)
- inspect target/sprite_sheets/review-all-18x36.png
This commit is contained in:
ddidderr committed 2026-10-11 21:53:29 +02:00
1 parent b2b248e003
commit 1388f117d3
58 files changed
+9331 -6

No files matched your search

+787
View File
@@ -0,0 +1,787 @@
//! This exposes primitives to draw 2D graphics and export the graphic to
//! a font atlas. Port of `font/sprite/canvas.zig`.
//!
//! Ghostty uses the `z2d` library for antialiased path drawing; this port
//! uses `tiny-skia` to rasterize path coverage (see [`Canvas::fill_path`]).
use std::ops::Sub;
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
use crate::atlas::{self, Atlas, Format, Region};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Point<T> {
pub x: T,
pub y: T,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Line<T> {
pub p0: Point<T>,
pub p1: Point<T>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Box<T> {
pub p0: Point<T>,
pub p1: Point<T>,
}
impl<T: Copy + PartialOrd + Sub<Output = T>> Box<T> {
pub fn rect(&self) -> Rect<T> {
let min = |a: T, b: T| if b < a { b } else { a };
let max = |a: T, b: T| if b > a { b } else { a };
let tl_x = min(self.p0.x, self.p1.x);
let tl_y = min(self.p0.y, self.p1.y);
let br_x = max(self.p0.x, self.p1.x);
let br_y = max(self.p0.y, self.p1.y);
Rect {
x: tl_x,
y: tl_y,
width: br_x - tl_x,
height: br_y - tl_y,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Rect<T> {
pub x: T,
pub y: T,
pub width: T,
pub height: T,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Triangle<T> {
pub p0: Point<T>,
pub p1: Point<T>,
pub p2: Point<T>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Quad<T> {
pub p0: Point<T>,
pub p1: Point<T>,
pub p2: Point<T>,
pub p3: Point<T>,
}
/// We only use alpha-channel so a pixel is an 8-bit alpha value. Zig models
/// this as a non-exhaustive `enum(u8)`; `ON` = 255, `OFF` = 0, and any other
/// value is a partial alpha (used for shades).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Color(pub u8);
impl Color {
pub const ON: Color = Color(255);
pub const OFF: Color = Color(0);
}
/// Errors that drawing functions can produce.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DrawError {
/// Something went wrong while doing math.
#[error("math error")]
MathError,
#[error("atlas error: {0}")]
Atlas(#[from] atlas::Error),
#[error("path error")]
Path,
}
/// A node of a [`Path`].
#[derive(Debug, Clone, Copy, PartialEq)]
enum PathNode {
MoveTo(f64, f64),
LineTo(f64, f64),
CurveTo(f64, f64, f64, f64, f64, f64),
ClosePath,
}
/// A path in cell-relative coordinates (the canvas applies the padding
/// translation when the path is filled or stroked, like z2d's
/// `staticPath` transformation).
#[derive(Debug, Clone, Default)]
pub struct Path {
nodes: Vec<PathNode>,
current: Option<(f64, f64)>,
start: Option<(f64, f64)>,
}
impl Path {
pub fn new() -> Path {
Path::default()
}
pub fn move_to(&mut self, x: f64, y: f64) {
self.nodes.push(PathNode::MoveTo(x, y));
self.current = Some((x, y));
self.start = Some((x, y));
}
pub fn line_to(&mut self, x: f64, y: f64) {
if self.current.is_none() {
self.move_to(x, y);
return;
}
self.nodes.push(PathNode::LineTo(x, y));
self.current = Some((x, y));
}
pub fn curve_to(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) {
if self.current.is_none() {
self.move_to(x1, y1);
}
self.nodes.push(PathNode::CurveTo(x1, y1, x2, y2, x3, y3));
self.current = Some((x3, y3));
}
/// Close the current subpath. Like z2d, the current point returns to
/// the start of the subpath.
pub fn close(&mut self) {
if self.current.is_none() {
return;
}
self.nodes.push(PathNode::ClosePath);
self.current = self.start;
}
/// Add a circular arc centered at `(x, y)` from angle `a0` to `a1`
/// (radians, clockwise in canvas space with +y down). If there is a
/// current point, a line is drawn to the start of the arc, otherwise
/// the arc starts a new subpath.
pub fn arc(&mut self, x: f64, y: f64, radius: f64, a0: f64, a1: f64) {
use std::f64::consts::FRAC_PI_2;
let (sx, sy) = (x + radius * a0.cos(), y + radius * a0.sin());
if self.current.is_some() {
self.line_to(sx, sy);
} else {
self.move_to(sx, sy);
}
let mut sweep = a1 - a0;
// Split into segments of at most a quarter turn.
let segs = ((sweep.abs() / FRAC_PI_2).ceil() as usize).max(1);
sweep /= segs as f64;
let k = 4.0 / 3.0 * (sweep / 4.0).tan();
let mut a = a0;
for _ in 0..segs {
let (s0, c0) = a.sin_cos();
let (s1, c1) = (a + sweep).sin_cos();
self.curve_to(
x + radius * (c0 - k * s0),
y + radius * (s0 + k * c0),
x + radius * (c1 + k * s1),
y + radius * (s1 - k * c1),
x + radius * c1,
y + radius * s1,
);
a += sweep;
}
}
fn to_skia(&self, dx: f64, dy: f64) -> Option<tiny_skia::Path> {
let mut pb = PathBuilder::new();
let f = |v: f64, d: f64| (v + d) as f32;
for n in &self.nodes {
match *n {
PathNode::MoveTo(x, y) => pb.move_to(f(x, dx), f(y, dy)),
PathNode::LineTo(x, y) => pb.line_to(f(x, dx), f(y, dy)),
PathNode::CurveTo(x1, y1, x2, y2, x3, y3) => pb.cubic_to(
f(x1, dx),
f(y1, dy),
f(x2, dx),
f(y2, dy),
f(x3, dx),
f(y3, dy),
),
PathNode::ClosePath => pb.close(),
}
}
pb.finish()
}
}
/// Line cap modes (subset of z2d's).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CapMode {
#[default]
Butt,
Round,
Square,
}
/// Stroke options (subset of z2d's `StrokeOptions`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StrokeOptions {
pub line_width: f64,
pub line_cap_mode: CapMode,
}
impl Default for StrokeOptions {
fn default() -> Self {
StrokeOptions {
line_width: 2.0,
line_cap_mode: CapMode::Butt,
}
}
}
/// The canvas is an 8-bit alpha surface with padding on all sides, onto
/// which sprite glyphs are drawn.
#[derive(Debug)]
pub struct Canvas {
/// The underlying alpha8 surface, row-major, `sfc_width * sfc_height`.
pub sfc: Vec<u8>,
pub sfc_width: u32,
pub sfc_height: u32,
pub padding_x: u32,
pub padding_y: u32,
pub clip_top: u32,
pub clip_left: u32,
pub clip_right: u32,
pub clip_bottom: u32,
/// Scratch pixmap used to rasterize path coverage.
scratch: Pixmap,
}
impl Canvas {
pub fn new(width: u32, height: u32, padding_x: u32, padding_y: u32) -> Canvas {
// We add padding to both sides (hence `2 *`)
let w = width + 2 * padding_x;
let h = height + 2 * padding_y;
Canvas {
sfc: vec![0; w as usize * h as usize],
sfc_width: w,
sfc_height: h,
padding_x,
padding_y,
clip_top: 0,
clip_left: 0,
clip_right: 0,
clip_bottom: 0,
// tiny-skia can't make zero sized pixmaps; sprites are never empty
// but be defensive.
scratch: Pixmap::new(w.max(1), h.max(1)).expect("non-zero pixmap size"),
}
}
/// Write the data in this drawing to the atlas.
pub fn write_atlas(&mut self, atlas: &mut Atlas) -> Result<Region, atlas::Error> {
assert_eq!(atlas.format, Format::Grayscale);
self.trim();
let sfc_width = self.sfc_width;
let sfc_height = self.sfc_height;
// Subtract our clip margins from the
// width and height to get region size.
let region_width = sfc_width
.saturating_sub(self.clip_left)
.saturating_sub(self.clip_right);
let region_height = sfc_height
.saturating_sub(self.clip_top)
.saturating_sub(self.clip_bottom);
// Allocate our texture atlas region
let region = atlas.reserve(region_width, region_height)?;
if region.width > 0 && region.height > 0 {
// Write the glyph information into the atlas
assert_eq!(region.width, region_width);
assert_eq!(region.height, region_height);
atlas.set_from_larger(region, &self.sfc, sfc_width, self.clip_left, self.clip_top);
}
Ok(region)
}
/// Adjust clip boundaries to trim off any fully transparent rows or columns.
fn trim(&mut self) {
let width = self.sfc_width;
let height = self.sfc_height;
let buf = &self.sfc;
let px = |x: u32, y: u32| buf[y as usize * width as usize + x as usize];
while self.clip_top < height - self.clip_bottom {
let y = self.clip_top;
let x0 = self.clip_left;
let x1 = width - self.clip_right;
if (x0..x1).any(|x| px(x, y) != 0) {
break;
}
self.clip_top += 1;
}
while self.clip_bottom < height - self.clip_top {
let y = (height - self.clip_bottom).saturating_sub(1);
let x0 = self.clip_left;
let x1 = width - self.clip_right;
if (x0..x1).any(|x| px(x, y) != 0) {
break;
}
self.clip_bottom += 1;
}
while self.clip_left < width - self.clip_right {
let x = self.clip_left;
let y0 = self.clip_top;
let y1 = height - self.clip_bottom;
if (y0..y1).any(|y| px(x, y) != 0) {
break;
}
self.clip_left += 1;
}
while self.clip_right < width - self.clip_left {
let x = (width - self.clip_right).saturating_sub(1);
let y0 = self.clip_top;
let y1 = height - self.clip_bottom;
if (y0..y1).any(|y| px(x, y) != 0) {
break;
}
self.clip_right += 1;
}
}
/// Only really useful for test purposes, since the clipping region is
/// automatically excluded when writing to an atlas with `write_atlas`.
pub fn clear_clipping_regions(&mut self) {
let width = self.sfc_width as usize;
let height = self.sfc_height as usize;
for y in 0..height {
for x in 0..self.clip_left as usize {
self.sfc[y * width + x] = 0;
}
}
for y in 0..height {
for x in (width - self.clip_right as usize)..width {
self.sfc[y * width + x] = 0;
}
}
for y in 0..self.clip_top as usize {
for x in 0..width {
self.sfc[y * width + x] = 0;
}
}
for y in (height - self.clip_bottom as usize)..height {
for x in 0..width {
self.sfc[y * width + x] = 0;
}
}
}
/// Reset the canvas (pixels and clip margins) for reuse. Used by tests
/// that render many glyphs onto a single canvas.
pub fn reset(&mut self) {
self.sfc.fill(0);
self.clip_top = 0;
self.clip_left = 0;
self.clip_right = 0;
self.clip_bottom = 0;
}
/// Draw and fill a single pixel (out-of-bounds pixels are ignored).
pub fn pixel(&mut self, x: i32, y: i32, color: Color) {
let x = i64::from(x) + i64::from(self.padding_x);
let y = i64::from(y) + i64::from(self.padding_y);
if x < 0 || y < 0 || x >= i64::from(self.sfc_width) || y >= i64::from(self.sfc_height) {
return;
}
self.sfc[y as usize * self.sfc_width as usize + x as usize] = color.0;
}
/// Draw and fill a rectangle. This is the main primitive for drawing
/// lines as well (which are just generally skinny rectangles...)
pub fn rect(&mut self, v: Rect<i32>, color: Color) {
let mut y = v.y;
while y < v.y + v.height {
let mut x = v.x;
while x < v.x + v.width {
self.pixel(x, y, color);
x += 1;
}
y += 1;
}
}
/// Convenience wrapper for `Canvas::rect`
pub fn box_(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) {
self.rect(
Box {
p0: Point { x: x0, y: y0 },
p1: Point { x: x1, y: y1 },
}
.rect(),
color,
);
}
/// Draw and fill a quad.
pub fn quad(&mut self, q: Quad<f64>, color: Color) -> Result<(), DrawError> {
let mut path = Path::new();
path.move_to(q.p0.x, q.p0.y);
path.line_to(q.p1.x, q.p1.y);
path.line_to(q.p2.x, q.p2.y);
path.line_to(q.p3.x, q.p3.y);
path.close();
self.fill_path(&path, color)
}
/// Draw and fill a triangle.
pub fn triangle(&mut self, t: Triangle<f64>, color: Color) -> Result<(), DrawError> {
let mut path = Path::new();
path.move_to(t.p0.x, t.p0.y);
path.line_to(t.p1.x, t.p1.y);
path.line_to(t.p2.x, t.p2.y);
path.close();
self.fill_path(&path, color)
}
/// Stroke a line.
pub fn line(&mut self, l: Line<f64>, thickness: f64, color: Color) -> Result<(), DrawError> {
let mut path = Path::new();
path.move_to(l.p0.x, l.p0.y);
path.line_to(l.p1.x, l.p1.y);
self.stroke_path(
&path,
StrokeOptions {
line_cap_mode: CapMode::Butt,
line_width: thickness,
},
color,
)
}
/// Rasterize `path` (filled or stroked) into the scratch pixmap and
/// return nothing; the coverage is then in `self.scratch`'s alpha.
fn rasterize(&mut self, path: &Path, stroke: Option<StrokeOptions>) -> bool {
self.scratch.fill(tiny_skia::Color::TRANSPARENT);
let Some(p) = path.to_skia(0.0, 0.0) else {
return false;
};
let mut paint = Paint::default();
paint.set_color_rgba8(0, 0, 0, 255);
paint.anti_alias = true;
let ts = Transform::from_translate(self.padding_x as f32, self.padding_y as f32);
match stroke {
None => {
self.scratch
.fill_path(&p, &paint, FillRule::Winding, ts, None);
}
Some(opts) => {
let s = Stroke {
width: opts.line_width as f32,
miter_limit: 10.0,
line_cap: match opts.line_cap_mode {
CapMode::Butt => LineCap::Butt,
CapMode::Round => LineCap::Round,
CapMode::Square => LineCap::Square,
},
line_join: LineJoin::Miter,
dash: None,
};
self.scratch.stroke_path(&p, &paint, &s, ts, None);
}
}
true
}
/// Composite the scratch coverage onto the canvas using `src_over`
/// with an opaque pattern of alpha `color`.
fn composite_scratch(&mut self, color: Color) {
let a = u32::from(color.0);
for (dst, px) in self
.sfc
.iter_mut()
.zip(self.scratch.data().as_chunks::<4>().0.iter())
{
let cov = u32::from(px[3]);
if cov == 0 {
continue;
}
let src = (cov * a + 127) / 255;
let d = u32::from(*dst);
*dst = (src + (d * (255 - src) + 127) / 255).min(255) as u8;
}
}
/// Stroke a path.
pub fn stroke_path(
&mut self,
path: &Path,
opts: StrokeOptions,
color: Color,
) -> Result<(), DrawError> {
if self.rasterize(path, Some(opts)) {
self.composite_scratch(color);
}
Ok(())
}
/// Do an inner stroke on a path: the shape is filled to make a mask,
/// then stroked with double width, and the two are multiplied so only
/// the part of the stroke inside the shape remains.
pub fn inner_stroke_path(
&mut self,
path: &Path,
opts: StrokeOptions,
color: Color,
) -> Result<(), DrawError> {
// Make a closed version of the path for our fill, so
// that we can support open paths for inner stroke.
let mut closed = path.clone();
closed.close();
let mut fill = vec![0u8; self.sfc.len()];
if self.rasterize(&closed, None) {
for (d, px) in fill
.iter_mut()
.zip(self.scratch.data().as_chunks::<4>().0.iter())
{
*d = px[3];
}
}
// Stroke the shape with double the desired width.
let mut stroke = vec![0u8; self.sfc.len()];
let mut mut_opts = opts;
mut_opts.line_width *= 2.0;
if self.rasterize(path, Some(mut_opts)) {
let a = u32::from(color.0);
for (d, px) in stroke
.iter_mut()
.zip(self.scratch.data().as_chunks::<4>().0.iter())
{
*d = ((u32::from(px[3]) * a + 127) / 255) as u8;
}
}
// Multiply the stroke onto the fill, then composite over the canvas.
for ((dst, f), s) in self.sfc.iter_mut().zip(&fill).zip(&stroke) {
let m = (255.0 * (f64::from(*s) / 255.0) * (f64::from(*f) / 255.0)).round() as u32;
let d = u32::from(*dst);
*dst = (m + (d * (255 - m) + 127) / 255).min(255) as u8;
}
Ok(())
}
/// Fill a path (non-zero winding).
pub fn fill_path(&mut self, path: &Path, color: Color) -> Result<(), DrawError> {
if self.rasterize(path, None) {
self.composite_scratch(color);
}
Ok(())
}
/// Invert all pixels on the canvas.
pub fn invert(&mut self) {
for v in &mut self.sfc {
*v = 255 - *v;
}
}
/// Mirror the canvas horizontally.
pub fn flip_horizontal(&mut self) {
let width = self.sfc_width as usize;
for row in self.sfc.chunks_exact_mut(width) {
row.reverse();
}
std::mem::swap(&mut self.clip_left, &mut self.clip_right);
}
/// Mirror the canvas vertically.
pub fn flip_vertical(&mut self) {
let width = self.sfc_width as usize;
let height = self.sfc_height as usize;
for y in 0..height / 2 {
let (top, bottom) = self.sfc.split_at_mut((height - y - 1) * width);
top[y * width..(y + 1) * width].swap_with_slice(&mut bottom[..width]);
}
std::mem::swap(&mut self.clip_top, &mut self.clip_bottom);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(c: &Canvas, x: u32, y: u32) -> u8 {
c.sfc[y as usize * c.sfc_width as usize + x as usize]
}
#[test]
fn box_rect_normalizes() {
let b = Box {
p0: Point { x: 5, y: 9 },
p1: Point { x: 2, y: 3 },
};
assert_eq!(
Rect {
x: 2,
y: 3,
width: 3,
height: 6
},
b.rect()
);
}
#[test]
fn rect_and_padding() {
let mut c = Canvas::new(4, 4, 1, 1);
assert_eq!((6, 6), (c.sfc_width, c.sfc_height));
c.rect(
Rect {
x: 0,
y: 0,
width: 2,
height: 1,
},
Color::ON,
);
assert_eq!(255, at(&c, 1, 1));
assert_eq!(255, at(&c, 2, 1));
assert_eq!(0, at(&c, 3, 1));
assert_eq!(0, at(&c, 1, 2));
// out of bounds is ignored
c.rect(
Rect {
x: -10,
y: -10,
width: 100,
height: 100,
},
Color::OFF,
);
assert!(c.sfc.iter().all(|v| *v == 0));
}
#[test]
fn trim_and_write_atlas() {
let mut c = Canvas::new(8, 8, 2, 2);
c.rect(
Rect {
x: 1,
y: 2,
width: 3,
height: 2,
},
Color::ON,
);
let mut atlas = Atlas::new(32, Format::Grayscale);
let r = c.write_atlas(&mut atlas).unwrap();
assert_eq!((3, 2), (r.width, r.height));
assert_eq!(2 + 1, c.clip_left);
assert_eq!(2 + 2, c.clip_top);
assert_eq!(atlas.data[(r.y * 32 + r.x) as usize], 255);
}
#[test]
fn empty_canvas_writes_empty_region() {
let mut c = Canvas::new(8, 8, 2, 2);
let mut atlas = Atlas::new(32, Format::Grayscale);
let r = c.write_atlas(&mut atlas).unwrap();
assert_eq!((0, 0), (r.width, r.height));
}
#[test]
fn flips() {
let mut c = Canvas::new(4, 2, 0, 0);
c.pixel(0, 0, Color::ON);
c.flip_horizontal();
assert_eq!(255, at(&c, 3, 0));
c.flip_vertical();
assert_eq!(255, at(&c, 3, 1));
}
#[test]
fn invert_all() {
let mut c = Canvas::new(2, 2, 0, 0);
c.pixel(0, 0, Color::ON);
c.invert();
assert_eq!(0, at(&c, 0, 0));
assert_eq!(255, at(&c, 1, 1));
}
#[test]
fn fill_triangle_covers_interior() {
let mut c = Canvas::new(20, 20, 5, 5);
c.triangle(
Triangle {
p0: Point { x: 0.0, y: 0.0 },
p1: Point { x: 20.0, y: 0.0 },
p2: Point { x: 0.0, y: 20.0 },
},
Color::ON,
)
.unwrap();
assert_eq!(255, at(&c, 5 + 2, 5 + 2));
assert_eq!(0, at(&c, 5 + 18, 5 + 18));
}
#[test]
fn stroke_line_thickness() {
let mut c = Canvas::new(20, 20, 5, 5);
c.line(
Line {
p0: Point { x: 0.0, y: 10.0 },
p1: Point { x: 20.0, y: 10.0 },
},
4.0,
Color::ON,
)
.unwrap();
// rows 8..12 inclusive of padding offset
assert_eq!(255, at(&c, 15, 5 + 8));
assert_eq!(255, at(&c, 15, 5 + 11));
assert_eq!(0, at(&c, 15, 5 + 7));
assert_eq!(0, at(&c, 15, 5 + 12));
}
#[test]
fn arc_full_circle_fills() {
let mut c = Canvas::new(20, 20, 0, 0);
let mut p = Path::new();
p.arc(10.0, 10.0, 8.0, 0.0, std::f64::consts::TAU);
p.close();
c.fill_path(&p, Color::ON).unwrap();
assert_eq!(255, at(&c, 10, 10));
assert_eq!(0, at(&c, 0, 0));
}
#[test]
fn inner_stroke_stays_inside() {
let mut c = Canvas::new(20, 20, 0, 0);
let mut p = Path::new();
p.move_to(2.0, 2.0);
p.line_to(18.0, 2.0);
p.line_to(18.0, 18.0);
p.line_to(2.0, 18.0);
p.close();
c.inner_stroke_path(
&p,
StrokeOptions {
line_width: 2.0,
..Default::default()
},
Color::ON,
)
.unwrap();
assert_eq!(0, at(&c, 1, 10)); // outside
assert_eq!(255, at(&c, 3, 10)); // inside stroke
assert_eq!(0, at(&c, 10, 10)); // interior
}
}