use std::sync::{Mutex, MutexGuard, OnceLock};
use cosmic_text::{
Attrs, Buffer, Color as CosmicColor, Family, FontSystem, Metrics, Shaping,
Style as CosmicStyle, SwashCache, Weight as CosmicWeight,
};
use tiny_skia::{Pixmap, PixmapMut, PremultipliedColorU8};
use crate::core::error::{PlottingError, Result};
use crate::render::Color;
use crate::render::text_anchor::TextPlacementMetrics;
const MAX_TEXT_RASTER_DIMENSION: u32 = 8_192;
const MAX_TEXT_RASTER_BYTES: usize = 128 * 1024 * 1024;
trait PixmapTarget {
fn width(&self) -> u32;
fn height(&self) -> u32;
fn pixels_mut(&mut self) -> &mut [PremultipliedColorU8];
}
impl PixmapTarget for Pixmap {
fn width(&self) -> u32 {
self.width()
}
fn height(&self) -> u32 {
self.height()
}
fn pixels_mut(&mut self) -> &mut [PremultipliedColorU8] {
self.pixels_mut()
}
}
impl PixmapTarget for PixmapMut<'_> {
fn width(&self) -> u32 {
self.width()
}
fn height(&self) -> u32 {
self.height()
}
fn pixels_mut(&mut self) -> &mut [PremultipliedColorU8] {
self.pixels_mut()
}
}
fn validate_text_raster_size(width: u32, height: u32, context: &str) -> Result<()> {
if width > MAX_TEXT_RASTER_DIMENSION || height > MAX_TEXT_RASTER_DIMENSION {
return Err(PlottingError::PerformanceLimit {
limit_type: format!("{context} raster dimension"),
actual: width.max(height) as usize,
maximum: MAX_TEXT_RASTER_DIMENSION as usize,
});
}
let bytes = (width as usize)
.checked_mul(height as usize)
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| PlottingError::PerformanceLimit {
limit_type: format!("{context} raster bytes"),
actual: usize::MAX,
maximum: MAX_TEXT_RASTER_BYTES,
})?;
if bytes > MAX_TEXT_RASTER_BYTES {
return Err(PlottingError::PerformanceLimit {
limit_type: format!("{context} raster bytes"),
actual: bytes,
maximum: MAX_TEXT_RASTER_BYTES,
});
}
Ok(())
}
fn is_renderable_text(text: &str) -> bool {
!text.trim().is_empty()
}
fn estimate_text_metrics(text: &str, config: &FontConfig) -> TextPlacementMetrics {
let char_count = text.chars().count() as f32;
let height = (config.size * 1.2).max(config.size);
let width = char_count * config.size * 0.6;
TextPlacementMetrics::new(width, height, config.size)
}
static FONT_SYSTEM: OnceLock<Mutex<FontSystem>> = OnceLock::new();
static SWASH_CACHE: OnceLock<Mutex<SwashCache>> = OnceLock::new();
pub fn get_font_system() -> &'static Mutex<FontSystem> {
FONT_SYSTEM.get_or_init(|| {
log::debug!("Initializing global FontSystem with system font discovery");
Mutex::new(FontSystem::new())
})
}
pub fn get_swash_cache() -> &'static Mutex<SwashCache> {
SWASH_CACHE.get_or_init(|| {
log::debug!("Initializing global SwashCache for glyph caching");
Mutex::new(SwashCache::new())
})
}
fn lock_text_resource<'a, T>(
mutex: &'a Mutex<T>,
resource_name: &str,
) -> Result<MutexGuard<'a, T>> {
mutex.lock().map_err(|_| {
PlottingError::RenderError(format!(
"Text rendering aborted because {resource_name} lock is poisoned"
))
})
}
fn lock_font_system() -> Result<MutexGuard<'static, FontSystem>> {
lock_text_resource(get_font_system(), "FontSystem")
}
fn lock_swash_cache() -> Result<MutexGuard<'static, SwashCache>> {
lock_text_resource(get_swash_cache(), "SwashCache")
}
pub fn initialize_text_system() {
let _ = get_font_system();
let _ = get_swash_cache();
log::info!("Text rendering system initialized");
}
pub fn register_font_bytes(bytes: Vec<u8>) -> Result<()> {
let mut font_system = lock_font_system()?;
font_system.db_mut().load_font_data(bytes);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum FontFamily {
Serif,
#[default]
SansSerif,
Monospace,
Cursive,
Fantasy,
Name(String),
}
impl FontFamily {
pub fn to_cosmic_family(&self) -> Family<'_> {
match self {
FontFamily::Serif => Family::Serif,
FontFamily::SansSerif => Family::SansSerif,
FontFamily::Monospace => Family::Monospace,
FontFamily::Cursive => Family::Cursive,
FontFamily::Fantasy => Family::Fantasy,
FontFamily::Name(name) => Family::Name(name),
}
}
pub fn as_str(&self) -> &str {
match self {
FontFamily::Serif => "serif",
FontFamily::SansSerif => "sans-serif",
FontFamily::Monospace => "monospace",
FontFamily::Cursive => "cursive",
FontFamily::Fantasy => "fantasy",
FontFamily::Name(name) => name,
}
}
}
impl From<&str> for FontFamily {
fn from(name: &str) -> Self {
match name.to_lowercase().as_str() {
"serif" => FontFamily::Serif,
"sans-serif" | "sans" => FontFamily::SansSerif,
"monospace" | "mono" => FontFamily::Monospace,
"cursive" => FontFamily::Cursive,
"fantasy" => FontFamily::Fantasy,
_ => FontFamily::Name(name.to_string()),
}
}
}
impl From<String> for FontFamily {
fn from(name: String) -> Self {
FontFamily::from(name.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FontWeight {
Thin,
ExtraLight,
Light,
#[default]
Normal,
Medium,
SemiBold,
Bold,
ExtraBold,
Black,
}
impl FontWeight {
pub fn to_cosmic_weight(self) -> CosmicWeight {
match self {
FontWeight::Thin => CosmicWeight::THIN,
FontWeight::ExtraLight => CosmicWeight::EXTRA_LIGHT,
FontWeight::Light => CosmicWeight::LIGHT,
FontWeight::Normal => CosmicWeight::NORMAL,
FontWeight::Medium => CosmicWeight::MEDIUM,
FontWeight::SemiBold => CosmicWeight::SEMIBOLD,
FontWeight::Bold => CosmicWeight::BOLD,
FontWeight::ExtraBold => CosmicWeight::EXTRA_BOLD,
FontWeight::Black => CosmicWeight::BLACK,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FontStyle {
#[default]
Normal,
Italic,
Oblique,
}
impl FontStyle {
pub fn to_cosmic_style(self) -> CosmicStyle {
match self {
FontStyle::Normal => CosmicStyle::Normal,
FontStyle::Italic => CosmicStyle::Italic,
FontStyle::Oblique => CosmicStyle::Oblique,
}
}
}
#[derive(Debug, Clone)]
pub struct FontConfig {
pub family: FontFamily,
pub size: f32,
pub weight: FontWeight,
pub style: FontStyle,
}
impl FontConfig {
pub fn new(family: FontFamily, size: f32) -> Self {
Self {
family,
size,
weight: FontWeight::Normal,
style: FontStyle::Normal,
}
}
pub fn weight(mut self, weight: FontWeight) -> Self {
self.weight = weight;
self
}
pub fn bold(mut self) -> Self {
self.weight = FontWeight::Bold;
self
}
pub fn style(mut self, style: FontStyle) -> Self {
self.style = style;
self
}
pub fn italic(mut self) -> Self {
self.style = FontStyle::Italic;
self
}
pub fn size(mut self, size: f32) -> Self {
self.size = size;
self
}
pub fn to_cosmic_attrs(&self) -> Attrs<'_> {
Attrs::new()
.family(self.family.to_cosmic_family())
.weight(self.weight.to_cosmic_weight())
.style(self.style.to_cosmic_style())
}
}
impl Default for FontConfig {
fn default() -> Self {
Self {
family: FontFamily::default(),
size: 12.0,
weight: FontWeight::default(),
style: FontStyle::default(),
}
}
}
pub struct TextRenderer;
impl TextRenderer {
pub fn new() -> Self {
Self
}
pub fn render_text(
&self,
pixmap: &mut Pixmap,
text: &str,
x: f32,
y: f32,
config: &FontConfig,
color: Color,
) -> Result<()> {
self.render_text_impl(pixmap, text, x, y, config, color)
}
pub fn render_text_mut(
&self,
pixmap: &mut PixmapMut<'_>,
text: &str,
x: f32,
y: f32,
config: &FontConfig,
color: Color,
) -> Result<()> {
self.render_text_impl(pixmap, text, x, y, config, color)
}
fn render_text_impl<T: PixmapTarget>(
&self,
pixmap: &mut T,
text: &str,
x: f32,
y: f32,
config: &FontConfig,
color: Color,
) -> Result<()> {
if !is_renderable_text(text) {
return Ok(());
}
let mut font_system = lock_font_system()?;
if font_system.db().is_empty() {
log::debug!("Skipping text render because no fonts are registered");
return Ok(());
}
let mut swash_cache = lock_swash_cache()?;
let metrics = Metrics::new(config.size, config.size * 1.2);
let mut buffer = Buffer::new(&mut font_system, metrics);
let buffer_width = (text.len() as f32 * config.size * 2.0).max(800.0);
let buffer_height = (config.size * 4.0).max(100.0);
buffer.set_size(&mut font_system, Some(buffer_width), Some(buffer_height));
let attrs = config.to_cosmic_attrs();
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(&mut font_system, false);
let cosmic_color = CosmicColor::rgba(color.r, color.g, color.b, color.a);
let width = pixmap.width();
let height = pixmap.height();
let pixels = pixmap.pixels_mut();
for run in buffer.layout_runs() {
let line_y = run.line_y;
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((x, y + line_y), 1.0);
swash_cache.with_pixels(
&mut font_system,
physical_glyph.cache_key,
cosmic_color,
|glyph_x, glyph_y, glyph_color| {
let pixel_x = physical_glyph.x + glyph_x;
let pixel_y = physical_glyph.y + glyph_y;
if pixel_x >= 0
&& pixel_y >= 0
&& (pixel_x as u32) < width
&& (pixel_y as u32) < height
{
let alpha = glyph_color.a();
if alpha > 0 {
let idx = (pixel_y as u32 * width + pixel_x as u32) as usize;
let background = pixels[idx];
let alpha_f = alpha as f32 / 255.0;
let inv_alpha = 1.0 - alpha_f;
let blended_r = (glyph_color.r() as f32 * alpha_f
+ background.red() as f32 * inv_alpha)
as u8;
let blended_g = (glyph_color.g() as f32 * alpha_f
+ background.green() as f32 * inv_alpha)
as u8;
let blended_b = (glyph_color.b() as f32 * alpha_f
+ background.blue() as f32 * inv_alpha)
as u8;
if let Some(blended) = PremultipliedColorU8::from_rgba(
blended_r, blended_g, blended_b, 255,
) {
pixels[idx] = blended;
}
}
}
},
);
}
}
Ok(())
}
pub fn render_text_centered(
&self,
pixmap: &mut Pixmap,
text: &str,
center_x: f32,
y: f32,
config: &FontConfig,
color: Color,
) -> Result<()> {
let (width, _) = self.measure_text(text, config)?;
let x = center_x - width / 2.0;
self.render_text(pixmap, text, x, y, config, color)
}
pub fn render_text_rotated(
&self,
pixmap: &mut Pixmap,
text: &str,
x: f32,
y: f32,
config: &FontConfig,
color: Color,
) -> Result<()> {
if !is_renderable_text(text) {
return Ok(());
}
let mut font_system = lock_font_system()?;
if font_system.db().is_empty() {
log::debug!("Skipping rotated text render because no fonts are registered");
return Ok(());
}
let mut swash_cache = lock_swash_cache()?;
let metrics = Metrics::new(config.size, config.size * 1.2);
let mut buffer = Buffer::new(&mut font_system, metrics);
let buffer_width = (text.len() as f32 * config.size * 3.0).max(800.0);
let buffer_height = (config.size * 6.0).max(180.0);
buffer.set_size(&mut font_system, Some(buffer_width), Some(buffer_height));
let attrs = config.to_cosmic_attrs();
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(&mut font_system, false);
let cosmic_color = CosmicColor::rgba(color.r, color.g, color.b, color.a);
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
for run in buffer.layout_runs() {
let line_y = run.line_y;
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., line_y), 1.0);
swash_cache.with_pixels(
&mut font_system,
physical_glyph.cache_key,
cosmic_color,
|dx, dy, glyph_color| {
if glyph_color.a() == 0 {
return;
}
let px = physical_glyph.x + dx;
let py = physical_glyph.y + dy;
min_x = min_x.min(px);
min_y = min_y.min(py);
max_x = max_x.max(px);
max_y = max_y.max(py);
},
);
}
}
if min_x == i32::MAX || min_y == i32::MAX {
return Ok(());
}
let text_width = (max_x - min_x + 1).max(1) as u32;
let text_height = (max_y - min_y + 1).max(1) as u32;
validate_text_raster_size(text_width, text_height, "Rotated text")?;
let mut temp_pixmap = Pixmap::new(text_width, text_height).ok_or_else(|| {
PlottingError::RenderError("Failed to create temp pixmap".to_string())
})?;
temp_pixmap.fill(tiny_skia::Color::TRANSPARENT);
for run in buffer.layout_runs() {
let line_y = run.line_y;
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., line_y), 1.0);
swash_cache.with_pixels(
&mut font_system,
physical_glyph.cache_key,
cosmic_color,
|dx, dy, glyph_color| {
if glyph_color.a() == 0 {
return;
}
let glyph_x = (physical_glyph.x + dx - min_x) as u32;
let glyph_y = (physical_glyph.y + dy - min_y) as u32;
if glyph_x < text_width && glyph_y < text_height {
let idx = glyph_y as usize * text_width as usize + glyph_x as usize;
if idx < temp_pixmap.pixels().len() {
if let Some(rgba_pixel) = PremultipliedColorU8::from_rgba(
glyph_color.r(),
glyph_color.g(),
glyph_color.b(),
glyph_color.a(),
) {
temp_pixmap.pixels_mut()[idx] = rgba_pixel;
}
}
}
},
);
}
}
let rotated_width = text_height;
let rotated_height = text_width;
validate_text_raster_size(rotated_width, rotated_height, "Rotated text")?;
let mut rotated_pixmap = Pixmap::new(rotated_width, rotated_height).ok_or_else(|| {
PlottingError::RenderError("Failed to create rotated pixmap".to_string())
})?;
rotated_pixmap.fill(tiny_skia::Color::TRANSPARENT);
for orig_y in 0..text_height {
for orig_x in 0..text_width {
let src_pixel =
temp_pixmap.pixels()[orig_y as usize * text_width as usize + orig_x as usize];
if src_pixel.alpha() > 0 {
let new_x = orig_y;
let new_y = text_width - 1 - orig_x;
if new_x < rotated_width && new_y < rotated_height {
let new_idx = new_y as usize * rotated_width as usize + new_x as usize;
if new_idx < rotated_pixmap.pixels().len() {
rotated_pixmap.pixels_mut()[new_idx] = src_pixel;
}
}
}
}
}
let canvas_width = pixmap.width();
let canvas_height = pixmap.height();
let target_x = (x - rotated_width as f32 / 2.0).floor() as i32;
let target_y = (y - rotated_height as f32 / 2.0).floor() as i32;
for py in 0..rotated_height {
for px in 0..rotated_width {
let src_pixel =
rotated_pixmap.pixels()[py as usize * rotated_width as usize + px as usize];
if src_pixel.alpha() > 0 {
let final_x = target_x + px as i32;
let final_y = target_y + py as i32;
if final_x >= 0
&& final_y >= 0
&& final_x < canvas_width as i32
&& final_y < canvas_height as i32
{
let pixmap_idx = (final_y as u32 * canvas_width + final_x as u32) as usize;
if pixmap_idx < pixmap.pixels().len() {
let background = pixmap.pixels()[pixmap_idx];
let alpha_f = src_pixel.alpha() as f32 / 255.0;
let inv_alpha = 1.0 - alpha_f;
let blended_r = (src_pixel.red() as f32
+ background.red() as f32 * inv_alpha)
as u8;
let blended_g = (src_pixel.green() as f32
+ background.green() as f32 * inv_alpha)
as u8;
let blended_b = (src_pixel.blue() as f32
+ background.blue() as f32 * inv_alpha)
as u8;
if let Some(blended) = PremultipliedColorU8::from_rgba(
blended_r, blended_g, blended_b, 255,
) {
pixmap.pixels_mut()[pixmap_idx] = blended;
}
}
}
}
}
}
Ok(())
}
pub(crate) fn measure_text_placement(
&self,
text: &str,
config: &FontConfig,
) -> Result<TextPlacementMetrics> {
if !is_renderable_text(text) {
return Ok(TextPlacementMetrics::new(0.0, config.size, config.size));
}
let mut font_system = lock_font_system()?;
if font_system.db().is_empty() {
log::debug!("Estimating text metrics because no fonts are registered");
return Ok(estimate_text_metrics(text, config));
}
let metrics = Metrics::new(config.size, config.size * 1.2);
let mut buffer = Buffer::new(&mut font_system, metrics);
let buffer_width = (text.len() as f32 * config.size * 2.0).max(800.0);
let buffer_height = (config.size * 4.0).max(100.0);
buffer.set_size(&mut font_system, Some(buffer_width), Some(buffer_height));
let attrs = config.to_cosmic_attrs();
buffer.set_text(&mut font_system, text, &attrs, Shaping::Advanced, None);
buffer.shape_until_scroll(&mut font_system, false);
let mut width: f32 = 0.0;
let mut height: f32 = 0.0;
let mut baseline_from_top: Option<f32> = None;
for run in buffer.layout_runs() {
width = width.max(run.line_w);
height = height.max(run.line_height);
if baseline_from_top.is_none() {
baseline_from_top = Some(run.line_y);
}
}
let baseline_from_top = baseline_from_top.unwrap_or(height);
Ok(TextPlacementMetrics::new(width, height, baseline_from_top))
}
pub fn measure_text(&self, text: &str, config: &FontConfig) -> Result<(f32, f32)> {
let placement = self.measure_text_placement(text, config)?;
Ok((placement.width, placement.height))
}
}
impl Default for TextRenderer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_font_family_from_str() {
assert_eq!(FontFamily::from("serif"), FontFamily::Serif);
assert_eq!(FontFamily::from("sans-serif"), FontFamily::SansSerif);
assert_eq!(FontFamily::from("sans"), FontFamily::SansSerif);
assert_eq!(FontFamily::from("monospace"), FontFamily::Monospace);
assert_eq!(FontFamily::from("mono"), FontFamily::Monospace);
assert_eq!(
FontFamily::from("Arial"),
FontFamily::Name("Arial".to_string())
);
}
#[test]
fn test_font_family_as_str() {
assert_eq!(FontFamily::Serif.as_str(), "serif");
assert_eq!(FontFamily::SansSerif.as_str(), "sans-serif");
assert_eq!(FontFamily::Monospace.as_str(), "monospace");
assert_eq!(FontFamily::Name("Roboto".to_string()).as_str(), "Roboto");
}
#[test]
fn test_font_config_builder() {
let config = FontConfig::new(FontFamily::SansSerif, 14.0).bold().italic();
assert_eq!(config.family, FontFamily::SansSerif);
assert_eq!(config.size, 14.0);
assert_eq!(config.weight, FontWeight::Bold);
assert_eq!(config.style, FontStyle::Italic);
}
#[test]
fn test_font_config_to_cosmic_attrs() {
let config = FontConfig::new(FontFamily::Serif, 16.0).bold();
let attrs = config.to_cosmic_attrs();
let _ = attrs;
}
#[test]
fn test_font_weight_to_cosmic() {
let weights = [
FontWeight::Thin,
FontWeight::ExtraLight,
FontWeight::Light,
FontWeight::Normal,
FontWeight::Medium,
FontWeight::SemiBold,
FontWeight::Bold,
FontWeight::ExtraBold,
FontWeight::Black,
];
for weight in weights {
let _ = weight.to_cosmic_weight();
}
}
#[test]
fn test_font_style_to_cosmic() {
assert!(matches!(
FontStyle::Normal.to_cosmic_style(),
CosmicStyle::Normal
));
assert!(matches!(
FontStyle::Italic.to_cosmic_style(),
CosmicStyle::Italic
));
assert!(matches!(
FontStyle::Oblique.to_cosmic_style(),
CosmicStyle::Oblique
));
}
#[test]
fn test_text_renderer_creation() {
let renderer = TextRenderer::new();
let _ = renderer; }
#[test]
fn test_singleton_initialization() {
let fs1 = get_font_system();
let sc1 = get_swash_cache();
let fs2 = get_font_system();
let sc2 = get_swash_cache();
assert!(std::ptr::eq(fs1, fs2));
assert!(std::ptr::eq(sc1, sc2));
}
#[test]
fn poisoned_text_lock_returns_error() {
let mutex = Mutex::new(0_u8);
let _ = std::panic::catch_unwind(|| {
let _guard = mutex.lock().unwrap();
panic!("poison text lock");
});
let err = lock_text_resource(&mutex, "test resource").unwrap_err();
assert!(matches!(err, PlottingError::RenderError(_)));
assert!(err.to_string().contains("test resource lock is poisoned"));
}
#[test]
fn test_measure_text() {
let renderer = TextRenderer::new();
let config = FontConfig::new(FontFamily::SansSerif, 12.0);
let (w, h) = renderer.measure_text("", &config).unwrap();
assert_eq!(w, 0.0);
assert_eq!(h, 12.0);
let (w, _h) = renderer.measure_text("Hello", &config).unwrap();
assert!(w > 0.0);
}
#[test]
fn whitespace_text_is_treated_as_empty() {
let renderer = TextRenderer::new();
let config = FontConfig::new(FontFamily::SansSerif, 12.0);
let (w, h) = renderer.measure_text(" \n\t", &config).unwrap();
assert_eq!(w, 0.0);
assert_eq!(h, 12.0);
}
}