diff --git a/Cargo.lock b/Cargo.lock index f51ab08..028b865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.102" @@ -32,6 +38,7 @@ dependencies = [ "calloop-wayland-source", "fontconfig", "freetype-rs", + "lru", "pound", "rustix", "smithay-client-toolkit", @@ -146,6 +153,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -162,6 +175,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "fontconfig" version = "0.11.0" @@ -193,6 +212,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -245,6 +275,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown", +] + [[package]] name = "matchers" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 2496395..43a6dbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ calloop = "0.14.4" calloop-wayland-source = "0.4.1" fontconfig = "0.11.0" freetype-rs = "0.38.0" +lru = "0.18.0" pound = "0.1.6" rustix = { version = "1.1.4", features = [ "pty", diff --git a/src/font.rs b/src/font.rs index 3ac11c8..bebc571 100644 --- a/src/font.rs +++ b/src/font.rs @@ -4,19 +4,25 @@ //! FreeType rasterizes each glyph to an 8-bit coverage mask or, for colour //! fonts, a pre-multiplied BGRA bitmap. Layout is fixed-cell, so a glyph's own //! advance is never consulted - only the [`CellMetrics`] taken from the primary -//! face. All C interop is handled by the `freetype` and `fontconfig` safe -//! wrappers, so this module contains no `unsafe`. +//! face. C interop goes through the `freetype`/`fontconfig` safe wrappers; the +//! sole `unsafe` is reading a face's fixed-strike array (see `nearest_strike`). use std::collections::HashMap; use std::fmt; +use std::num::NonZeroUsize; use std::path::PathBuf; use fontconfig::{CharSet, Fontconfig, Pattern}; use freetype::bitmap::PixelMode; -use freetype::face::LoadFlag; -use freetype::{Face, Library}; +use freetype::face::{LoadFlag, StyleFlag}; +use freetype::{Face, Library, Matrix, Vector}; +use lru::LruCache; use thiserror::Error; +/// Upper bound on cached glyphs; the working set of a terminal is far smaller, +/// but this caps memory under adversarial all-of-Unicode output. +const GLYPH_CACHE_CAP: usize = 4096; + #[derive(Debug, Error)] pub enum FontError { #[error("FreeType: {0}")] @@ -99,7 +105,7 @@ pub struct Fonts { styled: [Option; 4], /// Fallback faces resolved by coverage, deduplicated by file path. fallbacks: HashMap, - cache: HashMap<(char, usize), Glyph>, + cache: LruCache<(char, usize), Glyph>, } impl fmt::Debug for Fonts { @@ -132,7 +138,7 @@ impl Fonts { faces: vec![regular], styled: [Some(0), None, None, None], fallbacks: HashMap::new(), - cache: HashMap::new(), + cache: LruCache::new(NonZeroUsize::new(GLYPH_CACHE_CAP).expect("cap is nonzero")), }) } @@ -144,12 +150,18 @@ impl Fonts { /// it on first use. pub fn glyph(&mut self, c: char, style: Style) -> Result<&Glyph, FontError> { let key = (c, style.index()); - if !self.cache.contains_key(&key) { - let face = self.face_for(c, style)?; - let glyph = rasterize(&self.faces[face], c)?; - self.cache.insert(key, glyph); + if self.cache.get(&key).is_none() { + let idx = self.face_for(c, style)?; + let face = &self.faces[idx]; + // Synthesize bold/italic only when the resolved face lacks the real + // variant (most monospace families ship both). + let flags = face.style_flags(); + let synth_bold = style.bold && !flags.contains(StyleFlag::BOLD); + let synth_italic = style.italic && !flags.contains(StyleFlag::ITALIC); + let glyph = rasterize(face, c, synth_bold, synth_italic)?; + self.cache.put(key, glyph); } - Ok(&self.cache[&key]) + Ok(self.cache.get(&key).expect("glyph was just inserted")) } /// Pick the face that should render `c`: the requested style if it has the @@ -294,8 +306,23 @@ fn cell_metrics(face: &Face, family: &str) -> Result { }) } -fn rasterize(face: &Face, c: char) -> Result { - face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR)?; +fn rasterize( + face: &Face, + c: char, + synth_bold: bool, + synth_italic: bool, +) -> Result { + // A shear transform fakes italics on a face that has no real oblique. It is + // applied to the outline at load time, so reset it immediately after. + if synth_italic { + face.set_transform(&mut shear_matrix(), &mut Vector { x: 0, y: 0 }); + } + let result = face.load_char(c as usize, LoadFlag::RENDER | LoadFlag::COLOR); + if synth_italic { + face.set_transform(&mut identity_matrix(), &mut Vector { x: 0, y: 0 }); + } + result?; + let slot = face.glyph(); let bitmap = slot.bitmap(); let width = bitmap.width().max(0) as usize; @@ -303,12 +330,17 @@ fn rasterize(face: &Face, c: char) -> Result { let pitch = bitmap.pitch(); let src = bitmap.buffer(); - let data = match bitmap.pixel_mode()? { + let mut data = match bitmap.pixel_mode()? { PixelMode::Gray => GlyphData::Mask(pack_rows(src, width, pitch, height)), PixelMode::Bgra => GlyphData::Color(pack_rows(src, width * 4, pitch, height)), PixelMode::Mono => GlyphData::Mask(expand_mono(src, width, pitch, height)), _ => GlyphData::Mask(vec![0; width * height]), }; + // Fake bold by widening coverage one pixel to the right (colour glyphs are + // left alone - there is no such thing as a bold emoji). + if synth_bold && let GlyphData::Mask(mask) = &mut data { + embolden(mask, width, height); + } Ok(Glyph { left: slot.bitmap_left(), @@ -319,6 +351,35 @@ fn rasterize(face: &Face, c: char) -> Result { }) } +fn shear_matrix() -> Matrix { + // ~0.2 horizontal shear in 16.16 fixed point. + Matrix { + xx: 0x1_0000, + xy: 0x3333, + yx: 0, + yy: 0x1_0000, + } +} + +fn identity_matrix() -> Matrix { + Matrix { + xx: 0x1_0000, + xy: 0, + yx: 0, + yy: 0x1_0000, + } +} + +/// Widen each row's coverage by one pixel (synthetic bold). +fn embolden(mask: &mut [u8], width: usize, height: usize) { + for y in 0..height { + let row = &mut mask[y * width..y * width + width]; + for x in (1..width).rev() { + row[x] = row[x].max(row[x - 1]); + } + } +} + /// Copy `height` rows of `row_bytes` each out of FreeType's padded buffer, /// honouring pitch sign (positive = top-down). fn pack_rows(src: &[u8], row_bytes: usize, pitch: i32, height: usize) -> Vec { @@ -385,6 +446,15 @@ mod tests { f.glyph(' ', Style::default()).expect("rasterize space"); } + #[test] + fn embolden_widens_coverage() { + // 3x2 mask, one lit pixel per row at x=1. + let mut mask = vec![0, 255, 0, 0, 200, 0]; + embolden(&mut mask, 3, 2); + // Each lit pixel bleeds one column to the right; the left edge is unchanged. + assert_eq!(mask, vec![0, 255, 255, 0, 200, 200]); + } + #[test] fn glyphs_are_cached() { let mut f = fonts();