diff options
Diffstat (limited to 'rust/crates/text/src')
| -rw-r--r-- | rust/crates/text/src/color.rs | 74 | ||||
| -rw-r--r-- | rust/crates/text/src/engine.rs | 194 | ||||
| -rw-r--r-- | rust/crates/text/src/layout.rs | 163 | ||||
| -rw-r--r-- | rust/crates/text/src/lib.rs | 21 | ||||
| -rw-r--r-- | rust/crates/text/src/params.rs | 166 | ||||
| -rw-r--r-- | rust/crates/text/src/raster.rs | 265 |
6 files changed, 883 insertions, 0 deletions
diff --git a/rust/crates/text/src/color.rs b/rust/crates/text/src/color.rs new file mode 100644 index 00000000..fa6bd3e9 --- /dev/null +++ b/rust/crates/text/src/color.rs @@ -0,0 +1,74 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Color { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl Color { + pub const TRANSPARENT: Self = Self { + r: 0, + g: 0, + b: 0, + a: 0, + }; + + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } + + pub const fn rgb(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b, a: 0xFF } + } + + pub fn parse(value: &str) -> Option<Self> { + if value == "transparent" { + return Some(Self::TRANSPARENT); + } + let hex = value.strip_prefix('#')?; + let channel = |pair: &str| u8::from_str_radix(pair, 16).ok(); + match hex.len() { + 3 => { + let r = channel(&hex[0..1].repeat(2))?; + let g = channel(&hex[1..2].repeat(2))?; + let b = channel(&hex[2..3].repeat(2))?; + Some(Self::rgb(r, g, b)) + } + 4 => { + let r = channel(&hex[0..1].repeat(2))?; + let g = channel(&hex[1..2].repeat(2))?; + let b = channel(&hex[2..3].repeat(2))?; + let a = channel(&hex[3..4].repeat(2))?; + Some(Self::rgba(r, g, b, a)) + } + 6 => { + let r = channel(&hex[0..2])?; + let g = channel(&hex[2..4])?; + let b = channel(&hex[4..6])?; + Some(Self::rgb(r, g, b)) + } + 8 => { + let r = channel(&hex[0..2])?; + let g = channel(&hex[2..4])?; + let b = channel(&hex[4..6])?; + let a = channel(&hex[6..8])?; + Some(Self::rgba(r, g, b, a)) + } + _ => None, + } + } + + pub(crate) fn to_cosmic(self) -> cosmic_text::Color { + cosmic_text::Color::rgba(self.r, self.g, self.b, self.a) + } + + pub(crate) fn from_cosmic(color: cosmic_text::Color) -> Self { + Self::rgba(color.r(), color.g(), color.b(), color.a()) + } + + pub(crate) fn scaled_alpha(self, coverage: f64) -> Self { + let alpha = (f64::from(self.a) * coverage).round() as u8; + Self::rgba(self.r, self.g, self.b, alpha) + } +} diff --git a/rust/crates/text/src/engine.rs b/rust/crates/text/src/engine.rs new file mode 100644 index 00000000..9d0289fd --- /dev/null +++ b/rust/crates/text/src/engine.rs @@ -0,0 +1,194 @@ +use cosmic_text::fontdb::Source; +use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap}; +use std::sync::Arc; + +use crate::layout::{ + LineMetrics, ResolvedTextLayout, TextBlockMeasurement, measure_text_block, resolve_text_layout, +}; +use crate::params::{FontStyle, FontWeight, TextLayoutParams}; + +#[derive(Debug, thiserror::Error)] +pub enum TextError { + #[error("failed to load font: {0}")] + FontLoad(String), +} + +pub struct TextEngine { + pub(crate) font_system: FontSystem, + pub(crate) swash_cache: SwashCache, +} + +pub struct MeasuredTextLayout { + pub resolved: ResolvedTextLayout, + pub lines: Vec<String>, + pub line_metrics: Vec<LineMetrics>, + pub block: TextBlockMeasurement, + pub(crate) buffer: Buffer, +} + +impl TextEngine { + pub fn new() -> Self { + Self::from_font_system(FontSystem::new()) + } + + pub fn with_font_bytes(fonts: &[&[u8]]) -> Self { + let sources = fonts + .iter() + .map(|bytes| Source::Binary(Arc::new(bytes.to_vec()))); + Self::from_font_system(FontSystem::new_with_fonts(sources)) + } + + fn from_font_system(font_system: FontSystem) -> Self { + Self { + font_system, + swash_cache: SwashCache::new(), + } + } + + pub fn load_font_bytes(&mut self, bytes: &[u8]) -> Result<(), TextError> { + let loaded = self + .font_system + .db_mut() + .load_font_source(Source::Binary(Arc::new(bytes.to_vec()))); + if loaded.is_empty() { + return Err(TextError::FontLoad( + "no faces found in font data".to_string(), + )); + } + Ok(()) + } + + pub fn measure(&mut self, params: &TextLayoutParams, canvas_height: f64) -> MeasuredTextLayout { + self.measure_with_wrap(params, canvas_height, None) + } + + pub fn measure_with_wrap( + &mut self, + params: &TextLayoutParams, + canvas_height: f64, + wrap_width: Option<f64>, + ) -> MeasuredTextLayout { + let resolved = resolve_text_layout(params, canvas_height); + let mut buffer = self.build_buffer(params, &resolved, wrap_width); + buffer.shape_until_scroll(&mut self.font_system, false); + + let mut lines = Vec::new(); + let mut line_metrics = Vec::new(); + for line_i in 0..buffer.lines.len() { + let line_text = buffer.lines[line_i].text().to_string(); + let layouts = buffer + .line_layout(&mut self.font_system, line_i) + .unwrap_or(&[]); + let mut laid_out = false; + for layout_line in layouts { + laid_out = true; + let (text, metrics) = visual_line_metrics(&line_text, layout_line, &resolved); + lines.push(text); + line_metrics.push(metrics); + } + if !laid_out { + lines.push(line_text); + line_metrics.push(empty_line_metrics(&resolved)); + } + } + if line_metrics.is_empty() { + lines.push(String::new()); + line_metrics.push(empty_line_metrics(&resolved)); + } + + let block = measure_text_block(&line_metrics, resolved.line_height_px); + MeasuredTextLayout { + resolved, + lines, + line_metrics, + block, + buffer, + } + } + + fn build_buffer( + &mut self, + params: &TextLayoutParams, + resolved: &ResolvedTextLayout, + wrap_width: Option<f64>, + ) -> Buffer { + let metrics = Metrics::new( + resolved.scaled_font_size as f32, + resolved.line_height_px as f32, + ); + let mut buffer = Buffer::new(&mut self.font_system, metrics); + let (wrap, width) = match wrap_width { + Some(width) => (Wrap::WordOrGlyph, Some(width as f32)), + None => (Wrap::None, None), + }; + buffer.set_wrap(&mut self.font_system, wrap); + buffer.set_size(&mut self.font_system, width, None); + let attrs = Attrs::new() + .family(Family::Name(¶ms.font_family)) + .weight(match params.font_weight { + FontWeight::Normal => Weight::NORMAL, + FontWeight::Bold => Weight::BOLD, + }) + .style(match params.font_style { + FontStyle::Normal => Style::Normal, + FontStyle::Italic => Style::Italic, + }) + .letter_spacing((resolved.letter_spacing / resolved.scaled_font_size.max(f64::EPSILON)) as f32); + buffer.set_text( + &mut self.font_system, + ¶ms.content, + &attrs, + Shaping::Advanced, + None, + ); + buffer + } +} + +impl Default for TextEngine { + fn default() -> Self { + Self::new() + } +} + +fn visual_line_metrics( + line_text: &str, + layout_line: &cosmic_text::LayoutLine, + resolved: &ResolvedTextLayout, +) -> (String, LineMetrics) { + if layout_line.glyphs.is_empty() { + return (line_text.to_string(), empty_line_metrics(resolved)); + } + let raw_width = layout_line + .glyphs + .iter() + .fold(0.0_f32, |max, glyph| max.max(glyph.x + glyph.w)); + let width = (f64::from(raw_width) - resolved.letter_spacing).max(0.0); + let start = layout_line + .glyphs + .iter() + .map(|glyph| glyph.start) + .min() + .unwrap_or(0); + let end = layout_line + .glyphs + .iter() + .map(|glyph| glyph.end) + .max() + .unwrap_or(line_text.len()); + let text = line_text.get(start..end).unwrap_or(line_text).to_string(); + let metrics = LineMetrics { + width, + ascent: f64::from(layout_line.max_ascent), + descent: f64::from(layout_line.max_descent), + }; + (text, metrics) +} + +fn empty_line_metrics(resolved: &ResolvedTextLayout) -> LineMetrics { + LineMetrics { + width: 0.0, + ascent: resolved.scaled_font_size * 0.8, + descent: resolved.scaled_font_size * 0.2, + } +} diff --git a/rust/crates/text/src/layout.rs b/rust/crates/text/src/layout.rs new file mode 100644 index 00000000..c0e3a198 --- /dev/null +++ b/rust/crates/text/src/layout.rs @@ -0,0 +1,163 @@ +use crate::params::{ + FONT_SIZE_SCALE_REFERENCE, TextAlign, TextBackground, TextDecoration, TextLayoutParams, +}; + +pub const TEXT_DECORATION_THICKNESS_RATIO: f64 = 0.07; +pub const STRIKETHROUGH_VERTICAL_RATIO: f64 = 0.35; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedTextLayout { + pub scaled_font_size: f64, + pub letter_spacing: f64, + pub line_height_px: f64, + pub font_size_ratio: f64, + pub text_align: TextAlign, + pub text_decoration: TextDecoration, +} + +pub fn resolve_text_layout(text: &TextLayoutParams, canvas_height: f64) -> ResolvedTextLayout { + let scaled_font_size = text.font_size * (canvas_height / FONT_SIZE_SCALE_REFERENCE); + ResolvedTextLayout { + scaled_font_size, + letter_spacing: text.letter_spacing, + line_height_px: scaled_font_size * text.line_height, + font_size_ratio: text.font_size / 15.0, + text_align: text.text_align, + text_decoration: text.text_decoration, + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct LineMetrics { + pub width: f64, + pub ascent: f64, + pub descent: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TextBlockMeasurement { + pub visual_center_offset: f64, + pub height: f64, + pub max_width: f64, +} + +pub fn measure_text_block( + line_metrics: &[LineMetrics], + line_height_px: f64, +) -> TextBlockMeasurement { + let max_width = line_metrics + .iter() + .fold(0.0_f64, |max, metrics| max.max(metrics.width)); + let line_count = line_metrics.len() as f64; + TextBlockMeasurement { + visual_center_offset: ((line_count - 1.0) * line_height_px) / 2.0, + height: line_count * line_height_px, + max_width, + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Rect { + pub left: f64, + pub top: f64, + pub width: f64, + pub height: f64, +} + +impl Rect { + pub fn right(self) -> f64 { + self.left + self.width + } + + pub fn bottom(self) -> f64 { + self.top + self.height + } +} + +pub fn align_offset(text_align: TextAlign, line_width: f64) -> f64 { + match text_align { + TextAlign::Left => 0.0, + TextAlign::Center => -line_width / 2.0, + TextAlign::Right => -line_width, + } +} + +pub fn text_rect(text_align: TextAlign, block: &TextBlockMeasurement) -> Rect { + Rect { + left: align_offset(text_align, block.max_width), + top: -block.height / 2.0, + width: block.max_width, + height: block.height, + } +} + +fn background_visible(background: &TextBackground) -> bool { + background.enabled && !background.color.is_empty() && background.color != "transparent" +} + +pub fn text_background_rect( + text_align: TextAlign, + block: &TextBlockMeasurement, + background: &TextBackground, + font_size_ratio: f64, +) -> Option<Rect> { + if !background_visible(background) { + return None; + } + let rect = text_rect(text_align, block); + let padding_x = background.padding_x * font_size_ratio; + let padding_y = background.padding_y * font_size_ratio; + Some(Rect { + left: rect.left - padding_x + background.offset_x, + top: rect.top - padding_y + background.offset_y, + width: rect.width + padding_x * 2.0, + height: rect.height + padding_y * 2.0, + }) +} + +pub fn text_visual_rect( + text_align: TextAlign, + block: &TextBlockMeasurement, + background: &TextBackground, + font_size_ratio: f64, +) -> Rect { + let rect = text_rect(text_align, block); + let Some(background_rect) = text_background_rect(text_align, block, background, font_size_ratio) + else { + return rect; + }; + let left = rect.left.min(background_rect.left); + let top = rect.top.min(background_rect.top); + Rect { + left, + top, + width: rect.right().max(background_rect.right()) - left, + height: rect.bottom().max(background_rect.bottom()) - top, + } +} + +pub fn decoration_rect( + text_decoration: TextDecoration, + text_align: TextAlign, + line_width: f64, + line_y: f64, + ascent: f64, + descent: f64, + scaled_font_size: f64, +) -> Option<Rect> { + if text_decoration == TextDecoration::None { + return None; + } + let thickness = (scaled_font_size * TEXT_DECORATION_THICKNESS_RATIO).max(1.0); + let top = match text_decoration { + TextDecoration::Underline => line_y + descent + thickness, + TextDecoration::LineThrough => line_y - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO, + TextDecoration::None => unreachable!(), + }; + Some(Rect { + left: align_offset(text_align, line_width), + top, + width: line_width, + height: thickness, + }) +} diff --git a/rust/crates/text/src/lib.rs b/rust/crates/text/src/lib.rs index 8b137891..ccb324bf 100644 --- a/rust/crates/text/src/lib.rs +++ b/rust/crates/text/src/lib.rs @@ -1 +1,22 @@ +mod color; +mod engine; +mod layout; +mod params; +mod raster; +pub use color::Color; +pub use engine::{MeasuredTextLayout, TextEngine, TextError}; +pub use layout::{ + LineMetrics, Rect, ResolvedTextLayout, STRIKETHROUGH_VERTICAL_RATIO, + TEXT_DECORATION_THICKNESS_RATIO, TextBlockMeasurement, align_offset, decoration_rect, + measure_text_block, resolve_text_layout, text_background_rect, text_rect, text_visual_rect, +}; +pub use params::{ + CORNER_RADIUS_MAX, CORNER_RADIUS_MIN, DEFAULT_BACKGROUND_COLOR, + DEFAULT_BACKGROUND_CORNER_RADIUS, DEFAULT_BACKGROUND_ENABLED, DEFAULT_BACKGROUND_OFFSET_X, + DEFAULT_BACKGROUND_OFFSET_Y, DEFAULT_BACKGROUND_PADDING_X, DEFAULT_BACKGROUND_PADDING_Y, + DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_COLOR, FONT_SIZE_SCALE_REFERENCE, + FontStyle, FontWeight, MAX_FONT_SIZE, MIN_FONT_SIZE, TextAlign, TextBackground, TextDecoration, + TextLayoutParams, text_background_from, text_layout_params_from, +}; +pub use raster::{RasterOptions, RgbaBuffer, StrokeStyle, rasterize}; diff --git a/rust/crates/text/src/params.rs b/rust/crates/text/src/params.rs new file mode 100644 index 00000000..bf2d7702 --- /dev/null +++ b/rust/crates/text/src/params.rs @@ -0,0 +1,166 @@ +use scene::params::{ParamValue, ParamValues}; + +pub const MIN_FONT_SIZE: f64 = 5.0; +pub const MAX_FONT_SIZE: f64 = 300.0; +pub const DEFAULT_TEXT_COLOR: &str = "#000000"; +pub const FONT_SIZE_SCALE_REFERENCE: f64 = 90.0; +pub const CORNER_RADIUS_MIN: f64 = 0.0; +pub const CORNER_RADIUS_MAX: f64 = 100.0; +pub const DEFAULT_LETTER_SPACING: f64 = 0.0; +pub const DEFAULT_LINE_HEIGHT: f64 = 1.2; +pub const DEFAULT_BACKGROUND_ENABLED: bool = false; +pub const DEFAULT_BACKGROUND_COLOR: &str = "#000000"; +pub const DEFAULT_BACKGROUND_CORNER_RADIUS: f64 = 0.0; +pub const DEFAULT_BACKGROUND_PADDING_X: f64 = 30.0; +pub const DEFAULT_BACKGROUND_PADDING_Y: f64 = 42.0; +pub const DEFAULT_BACKGROUND_OFFSET_X: f64 = 0.0; +pub const DEFAULT_BACKGROUND_OFFSET_Y: f64 = 0.0; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextAlign { + Left, + #[default] + Center, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FontWeight { + #[default] + Normal, + Bold, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FontStyle { + #[default] + Normal, + Italic, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextDecoration { + #[default] + None, + Underline, + LineThrough, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextLayoutParams { + pub content: String, + pub font_size: f64, + pub font_family: String, + pub font_weight: FontWeight, + pub font_style: FontStyle, + pub text_align: TextAlign, + pub text_decoration: TextDecoration, + pub letter_spacing: f64, + pub line_height: f64, +} + +impl Default for TextLayoutParams { + fn default() -> Self { + Self { + content: "Default text".to_string(), + font_size: 15.0, + font_family: "Arial".to_string(), + font_weight: FontWeight::Normal, + font_style: FontStyle::Normal, + text_align: TextAlign::Center, + text_decoration: TextDecoration::None, + letter_spacing: DEFAULT_LETTER_SPACING, + line_height: DEFAULT_LINE_HEIGHT, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextBackground { + pub enabled: bool, + pub color: String, + pub corner_radius: f64, + pub padding_x: f64, + pub padding_y: f64, + pub offset_x: f64, + pub offset_y: f64, +} + +impl Default for TextBackground { + fn default() -> Self { + Self { + enabled: DEFAULT_BACKGROUND_ENABLED, + color: DEFAULT_BACKGROUND_COLOR.to_string(), + corner_radius: DEFAULT_BACKGROUND_CORNER_RADIUS, + padding_x: DEFAULT_BACKGROUND_PADDING_X, + padding_y: DEFAULT_BACKGROUND_PADDING_Y, + offset_x: DEFAULT_BACKGROUND_OFFSET_X, + offset_y: DEFAULT_BACKGROUND_OFFSET_Y, + } + } +} + +pub fn text_layout_params_from(params: &ParamValues) -> TextLayoutParams { + TextLayoutParams { + content: string_param(params, "content", "Default text"), + font_size: number_param(params, "fontSize", 15.0), + font_family: string_param(params, "fontFamily", "Arial"), + font_weight: match string_param(params, "fontWeight", "normal").as_str() { + "bold" => FontWeight::Bold, + _ => FontWeight::Normal, + }, + font_style: match string_param(params, "fontStyle", "normal").as_str() { + "italic" => FontStyle::Italic, + _ => FontStyle::Normal, + }, + text_align: match string_param(params, "textAlign", "center").as_str() { + "left" => TextAlign::Left, + "right" => TextAlign::Right, + _ => TextAlign::Center, + }, + text_decoration: match string_param(params, "textDecoration", "none").as_str() { + "underline" => TextDecoration::Underline, + "line-through" => TextDecoration::LineThrough, + _ => TextDecoration::None, + }, + letter_spacing: number_param(params, "letterSpacing", DEFAULT_LETTER_SPACING), + line_height: number_param(params, "lineHeight", DEFAULT_LINE_HEIGHT), + } +} + +pub fn text_background_from(params: &ParamValues) -> TextBackground { + TextBackground { + enabled: bool_param(params, "background.enabled", DEFAULT_BACKGROUND_ENABLED), + color: string_param(params, "background.color", DEFAULT_BACKGROUND_COLOR), + corner_radius: number_param( + params, + "background.cornerRadius", + DEFAULT_BACKGROUND_CORNER_RADIUS, + ), + padding_x: number_param(params, "background.paddingX", DEFAULT_BACKGROUND_PADDING_X), + padding_y: number_param(params, "background.paddingY", DEFAULT_BACKGROUND_PADDING_Y), + offset_x: number_param(params, "background.offsetX", DEFAULT_BACKGROUND_OFFSET_X), + offset_y: number_param(params, "background.offsetY", DEFAULT_BACKGROUND_OFFSET_Y), + } +} + +fn string_param(params: &ParamValues, key: &str, fallback: &str) -> String { + match params.get(key) { + Some(ParamValue::String(value)) => value.clone(), + _ => fallback.to_string(), + } +} + +fn number_param(params: &ParamValues, key: &str, fallback: f64) -> f64 { + match params.get(key) { + Some(ParamValue::Number(value)) => *value, + _ => fallback, + } +} + +fn bool_param(params: &ParamValues, key: &str, fallback: bool) -> bool { + match params.get(key) { + Some(ParamValue::Bool(value)) => *value, + _ => fallback, + } +} diff --git a/rust/crates/text/src/raster.rs b/rust/crates/text/src/raster.rs new file mode 100644 index 00000000..de945696 --- /dev/null +++ b/rust/crates/text/src/raster.rs @@ -0,0 +1,265 @@ +use crate::color::Color; +use crate::engine::{MeasuredTextLayout, TextEngine}; +use crate::layout::{align_offset, decoration_rect, text_background_rect, text_visual_rect}; +use crate::params::TextBackground; + +#[derive(Clone, Debug, PartialEq)] +pub struct RgbaBuffer { + pub width: u32, + pub height: u32, + pub pixels: Vec<u8>, +} + +impl RgbaBuffer { + fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + pixels: vec![0; width as usize * height as usize * 4], + } + } + + pub fn pixel(&self, x: u32, y: u32) -> Color { + let index = (y as usize * self.width as usize + x as usize) * 4; + Color::rgba( + self.pixels[index], + self.pixels[index + 1], + self.pixels[index + 2], + self.pixels[index + 3], + ) + } + + fn blend_pixel(&mut self, x: i32, y: i32, color: Color) { + if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 || color.a == 0 { + return; + } + let index = (y as usize * self.width as usize + x as usize) * 4; + let src_alpha = u32::from(color.a); + let dst_alpha = u32::from(self.pixels[index + 3]); + let out_alpha = src_alpha + dst_alpha * (255 - src_alpha) / 255; + if out_alpha == 0 { + return; + } + for (channel, src) in [(0, color.r), (1, color.g), (2, color.b)] { + let dst = u32::from(self.pixels[index + channel]); + let blended = (u32::from(src) * src_alpha * 255 + + dst * dst_alpha * (255 - src_alpha)) + / (out_alpha * 255); + self.pixels[index + channel] = blended as u8; + } + self.pixels[index + 3] = out_alpha as u8; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StrokeStyle { + pub color: Color, + pub width: f64, +} + +#[derive(Clone, Copy, Debug)] +pub struct RasterOptions<'a> { + pub text_color: Color, + pub background: Option<(&'a TextBackground, Color)>, + pub stroke: Option<StrokeStyle>, +} + +pub fn rasterize( + engine: &mut TextEngine, + measured: &MeasuredTextLayout, + options: &RasterOptions, +) -> RgbaBuffer { + let resolved = &measured.resolved; + let disabled_background = TextBackground::default(); + let rect_background = options + .background + .map(|(background, _)| background) + .unwrap_or(&disabled_background); + let visual = text_visual_rect( + resolved.text_align, + &measured.block, + rect_background, + resolved.font_size_ratio, + ); + let width = visual.width.ceil().max(1.0) as u32; + let height = visual.height.ceil().max(1.0) as u32; + let mut output = RgbaBuffer::new(width, height); + let origin_x = -visual.left; + let origin_y = -visual.top; + + if let Some((background, color)) = options.background { + if color.a > 0 { + if let Some(rect) = text_background_rect( + resolved.text_align, + &measured.block, + background, + resolved.font_size_ratio, + ) { + let radius = (background.corner_radius.clamp( + crate::params::CORNER_RADIUS_MIN, + crate::params::CORNER_RADIUS_MAX, + ) / 100.0) + * (rect.width.min(rect.height) / 2.0); + fill_rounded_rect( + &mut output, + origin_x + rect.left, + origin_y + rect.top, + rect.width, + rect.height, + radius, + color, + ); + } + } + } + + if options.text_color.a > 0 || options.stroke.is_some_and(|stroke| stroke.color.a > 0) { + draw_glyphs(engine, measured, &mut output, origin_x, origin_y, options); + } + + if options.text_color.a > 0 { + for (index, metrics) in measured.line_metrics.iter().enumerate() { + let line_y = + index as f64 * resolved.line_height_px - measured.block.visual_center_offset; + if let Some(rect) = decoration_rect( + resolved.text_decoration, + resolved.text_align, + metrics.width, + line_y, + metrics.ascent, + metrics.descent, + resolved.scaled_font_size, + ) { + fill_rect( + &mut output, + origin_x + rect.left, + origin_y + rect.top, + rect.width, + rect.height, + options.text_color, + ); + } + } + } + + output +} + +fn draw_glyphs( + engine: &mut TextEngine, + measured: &MeasuredTextLayout, + output: &mut RgbaBuffer, + origin_x: f64, + origin_y: f64, + options: &RasterOptions, +) { + let resolved = &measured.resolved; + let y_shift = origin_y - measured.block.height / 2.0; + for run in measured.buffer.layout_runs() { + let raw_width = run + .glyphs + .iter() + .fold(0.0_f32, |max, glyph| max.max(glyph.x + glyph.w)); + let line_width = (f64::from(raw_width) - resolved.letter_spacing).max(0.0); + let x_offset = origin_x + align_offset(resolved.text_align, line_width); + for glyph in run.glyphs { + let physical = glyph.physical((x_offset as f32, y_shift as f32), 1.0); + let baseline = run.line_y as i32; + if let Some(stroke) = options.stroke { + if stroke.color.a > 0 && stroke.width > 0.0 { + let cosmic = stroke.color.to_cosmic(); + let offsets = stroke_offsets(stroke.width / 2.0); + engine.swash_cache.with_pixels( + &mut engine.font_system, + physical.cache_key, + cosmic, + |x, y, color| { + for (dx, dy) in &offsets { + output.blend_pixel( + physical.x + x + dx, + baseline + physical.y + y + dy, + Color::from_cosmic(color), + ); + } + }, + ); + } + } + if options.text_color.a > 0 { + let cosmic = options.text_color.to_cosmic(); + engine.swash_cache.with_pixels( + &mut engine.font_system, + physical.cache_key, + cosmic, + |x, y, color| { + output.blend_pixel( + physical.x + x, + baseline + physical.y + y, + Color::from_cosmic(color), + ); + }, + ); + } + } + } +} + +fn stroke_offsets(radius: f64) -> Vec<(i32, i32)> { + let mut offsets = Vec::new(); + if radius < 0.5 { + offsets.push((0, 0)); + return offsets; + } + let steps = ((radius * 6.0).ceil() as u32).max(16); + for step in 0..steps { + let angle = std::f64::consts::TAU * f64::from(step) / f64::from(steps); + let dx = (radius * angle.cos()).round() as i32; + let dy = (radius * angle.sin()).round() as i32; + if !offsets.contains(&(dx, dy)) { + offsets.push((dx, dy)); + } + } + offsets +} + +fn fill_rect(output: &mut RgbaBuffer, left: f64, top: f64, width: f64, height: f64, color: Color) { + let x_start = left.round() as i32; + let y_start = top.round() as i32; + let x_end = (left + width).round() as i32; + let y_end = (top + height).round() as i32; + for y in y_start..y_end { + for x in x_start..x_end { + output.blend_pixel(x, y, color); + } + } +} + +fn fill_rounded_rect( + output: &mut RgbaBuffer, + left: f64, + top: f64, + width: f64, + height: f64, + radius: f64, + color: Color) { + let radius = radius.clamp(0.0, width.min(height) / 2.0); + let center_x = left + width / 2.0; + let center_y = top + height / 2.0; + let inner_x = (width / 2.0 - radius).max(0.0); + let inner_y = (height / 2.0 - radius).max(0.0); + let x_start = left.floor() as i32; + let y_start = top.floor() as i32; + let x_end = (left + width).ceil() as i32; + let y_end = (top + height).ceil() as i32; + for y in y_start..y_end { + for x in x_start..x_end { + let qx = (f64::from(x) + 0.5 - center_x).abs() - inner_x; + let qy = (f64::from(y) + 0.5 - center_y).abs() - inner_y; + let outside = qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius; + let coverage = (0.5 - outside).clamp(0.0, 1.0); + if coverage > 0.0 { + output.blend_pixel(x, y, color.scaled_alpha(coverage)); + } + } + } +} |
