fangorn/huorn-minecraft
public
ref:main
//! Glyph caching and font rasterization module
//!
//! Uses fontdue for high-quality font rasterization with LRU caching
//! to avoid re-rasterizing glyphs on every frame.
//!
//! Supports both standard grayscale anti-aliasing and subpixel (LCD) rendering
//! for sharper text on LCD displays.
use fontdue::{Font, FontSettings, Metrics};
use lru::LruCache;
use std::num::NonZeroUsize;
/// Key for looking up glyphs in the cache
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub struct GlyphKey {
/// Character to render
pub c: char,
/// Font style flags (bold, italic)
pub style: GlyphStyle,
/// Whether this is a subpixel-rendered glyph
pub subpixel: bool,
}
/// Font style flags
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Default)]
pub struct GlyphStyle {
pub bold: bool,
pub italic: bool,
}
/// Subpixel layout order for LCD displays
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SubpixelOrder {
/// RGB subpixel order (most common)
#[default]
Rgb,
/// BGR subpixel order
Bgr,
/// Disabled (use grayscale AA)
None,
}
impl From<i32> for SubpixelOrder {
fn from(value: i32) -> Self {
match value {
1 => SubpixelOrder::Bgr,
2 => SubpixelOrder::None,
_ => SubpixelOrder::Rgb,
}
}
}
/// Rasterized glyph data
#[derive(Clone)]
pub struct RasterizedGlyph {
/// Glyph bitmap (alpha values for grayscale, or RGB for subpixel)
pub bitmap: Vec<u8>,
/// Glyph metrics
pub metrics: Metrics,
/// Width of the bitmap (in pixels, not subpixels)
pub width: u32,
/// Height of the bitmap
pub height: u32,
/// Whether this is subpixel data (3 bytes per pixel: R, G, B coverage)
pub is_subpixel: bool,
}
/// Glyph cache with LRU eviction
pub struct GlyphCache {
/// Regular font
font: Font,
/// Bold font (optional, falls back to regular)
bold_font: Option<Font>,
/// Italic font (optional, falls back to regular)
italic_font: Option<Font>,
/// Bold italic font (optional)
bold_italic_font: Option<Font>,
/// Cache of rasterized glyphs
cache: LruCache<GlyphKey, RasterizedGlyph>,
/// Font size in pixels
font_size: f32,
/// Cell width in pixels
cell_width: u32,
/// Cell height in pixels
cell_height: u32,
/// Line height multiplier (1.0 = single spacing)
line_height: f32,
/// Extra character spacing in pixels (can be negative)
char_spacing: f32,
}
/// Default line height multiplier (1.4 = 140% of font size)
pub const DEFAULT_LINE_HEIGHT: f32 = 1.4;
/// Default character spacing (0.0 = no extra spacing)
pub const DEFAULT_CHAR_SPACING: f32 = 0.0;
impl GlyphCache {
/// Create a new glyph cache with embedded font and default spacing
#[allow(dead_code)] // Public API, may be used externally
pub fn new(font_size: f32) -> Result<Self, String> {
Self::with_spacing(font_size, DEFAULT_LINE_HEIGHT, DEFAULT_CHAR_SPACING)
}
/// Create a new glyph cache with embedded font and custom spacing
///
/// # Arguments
/// * `font_size` - Font size in pixels
/// * `line_height` - Line height multiplier (1.0 = single spacing, 1.4 = default)
/// * `char_spacing` - Extra character spacing in pixels (can be negative)
pub fn with_spacing(
font_size: f32,
line_height: f32,
char_spacing: f32,
) -> Result<Self, String> {
let font_data = include_bytes!("../fonts/JetBrainsMono-Regular.ttf");
let font = Font::from_bytes(font_data as &[u8], FontSettings::default())
.map_err(|e| format!("Failed to load font: {}", e))?;
Self::from_font_with_spacing(font, font_size, line_height, char_spacing)
}
/// Create glyph cache from custom font file with default spacing
#[allow(dead_code)] // Public API, may be used externally
pub fn from_font_data(font_data: &[u8], font_size: f32) -> Result<Self, String> {
Self::from_font_data_with_spacing(
font_data,
font_size,
DEFAULT_LINE_HEIGHT,
DEFAULT_CHAR_SPACING,
)
}
/// Create glyph cache from custom font file with custom spacing
///
/// # Arguments
/// * `font_data` - Raw TTF or OTF font file data
/// * `font_size` - Font size in pixels
/// * `line_height` - Line height multiplier (1.0 = single spacing, 1.4 = default)
/// * `char_spacing` - Extra character spacing in pixels (can be negative)
pub fn from_font_data_with_spacing(
font_data: &[u8],
font_size: f32,
line_height: f32,
char_spacing: f32,
) -> Result<Self, String> {
let font = Font::from_bytes(font_data, FontSettings::default())
.map_err(|e| format!("Failed to load font: {}", e))?;
Self::from_font_with_spacing(font, font_size, line_height, char_spacing)
}
/// Internal: Create cache from font with spacing parameters
fn from_font_with_spacing(
font: Font,
font_size: f32,
line_height: f32,
char_spacing: f32,
) -> Result<Self, String> {
// Calculate cell dimensions from font metrics
// Use 'M' as reference for width (monospace font)
let metrics = font.metrics('M', font_size);
// Cell width = glyph advance width + extra character spacing
let base_width = metrics.advance_width.ceil();
let cell_width = (base_width + char_spacing).max(1.0) as u32;
// Cell height = font size * line height multiplier
let cell_height = (font_size * line_height).ceil() as u32;
let cache_size = NonZeroUsize::new(4096).unwrap(); // Cache up to 4096 glyphs
Ok(Self {
font,
bold_font: None,
italic_font: None,
bold_italic_font: None,
cache: LruCache::new(cache_size),
font_size,
cell_width,
cell_height,
line_height,
char_spacing,
})
}
/// Get or rasterize a glyph (grayscale anti-aliasing)
pub fn get_glyph(&mut self, c: char, style: GlyphStyle) -> &RasterizedGlyph {
self.get_glyph_internal(c, style, false)
}
/// Get or rasterize a glyph with subpixel anti-aliasing
pub fn get_glyph_subpixel(&mut self, c: char, style: GlyphStyle) -> &RasterizedGlyph {
self.get_glyph_internal(c, style, true)
}
/// Internal: get or rasterize a glyph with optional subpixel rendering
fn get_glyph_internal(
&mut self,
c: char,
style: GlyphStyle,
subpixel: bool,
) -> &RasterizedGlyph {
let key = GlyphKey { c, style, subpixel };
if !self.cache.contains(&key) {
let glyph = if subpixel {
self.rasterize_glyph_subpixel(c, style)
} else {
self.rasterize_glyph(c, style)
};
self.cache.put(key, glyph);
}
self.cache.get(&key).unwrap()
}
/// Rasterize a single glyph with grayscale anti-aliasing
fn rasterize_glyph(&self, c: char, style: GlyphStyle) -> RasterizedGlyph {
// Select font based on style
let font = match (style.bold, style.italic) {
(true, true) => self.bold_italic_font.as_ref().unwrap_or(&self.font),
(true, false) => self.bold_font.as_ref().unwrap_or(&self.font),
(false, true) => self.italic_font.as_ref().unwrap_or(&self.font),
(false, false) => &self.font,
};
let (metrics, bitmap) = font.rasterize(c, self.font_size);
RasterizedGlyph {
width: metrics.width as u32,
height: metrics.height as u32,
bitmap,
metrics,
is_subpixel: false,
}
}
/// Rasterize a single glyph with subpixel anti-aliasing
///
/// Returns RGB coverage data where each pixel has 3 bytes (R, G, B coverage).
/// The width in metrics is 3x the actual pixel width.
fn rasterize_glyph_subpixel(&self, c: char, style: GlyphStyle) -> RasterizedGlyph {
// Select font based on style
let font = match (style.bold, style.italic) {
(true, true) => self.bold_italic_font.as_ref().unwrap_or(&self.font),
(true, false) => self.bold_font.as_ref().unwrap_or(&self.font),
(false, true) => self.italic_font.as_ref().unwrap_or(&self.font),
(false, false) => &self.font,
};
let (metrics, bitmap) = font.rasterize_subpixel(c, self.font_size);
// The subpixel metrics.width is 3x the actual pixel width
let actual_width = metrics.width / 3;
RasterizedGlyph {
width: actual_width as u32,
height: metrics.height as u32,
bitmap,
metrics,
is_subpixel: true,
}
}
/// Get cell dimensions
pub fn cell_size(&self) -> (u32, u32) {
(self.cell_width, self.cell_height)
}
/// Get font size
#[allow(dead_code)] // Future API
pub fn font_size(&self) -> f32 {
self.font_size
}
/// Get line height multiplier
#[allow(dead_code)] // Future API
pub fn line_height(&self) -> f32 {
self.line_height
}
/// Get character spacing in pixels
#[allow(dead_code)] // Future API
pub fn char_spacing(&self) -> f32 {
self.char_spacing
}
/// Clear the cache (call when font changes)
#[allow(dead_code)] // Future API
pub fn clear(&mut self) {
self.cache.clear();
}
/// Get baseline offset for proper glyph positioning
pub fn baseline_offset(&self) -> i32 {
// Approximate baseline offset (depends on font metrics)
// Scale with line height to keep text vertically centered
(self.font_size * self.line_height * 0.78) as i32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glyph_cache_creation() {
let cache = GlyphCache::new(14.0);
assert!(cache.is_ok());
}
#[test]
fn test_glyph_cache_various_sizes() {
// Test common font sizes
for size in [10.0, 12.0, 14.0, 16.0, 18.0, 24.0, 32.0] {
let cache = GlyphCache::new(size);
assert!(cache.is_ok(), "Failed to create cache with size {}", size);
let cache = cache.unwrap();
assert_eq!(cache.font_size(), size);
// Cell size should scale with font size
let (cell_w, cell_h) = cache.cell_size();
assert!(
cell_w > 0,
"Cell width should be positive for size {}",
size
);
assert!(
cell_h > 0,
"Cell height should be positive for size {}",
size
);
}
}
#[test]
fn test_glyph_rasterization() {
let mut cache = GlyphCache::new(14.0).unwrap();
let glyph = cache.get_glyph('A', GlyphStyle::default());
assert!(glyph.width > 0);
assert!(glyph.height > 0);
assert!(!glyph.bitmap.is_empty());
}
#[test]
fn test_glyph_rasterization_various_chars() {
let mut cache = GlyphCache::new(14.0).unwrap();
// Test ASCII printable characters
let test_chars = ['A', 'Z', 'a', 'z', '0', '9', '@', '#', '$', '%', '&', '*'];
for c in test_chars {
let glyph = cache.get_glyph(c, GlyphStyle::default());
assert!(glyph.width > 0, "Glyph for '{}' should have width", c);
assert!(glyph.height > 0, "Glyph for '{}' should have height", c);
}
}
#[test]
fn test_space_character() {
let mut cache = GlyphCache::new(14.0).unwrap();
let glyph = cache.get_glyph(' ', GlyphStyle::default());
// Space should rasterize (may be empty bitmap but should have metrics)
// Width may be 0 for space depending on font
assert!(glyph.bitmap.len() == glyph.width as usize * glyph.height as usize);
}
#[test]
fn test_glyph_caching() {
let mut cache = GlyphCache::new(14.0).unwrap();
// First access should rasterize
let _ = cache.get_glyph('A', GlyphStyle::default());
// Second access should hit cache
let _ = cache.get_glyph('A', GlyphStyle::default());
// Different style should be separate entry
let _ = cache.get_glyph(
'A',
GlyphStyle {
bold: true,
italic: false,
},
);
}
#[test]
fn test_glyph_style_variations() {
let mut cache = GlyphCache::new(14.0).unwrap();
let styles = [
GlyphStyle {
bold: false,
italic: false,
},
GlyphStyle {
bold: true,
italic: false,
},
GlyphStyle {
bold: false,
italic: true,
},
GlyphStyle {
bold: true,
italic: true,
},
];
for style in styles {
let glyph = cache.get_glyph('A', style);
assert!(
glyph.width > 0,
"Glyph should have width for style {:?}",
style
);
assert!(
glyph.height > 0,
"Glyph should have height for style {:?}",
style
);
}
}
#[test]
fn test_cell_size_consistency() {
let cache = GlyphCache::new(14.0).unwrap();
let (w1, h1) = cache.cell_size();
let (w2, h2) = cache.cell_size();
assert_eq!(w1, w2, "Cell width should be consistent");
assert_eq!(h1, h2, "Cell height should be consistent");
}
#[test]
fn test_baseline_offset() {
let cache = GlyphCache::new(14.0).unwrap();
let baseline = cache.baseline_offset();
// Baseline should be positive and reasonable for font size
assert!(baseline > 0, "Baseline should be positive");
assert!(baseline < 30, "Baseline should be reasonable for 14pt font");
}
#[test]
fn test_cache_clear() {
let mut cache = GlyphCache::new(14.0).unwrap();
// Populate cache
for c in 'A'..='Z' {
cache.get_glyph(c, GlyphStyle::default());
}
// Clear should not panic
cache.clear();
// Should still be able to get glyphs after clear
let glyph = cache.get_glyph('A', GlyphStyle::default());
assert!(glyph.width > 0);
}
#[test]
fn test_glyph_key_equality() {
let key1 = GlyphKey {
c: 'A',
style: GlyphStyle::default(),
subpixel: false,
};
let key2 = GlyphKey {
c: 'A',
style: GlyphStyle::default(),
subpixel: false,
};
let key3 = GlyphKey {
c: 'B',
style: GlyphStyle::default(),
subpixel: false,
};
let key4 = GlyphKey {
c: 'A',
style: GlyphStyle {
bold: true,
italic: false,
},
subpixel: false,
};
let key5 = GlyphKey {
c: 'A',
style: GlyphStyle::default(),
subpixel: true,
};
assert_eq!(key1, key2, "Same keys should be equal");
assert_ne!(key1, key3, "Different chars should not be equal");
assert_ne!(key1, key4, "Different styles should not be equal");
assert_ne!(
key1, key5,
"Different subpixel settings should not be equal"
);
}
#[test]
fn test_glyph_key_hash() {
use std::collections::HashMap;
let mut map: HashMap<GlyphKey, i32> = HashMap::new();
let key1 = GlyphKey {
c: 'A',
style: GlyphStyle::default(),
subpixel: false,
};
let key2 = GlyphKey {
c: 'A',
style: GlyphStyle::default(),
subpixel: false,
};
map.insert(key1, 42);
// Same key should retrieve the value
assert_eq!(map.get(&key2), Some(&42));
}
#[test]
fn test_unicode_characters() {
let mut cache = GlyphCache::new(14.0).unwrap();
// Test some common Unicode characters the font likely supports
let unicode_chars = ['─', '│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼'];
for c in unicode_chars {
// Should not panic, even if glyph is missing (fallback)
let glyph = cache.get_glyph(c, GlyphStyle::default());
// At minimum, should return valid struct
assert!(glyph.bitmap.len() == glyph.width as usize * glyph.height as usize);
}
}
#[test]
fn test_monospace_consistency() {
let cache = GlyphCache::new(14.0).unwrap();
// For a monospace font, all regular ASCII characters should have same advance width
let (cell_width, _) = cache.cell_size();
// The cell width is based on 'M', so it should be consistent
// This tests that the font is indeed monospace
assert!(cell_width > 0);
}
#[test]
fn test_line_height_affects_cell_height() {
let default = GlyphCache::new(14.0).unwrap();
let tighter = GlyphCache::with_spacing(14.0, 1.0, 0.0).unwrap();
let looser = GlyphCache::with_spacing(14.0, 2.0, 0.0).unwrap();
let (_, default_h) = default.cell_size();
let (_, tighter_h) = tighter.cell_size();
let (_, looser_h) = looser.cell_size();
// Tighter line height should produce smaller cells
assert!(
tighter_h < default_h,
"Line height 1.0 should be smaller than default 1.4"
);
// Looser line height should produce larger cells
assert!(
looser_h > default_h,
"Line height 2.0 should be larger than default 1.4"
);
}
#[test]
fn test_char_spacing_affects_cell_width() {
let default = GlyphCache::new(14.0).unwrap();
let wider = GlyphCache::with_spacing(14.0, 1.4, 2.0).unwrap();
let tighter = GlyphCache::with_spacing(14.0, 1.4, -1.0).unwrap();
let (default_w, _) = default.cell_size();
let (wider_w, _) = wider.cell_size();
let (tighter_w, _) = tighter.cell_size();
// Positive char spacing should produce wider cells
assert!(
wider_w > default_w,
"Positive char_spacing should widen cells"
);
// Negative char spacing should produce narrower cells (but minimum 1px)
assert!(
tighter_w <= default_w,
"Negative char_spacing should narrow or maintain cells"
);
assert!(tighter_w >= 1, "Cell width should never be less than 1");
}
#[test]
fn test_spacing_getters() {
let cache = GlyphCache::with_spacing(14.0, 1.2, 3.5).unwrap();
assert!(
(cache.line_height() - 1.2).abs() < 0.001,
"line_height getter should return set value"
);
assert!(
(cache.char_spacing() - 3.5).abs() < 0.001,
"char_spacing getter should return set value"
);
}
#[test]
fn test_default_spacing_constants() {
assert!(
(DEFAULT_LINE_HEIGHT - 1.4).abs() < 0.001,
"Default line height should be 1.4"
);
assert!(
(DEFAULT_CHAR_SPACING - 0.0).abs() < 0.001,
"Default char spacing should be 0.0"
);
}
#[test]
fn test_subpixel_glyph_rasterization() {
let mut cache = GlyphCache::new(14.0).unwrap();
// Get a subpixel-rendered glyph
let glyph = cache.get_glyph_subpixel('A', GlyphStyle::default());
assert!(
glyph.is_subpixel,
"Subpixel glyph should have is_subpixel=true"
);
assert!(glyph.width > 0, "Subpixel glyph should have width > 0");
assert!(glyph.height > 0, "Subpixel glyph should have height > 0");
// Subpixel bitmap stores RGB data per pixel
// The bitmap length should be divisible by height and result in a width
assert!(
!glyph.bitmap.is_empty(),
"Subpixel bitmap should not be empty"
);
assert!(
glyph.bitmap.len() % (glyph.height as usize) == 0,
"Subpixel bitmap should be rectangular"
);
}
#[test]
fn test_subpixel_vs_grayscale_glyph() {
let mut cache = GlyphCache::new(14.0).unwrap();
let style = GlyphStyle::default();
// Get both grayscale and subpixel versions
let grayscale = cache.get_glyph('A', style).clone();
let subpixel = cache.get_glyph_subpixel('A', style).clone();
// Both should have positive dimensions
assert!(grayscale.width > 0, "Grayscale should have width > 0");
assert!(subpixel.width > 0, "Subpixel should have width > 0");
// Grayscale and subpixel should have same height
assert_eq!(
grayscale.height, subpixel.height,
"Grayscale and subpixel should have same height"
);
// Different is_subpixel flag
assert!(
!grayscale.is_subpixel,
"Grayscale should have is_subpixel=false"
);
assert!(
subpixel.is_subpixel,
"Subpixel should have is_subpixel=true"
);
// Subpixel bitmap should be larger (3x width worth of data)
assert!(
subpixel.bitmap.len() > grayscale.bitmap.len(),
"Subpixel bitmap should be larger than grayscale"
);
}
#[test]
fn test_subpixel_order_from_i32() {
assert_eq!(SubpixelOrder::from(0), SubpixelOrder::Rgb);
assert_eq!(SubpixelOrder::from(1), SubpixelOrder::Bgr);
assert_eq!(SubpixelOrder::from(2), SubpixelOrder::None);
// Invalid values default to RGB
assert_eq!(SubpixelOrder::from(-1), SubpixelOrder::Rgb);
assert_eq!(SubpixelOrder::from(3), SubpixelOrder::Rgb);
assert_eq!(SubpixelOrder::from(100), SubpixelOrder::Rgb);
}
#[test]
fn test_subpixel_order_default() {
assert_eq!(SubpixelOrder::default(), SubpixelOrder::Rgb);
}
}