use std::sync::{Mutex, MutexGuard, OnceLock};
use cosmic_text::{
Attrs, Buffer, CacheKey, Color as CosmicColor, Family, FontSystem, Metrics, Shaping,
Style as CosmicStyle, SwashCache, SwashContent, Weight as CosmicWeight,
};
use swash::scale::Source as SwashSource;
use tiny_skia::{Pixmap, PixmapMut, PremultipliedColorU8};
use crate::core::error::{PlottingError, Result};
use crate::render::font_registry::{self, Registration};
use crate::render::text_anchor::TextPlacementMetrics;
use crate::render::{
Color,
color::{premultiply_rgba, scale_premultiplied_rgba, source_over_premultiplied_rgba},
};
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()
}
}
pub(crate) 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(())
}
#[derive(Clone, Copy)]
enum GlyphPixel {
Straight([u8; 4]),
Premultiplied([u8; 4]),
}
fn premultiplied_glyph_pixel(
glyph_pixel: GlyphPixel,
requested_alpha: u8,
) -> Option<PremultipliedColorU8> {
let [red, green, blue, alpha] = match glyph_pixel {
GlyphPixel::Straight([red, green, blue, alpha]) => {
let effective_alpha = crate::render::color::mul_div_255(alpha, requested_alpha);
premultiply_rgba(red, green, blue, effective_alpha)
}
GlyphPixel::Premultiplied(rgba) => scale_premultiplied_rgba(rgba, requested_alpha),
};
if alpha == 0 {
return None;
}
PremultipliedColorU8::from_rgba(red, green, blue, alpha)
}
pub(crate) fn with_premultiplied_glyph_pixels<F: FnMut(i32, i32, PremultipliedColorU8)>(
swash_cache: &mut SwashCache,
font_system: &mut FontSystem,
cache_key: CacheKey,
color: Color,
mut callback: F,
) {
let Some(image) = swash_cache.get_image(font_system, cache_key) else {
return;
};
let origin_x = image.placement.left;
let origin_y = -image.placement.top;
match image.content {
SwashContent::Mask => {
for (index, coverage) in image.data.iter().copied().enumerate() {
let x = index as u32 % image.placement.width;
let y = index as u32 / image.placement.width;
let glyph_pixel = GlyphPixel::Straight([color.r, color.g, color.b, coverage]);
if let Some(source) = premultiplied_glyph_pixel(glyph_pixel, color.a) {
callback(origin_x + x as i32, origin_y + y as i32, source);
}
}
}
SwashContent::Color => {
let is_premultiplied = matches!(image.source, SwashSource::ColorOutline(_));
for (index, rgba) in image.data.chunks_exact(4).enumerate() {
let x = index as u32 % image.placement.width;
let y = index as u32 / image.placement.width;
let rgba = [rgba[0], rgba[1], rgba[2], rgba[3]];
let glyph_pixel = if is_premultiplied {
GlyphPixel::Premultiplied(rgba)
} else {
GlyphPixel::Straight(rgba)
};
if let Some(source) = premultiplied_glyph_pixel(glyph_pixel, color.a) {
callback(origin_x + x as i32, origin_y + y as i32, source);
}
}
}
SwashContent::SubpixelMask => {
log::warn!("Subpixel glyph masks are not supported");
}
}
}
pub(crate) fn blend_premultiplied_source_over(
destination: &mut PremultipliedColorU8,
source: PremultipliedColorU8,
) {
let [red, green, blue, alpha] = source_over_premultiplied_rgba(
[
destination.red(),
destination.green(),
destination.blue(),
destination.alpha(),
],
[source.red(), source.green(), source.blue(), source.alpha()],
);
if let Some(blended) = PremultipliedColorU8::from_rgba(red, green, blue, alpha) {
*destination = blended;
}
}
fn is_renderable_text(text: &str) -> bool {
!text.trim().is_empty()
}
fn text_line_count(text: &str) -> usize {
text.split('\n').count().max(1)
}
fn text_buffer_height(text: &str, size: f32, minimum: f32) -> f32 {
let line_height = size * 1.2;
(text_line_count(text) as f32 * line_height + size * 2.0).max(minimum)
}
fn estimate_text_metrics(text: &str, config: &FontConfig) -> TextPlacementMetrics {
let max_char_count = text
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line).chars().count())
.max()
.unwrap_or(0) as f32;
let line_height = (config.size * 1.2).max(config.size);
let height = text_line_count(text) as f32 * line_height;
let width = max_char_count * config.size * 0.6;
TextPlacementMetrics::new(width, height, config.size)
}
#[derive(Debug, Clone, Copy)]
struct InkBoxMetrics {
width: f32,
height: f32,
min_y_from_top: f32,
max_y_from_top: f32,
baseline_from_top: f32,
}
impl InkBoxMetrics {
fn center_y_from_top(self) -> f32 {
(self.min_y_from_top + self.max_y_from_top) / 2.0
}
}
static FONT_SYSTEM: OnceLock<Mutex<FontSystem>> = OnceLock::new();
static SWASH_CACHE: OnceLock<Mutex<SwashCache>> = OnceLock::new();
fn font_system_with_registered_fonts(snapshot: &font_registry::RegistrySnapshot) -> FontSystem {
let baseline = FontSystem::new();
let locale = baseline.locale().to_string();
let mut database = baseline.db().clone();
font_registry::load_with_registered_precedence(&mut database, snapshot);
FontSystem::new_with_locale_and_db(locale, database)
}
pub fn get_font_system() -> &'static Mutex<FontSystem> {
FONT_SYSTEM.get_or_init(|| {
log::debug!("Initializing global FontSystem with system font discovery");
let font_system = match font_registry::snapshot() {
Ok(snapshot) => font_system_with_registered_fonts(&snapshot),
Err(err) => {
log::error!("Failed to seed FontSystem from font registry: {err}");
FontSystem::new()
}
};
Mutex::new(font_system)
})
}
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 font = match font_registry::validate(bytes) {
Ok(font) => font,
Err(err) => {
log::warn!("Ignoring font registration: {err}");
return Ok(());
}
};
let mut font_system = lock_font_system()?;
let mut swash_cache = lock_swash_cache()?;
if let Registration::Added(snapshot) = font_registry::register(font)? {
*font_system = font_system_with_registered_fonts(&snapshot);
*swash_cache = SwashCache::new();
}
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(crate) const fn numeric(self) -> u16 {
match self {
FontWeight::Thin => 100,
FontWeight::ExtraLight => 200,
FontWeight::Light => 300,
FontWeight::Normal => 400,
FontWeight::Medium => 500,
FontWeight::SemiBold => 600,
FontWeight::Bold => 700,
FontWeight::ExtraBold => 800,
FontWeight::Black => 900,
}
}
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) || color.a == 0 {
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 = text_buffer_height(text, config.size, 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 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);
with_premultiplied_glyph_pixels(
&mut swash_cache,
&mut font_system,
physical_glyph.cache_key,
color,
|glyph_x, glyph_y, source| {
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 idx = (pixel_y as u32 * width + pixel_x as u32) as usize;
blend_premultiplied_source_over(&mut pixels[idx], source);
}
},
);
}
}
Ok(())
}
pub fn render_text_centered(
&self,
pixmap: &mut Pixmap,
text: &str,
center_x: f32,
y: f32,
config: &FontConfig,
color: Color,
) -> Result<()> {
if !is_renderable_text(text) || color.a == 0 {
return Ok(());
}
let metrics = self.measure_text_placement(text, config)?;
self.render_text_aligned(
pixmap,
text,
center_x - metrics.width / 2.0,
y,
metrics.width,
crate::core::TextAlign::Center,
config,
color,
)
}
pub(crate) fn render_text_aligned(
&self,
pixmap: &mut Pixmap,
text: &str,
block_x: f32,
y: f32,
block_width: f32,
align: crate::core::TextAlign,
config: &FontConfig,
color: Color,
) -> Result<()> {
if !is_renderable_text(text) || color.a == 0 {
return Ok(());
}
let line_height = config.size * 1.2;
for (line_index, line) in text.split('\n').enumerate() {
let line = line.strip_suffix('\r').unwrap_or(line);
let line_width = self.measure_text_placement(line, config)?.width;
let offset = match align {
crate::core::TextAlign::Left => 0.0,
crate::core::TextAlign::Center => (block_width - line_width) / 2.0,
crate::core::TextAlign::Right => block_width - line_width,
};
self.render_text(
pixmap,
line,
block_x + offset,
y + line_index as f32 * line_height,
config,
color,
)?;
}
Ok(())
}
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) || color.a == 0 {
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 = text_buffer_height(text, config.size, 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 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);
with_premultiplied_glyph_pixels(
&mut swash_cache,
&mut font_system,
physical_glyph.cache_key,
color,
|dx, dy, _source| {
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);
with_premultiplied_glyph_pixels(
&mut swash_cache,
&mut font_system,
physical_glyph.cache_key,
color,
|dx, dy, source| {
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() {
blend_premultiplied_source_over(
&mut temp_pixmap.pixels_mut()[idx],
source,
);
}
}
},
);
}
}
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() {
blend_premultiplied_source_over(
&mut pixmap.pixels_mut()[pixmap_idx],
src_pixel,
);
}
}
}
}
}
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 = text_buffer_height(text, config.size, 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_top + 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))
}
fn measure_text_ink_box(&self, text: &str, config: &FontConfig) -> Result<InkBoxMetrics> {
if !is_renderable_text(text) {
return Ok(InkBoxMetrics {
width: 0.0,
height: config.size,
min_y_from_top: 0.0,
max_y_from_top: config.size,
baseline_from_top: config.size,
});
}
let mut font_system = lock_font_system()?;
if font_system.db().is_empty() {
log::debug!("Estimating text ink metrics because no fonts are registered");
let estimated = estimate_text_metrics(text, config);
return Ok(InkBoxMetrics {
width: estimated.width,
height: estimated.height,
min_y_from_top: 0.0,
max_y_from_top: estimated.height,
baseline_from_top: estimated.baseline_from_top,
});
}
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 = text_buffer_height(text, config.size, 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(0, 0, 0, 255);
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
let mut baseline_from_top: Option<f32> = None;
for run in buffer.layout_runs() {
baseline_from_top.get_or_insert(run.line_y);
let line_y = run.line_y;
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0.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 {
let placement = self.measure_text_placement(text, config)?;
return Ok(InkBoxMetrics {
width: placement.width,
height: placement.height,
min_y_from_top: 0.0,
max_y_from_top: placement.height,
baseline_from_top: placement.baseline_from_top,
});
}
let width = (max_x - min_x + 1).max(1) as f32;
let height = (max_y - min_y + 1).max(1) as f32;
let baseline_from_top = baseline_from_top.unwrap_or(height) - min_y as f32;
Ok(InkBoxMetrics {
width,
height,
min_y_from_top: min_y as f32,
max_y_from_top: max_y as f32,
baseline_from_top,
})
}
pub(crate) fn measure_text_ink_placement(
&self,
text: &str,
config: &FontConfig,
) -> Result<TextPlacementMetrics> {
let ink_box = self.measure_text_ink_box(text, config)?;
Ok(TextPlacementMetrics::new(
ink_box.width,
ink_box.height,
ink_box.baseline_from_top,
))
}
pub(crate) fn measure_text_ink_center_from_top(
&self,
text: &str,
config: &FontConfig,
) -> Result<f32> {
Ok(self.measure_text_ink_box(text, config)?.center_y_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("cursive"), FontFamily::Cursive);
assert_eq!(FontFamily::from("fantasy"), FontFamily::Fantasy);
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::Cursive.as_str(), "cursive");
assert_eq!(FontFamily::Fantasy.as_str(), "fantasy");
assert_eq!(FontFamily::Name("Roboto".to_string()).as_str(), "Roboto");
}
#[test]
fn test_font_family_to_cosmic_generic_mapping() {
assert!(matches!(
FontFamily::Cursive.to_cosmic_family(),
Family::Cursive
));
assert!(matches!(
FontFamily::Fantasy.to_cosmic_family(),
Family::Fantasy
));
}
#[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 registered_family_is_available_to_cosmic_text_rendering() {
let Some(bytes) = crate::render::font_registry::renamed_test_font(b"PCos") else {
return;
};
let family = "PCos Sans";
register_font_bytes(bytes).unwrap();
let font_system = lock_font_system().unwrap();
assert!(font_system.db().faces().any(|face| {
face.families
.iter()
.any(|(registered, _)| registered == family)
}));
drop(font_system);
let renderer = TextRenderer::new();
let config = FontConfig::new(FontFamily::Name(family.to_string()), 24.0);
let mut pixmap = Pixmap::new(96, 64).unwrap();
renderer
.render_text(&mut pixmap, "P12", 8.0, 8.0, &config, Color::BLACK)
.unwrap();
assert!(pixmap.pixels().iter().any(|pixel| pixel.alpha() > 0));
}
#[test]
fn invalid_registration_remains_a_successful_no_op() {
let font_system = lock_font_system().unwrap();
let faces_before = font_system.db().len();
let generation_before = crate::render::font_registry::snapshot().unwrap().generation;
assert!(register_font_bytes(b"not a font".to_vec()).is_ok());
assert_eq!(font_system.db().len(), faces_before);
assert_eq!(
crate::render::font_registry::snapshot().unwrap().generation,
generation_before
);
}
#[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 multiline_placement_height_includes_every_line() {
let renderer = TextRenderer::new();
let config = FontConfig::new(FontFamily::SansSerif, 12.0);
let single = renderer.measure_text_placement("first", &config).unwrap();
let multiline = renderer
.measure_text_placement(
"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve",
&config,
)
.unwrap();
assert!(multiline.height > single.height * 10.0);
assert_eq!(multiline.baseline_from_top, single.baseline_from_top);
}
#[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);
}
fn pixel_rgba(pixel: PremultipliedColorU8) -> [u8; 4] {
[pixel.red(), pixel.green(), pixel.blue(), pixel.alpha()]
}
fn composite_test_glyph(
destination: &mut PremultipliedColorU8,
glyph_pixel: GlyphPixel,
requested_alpha: u8,
) {
if let Some(source) = premultiplied_glyph_pixel(glyph_pixel, requested_alpha) {
blend_premultiplied_source_over(destination, source);
}
}
#[test]
fn glyph_compositing_combines_coverage_and_requested_alpha() {
let glyph = GlyphPixel::Straight([200, 100, 50, 128]);
let mut destination = PremultipliedColorU8::TRANSPARENT;
composite_test_glyph(&mut destination, glyph, 128);
assert_eq!(pixel_rgba(destination), [50, 25, 13, 64]);
}
#[test]
fn premultiplied_color_glyphs_are_not_premultiplied_twice() {
let color_outline = GlyphPixel::Premultiplied([128, 0, 0, 128]);
let color_bitmap = GlyphPixel::Straight([255, 0, 0, 128]);
let outline_source = premultiplied_glyph_pixel(color_outline, 255).unwrap();
let bitmap_source = premultiplied_glyph_pixel(color_bitmap, 255).unwrap();
assert_eq!(pixel_rgba(outline_source), [128, 0, 0, 128]);
assert_eq!(outline_source, bitmap_source);
let translucent_outline = premultiplied_glyph_pixel(color_outline, 128).unwrap();
assert_eq!(pixel_rgba(translucent_outline), [64, 0, 0, 64]);
}
#[test]
fn glyph_source_over_preserves_transparent_translucent_and_opaque_alpha() {
let glyph = GlyphPixel::Straight([200, 100, 50, 128]);
let cases = [
([0, 0, 0, 0], [50, 25, 13, 64]),
([20, 40, 60, 128], [65, 55, 58, 160]),
([10, 20, 30, 255], [57, 40, 35, 255]),
];
for (destination, expected) in cases {
let mut destination = PremultipliedColorU8::from_rgba(
destination[0],
destination[1],
destination[2],
destination[3],
)
.unwrap();
composite_test_glyph(&mut destination, glyph, 128);
assert_eq!(pixel_rgba(destination), expected);
}
}
#[test]
fn transparent_requested_text_is_a_no_op_and_opaque_text_replaces() {
let original = PremultipliedColorU8::from_rgba(20, 40, 60, 128).unwrap();
let glyph = GlyphPixel::Straight([200, 100, 50, 255]);
let mut destination = original;
composite_test_glyph(&mut destination, glyph, 0);
assert_eq!(destination, original);
composite_test_glyph(&mut destination, glyph, 255);
assert_eq!(pixel_rgba(destination), [200, 100, 50, 255]);
}
#[test]
fn transparent_centered_text_is_a_no_op() {
let renderer = TextRenderer::new();
let config = FontConfig::new(FontFamily::SansSerif, 24.0);
let mut pixmap = Pixmap::new(32, 32).unwrap();
pixmap.fill(tiny_skia::Color::from_rgba8(40, 80, 120, 128));
let before = pixmap.data().to_vec();
renderer
.render_text_centered(
&mut pixmap,
"centered",
16.0,
8.0,
&config,
Color::new_rgba(200, 100, 50, 0),
)
.unwrap();
assert_eq!(pixmap.data(), before);
}
fn nontransparent_bounds(pixmap: &Pixmap) -> Option<(u32, u32, u32, u32)> {
let mut min_x = pixmap.width();
let mut min_y = pixmap.height();
let mut max_x = 0;
let mut max_y = 0;
let mut found = false;
for y in 0..pixmap.height() {
for x in 0..pixmap.width() {
let pixel = pixmap.pixels()[(y * pixmap.width() + x) as usize];
if pixel.alpha() > 0 {
found = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
found.then_some((min_x, min_y, max_x, max_y))
}
fn cropped_pixels(pixmap: &Pixmap, bounds: (u32, u32, u32, u32)) -> Vec<[u8; 4]> {
let (min_x, min_y, max_x, max_y) = bounds;
let mut pixels = Vec::new();
for y in min_y..=max_y {
for x in min_x..=max_x {
pixels.push(pixel_rgba(
pixmap.pixels()[(y * pixmap.width() + x) as usize],
));
}
}
pixels
}
#[test]
fn rotated_text_is_pixel_exact_counterclockwise_parity() {
let renderer = TextRenderer::new();
let config = FontConfig::new(FontFamily::SansSerif, 32.0);
let color = Color::new_rgba(180, 90, 30, 128);
let mut normal = Pixmap::new(128, 128).unwrap();
let mut rotated = Pixmap::new(128, 128).unwrap();
renderer
.render_text(&mut normal, "A", 24.0, 24.0, &config, color)
.unwrap();
renderer
.render_text_rotated(&mut rotated, "A", 64.0, 64.0, &config, color)
.unwrap();
let normal_bounds = nontransparent_bounds(&normal).expect("normal text rendered no pixels");
let rotated_bounds =
nontransparent_bounds(&rotated).expect("rotated text rendered no pixels");
let normal_width = normal_bounds.2 - normal_bounds.0 + 1;
let normal_height = normal_bounds.3 - normal_bounds.1 + 1;
let rotated_width = rotated_bounds.2 - rotated_bounds.0 + 1;
let rotated_height = rotated_bounds.3 - rotated_bounds.1 + 1;
assert_eq!(
(rotated_width, rotated_height),
(normal_height, normal_width)
);
let normal_pixels = cropped_pixels(&normal, normal_bounds);
let rotated_pixels = cropped_pixels(&rotated, rotated_bounds);
for y in 0..normal_height {
for x in 0..normal_width {
let normal_index = (y * normal_width + x) as usize;
let rotated_x = y;
let rotated_y = normal_width - 1 - x;
let rotated_index = (rotated_y * rotated_width + rotated_x) as usize;
assert_eq!(rotated_pixels[rotated_index], normal_pixels[normal_index]);
}
}
let mut alphas = normal_pixels.iter().map(|pixel| pixel[3]);
assert!(alphas.clone().any(|alpha| alpha == color.a));
assert!(alphas.any(|alpha| alpha > 0 && alpha < color.a));
}
}