From 07a4858175a9695b8c16d94b2f6b35674dc91a59 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Fri, 2 Oct 2026 23:52:49 +0300 Subject: [PATCH] feat: bundled font and text layout for the editor --- Cargo.lock | 32 +++++++++ Cargo.toml | 1 + src/editor/mod.rs | 3 + src/editor/text.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 src/editor/text.rs diff --git a/Cargo.lock b/Cargo.lock index ec9e722..c67f0e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "accesskit" version = "0.24.1" @@ -1834,6 +1850,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2700,6 +2725,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "type-map" version = "0.5.1" @@ -2755,6 +2786,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" name = "valw" version = "0.1.0" dependencies = [ + "ab_glyph", "anyhow", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index b6bad98..e3c1a3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ description = "macOS-style screenshots for niri" license = "MIT" [dependencies] +ab_glyph = "0.2.32" anyhow = "1.0.104" chrono = "0.4.45" clap = { version = "4.6.7", features = ["derive"] } diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 5a3edbe..23f0728 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -3,6 +3,9 @@ pub mod doc; pub mod export; pub mod shape; +// Used from Task 2 of the 5b plan on; that task removes this. +#[allow(dead_code)] +pub mod text; pub mod view; use std::io::Write; diff --git a/src/editor/text.rs b/src/editor/text.rs new file mode 100644 index 0000000..aca5d48 --- /dev/null +++ b/src/editor/text.rs @@ -0,0 +1,168 @@ +//! Text for the editor: the bundled DejaVu Sans Bold, laid out with +//! ab_glyph and turned into tiny-skia paths, so canvas and export draw the +//! same glyphs. + +use std::sync::LazyLock; + +use ab_glyph::{Font, FontRef, GlyphId, OutlineCurve, PxScale, ScaleFont}; +use tiny_skia::{Path, PathBuilder}; + +use crate::editor::shape::P; + +static FONT_DATA: &[u8] = include_bytes!("../../assets/fonts/DejaVuSans-Bold.ttf"); + +pub static FONT: LazyLock> = + LazyLock::new(|| FontRef::try_from_slice(FONT_DATA).expect("the bundled font is valid")); + +pub fn line_height(size: f32) -> f32 { + size * 1.2 +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Layout { + /// Each glyph and its pen position (baseline) relative to the box's top-left. + pub glyphs: Vec<(GlyphId, P)>, + pub width: f32, + pub height: f32, + /// The width of every line. + pub lines: Vec, +} + +pub fn layout(text: &str, size: f32) -> Layout { + if text.is_empty() { + return Layout { + glyphs: Vec::new(), + width: 0.0, + height: 0.0, + lines: Vec::new(), + }; + } + let font = FONT.as_scaled(PxScale::from(size)); + let mut glyphs = Vec::new(); + let mut lines = Vec::new(); + for (i, line) in text.split('\n').enumerate() { + let baseline = i as f32 * line_height(size) + font.ascent(); + let mut x = 0.0; + let mut previous = None; + for c in line.chars() { + let id = font.glyph_id(c); + if let Some(p) = previous { + x += font.kern(p, id); + } + glyphs.push((id, (x, baseline))); + x += font.h_advance(id); + previous = Some(id); + } + lines.push(x); + } + Layout { + glyphs, + width: lines.iter().copied().fold(0.0, f32::max), + height: lines.len() as f32 * line_height(size), + lines, + } +} + +/// The outlines of `text` with its line box's top-left at `origin`. +pub fn path(text: &str, size: f32, origin: P) -> Option { + let layout = layout(text, size); + let scale = FONT.as_scaled(PxScale::from(size)).scale_factor(); + let mut pb = PathBuilder::new(); + for (id, (gx, gy)) in layout.glyphs { + let Some(outline) = FONT.outline(id) else { + continue; + }; + // Font units, y up; the pen sits on the baseline. + let at = |p: ab_glyph::Point| { + ( + origin.0 + gx + p.x * scale.horizontal, + origin.1 + gy - p.y * scale.vertical, + ) + }; + let mut end: Option = None; + for curve in &outline.curves { + let start = match curve { + OutlineCurve::Line(a, _) + | OutlineCurve::Quad(a, _, _) + | OutlineCurve::Cubic(a, _, _, _) => *a, + }; + if end != Some(start) { + if end.is_some() { + pb.close(); + } + let (x, y) = at(start); + pb.move_to(x, y); + } + match curve { + OutlineCurve::Line(_, b) => { + let (x, y) = at(*b); + pb.line_to(x, y); + end = Some(*b); + } + OutlineCurve::Quad(_, c, b) => { + let ((cx, cy), (x, y)) = (at(*c), at(*b)); + pb.quad_to(cx, cy, x, y); + end = Some(*b); + } + OutlineCurve::Cubic(_, c1, c2, b) => { + let ((ax, ay), (bx, by), (x, y)) = (at(*c1), at(*c2), at(*b)); + pb.cubic_to(ax, ay, bx, by, x, y); + end = Some(*b); + } + } + } + if end.is_some() { + pb.close(); + } + } + pb.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_text_has_no_size() { + let l = layout("", 24.0); + assert_eq!((l.width, l.height), (0.0, 0.0)); + assert!(path("", 24.0, (0.0, 0.0)).is_none()); + } + + #[test] + fn lines_and_widths() { + let one = layout("Hello", 24.0); + let longer = layout("Hello!", 24.0); + assert!(longer.width > one.width && one.width > 0.0); + assert_eq!(one.height, line_height(24.0)); + let two = layout("Hi\nthere", 24.0); + assert_eq!(two.height, 2.0 * line_height(24.0)); + assert_eq!(two.lines.len(), 2); + assert!(two.lines[1] > two.lines[0]); + assert_eq!(two.width, two.lines[1]); + } + + #[test] + fn kerning_is_applied() { + let font = FONT.as_scaled(PxScale::from(48.0)); + let (a, v) = (FONT.glyph_id('A'), FONT.glyph_id('V')); + let plain = font.h_advance(a) + font.h_advance(v); + assert!(font.kern(a, v) < 0.0, "DejaVu kerns AV"); + assert!(layout("AV", 48.0).width < plain); + } + + #[test] + fn turkish_letters_have_glyphs() { + for c in "ğüşıöçĞÜŞİÖÇ".chars() { + assert_ne!(FONT.glyph_id(c).0, 0, "{c} has no glyph"); + } + } + + #[test] + fn the_path_sits_in_the_line_box() { + let p = path("Hg", 24.0, (100.0, 50.0)).unwrap(); + let b = p.bounds(); + assert!(b.left() >= 100.0 && b.top() >= 50.0, "{b:?}"); + assert!(b.bottom() <= 50.0 + line_height(24.0), "{b:?}"); + } +}