From 32438ce25f69de99d2523b98237f62cfa90890d7 Mon Sep 17 00:00:00 2001 From: cashmere Date: Tue, 28 Jul 2026 20:14:51 +0200 Subject: feat: port domain crates to Rust (session 1 complete) All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps) --- rust/crates/text/src/color.rs | 74 +++++ rust/crates/text/src/engine.rs | 194 +++++++++++ rust/crates/text/src/layout.rs | 163 +++++++++ rust/crates/text/src/lib.rs | 21 ++ rust/crates/text/src/params.rs | 166 +++++++++ rust/crates/text/src/raster.rs | 265 +++++++++++++++ .../crates/text/tests/fixtures/DejaVuSans-Bold.ttf | Bin 0 -> 708876 bytes rust/crates/text/tests/fixtures/DejaVuSans.ttf | Bin 0 -> 759676 bytes rust/crates/text/tests/layout.rs | 370 +++++++++++++++++++++ rust/crates/text/tests/raster.rs | 172 ++++++++++ 10 files changed, 1425 insertions(+) create mode 100644 rust/crates/text/src/color.rs create mode 100644 rust/crates/text/src/engine.rs create mode 100644 rust/crates/text/src/layout.rs create mode 100644 rust/crates/text/src/params.rs create mode 100644 rust/crates/text/src/raster.rs create mode 100644 rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf create mode 100644 rust/crates/text/tests/fixtures/DejaVuSans.ttf create mode 100644 rust/crates/text/tests/layout.rs create mode 100644 rust/crates/text/tests/raster.rs (limited to 'rust/crates/text') 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 { + 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, + pub line_metrics: Vec, + 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, + ) -> 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, + ) -> 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 { + 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 { + 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, +} + +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, +} + +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)); + } + } + } +} diff --git a/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf b/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf new file mode 100644 index 00000000..3710f897 Binary files /dev/null and b/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf differ diff --git a/rust/crates/text/tests/fixtures/DejaVuSans.ttf b/rust/crates/text/tests/fixtures/DejaVuSans.ttf new file mode 100644 index 00000000..1a126069 Binary files /dev/null and b/rust/crates/text/tests/fixtures/DejaVuSans.ttf differ diff --git a/rust/crates/text/tests/layout.rs b/rust/crates/text/tests/layout.rs new file mode 100644 index 00000000..c0d652a5 --- /dev/null +++ b/rust/crates/text/tests/layout.rs @@ -0,0 +1,370 @@ +use scene::params::{ParamValue, ParamValues}; +use text::{ + Color, FontStyle, FontWeight, TextAlign, TextBackground, TextDecoration, TextEngine, + TextLayoutParams, align_offset, decoration_rect, measure_text_block, resolve_text_layout, + text_background_from, text_background_rect, text_layout_params_from, text_rect, + text_visual_rect, +}; + +const FONT_REGULAR: &[u8] = include_bytes!("fixtures/DejaVuSans.ttf"); +const FONT_BOLD: &[u8] = include_bytes!("fixtures/DejaVuSans-Bold.ttf"); + +fn engine() -> TextEngine { + TextEngine::with_font_bytes(&[FONT_REGULAR, FONT_BOLD]) +} + +fn dejavu_params(content: &str) -> TextLayoutParams { + TextLayoutParams { + content: content.to_string(), + font_family: "DejaVu Sans".to_string(), + ..TextLayoutParams::default() + } +} + +#[test] +fn params_fall_back_to_web_defaults() { + let params = ParamValues::new(); + let layout = text_layout_params_from(¶ms); + assert_eq!(layout, TextLayoutParams::default()); + assert_eq!(layout.content, "Default text"); + assert_eq!(layout.font_size, 15.0); + assert_eq!(layout.font_family, "Arial"); + assert_eq!(layout.text_align, TextAlign::Center); + assert_eq!(layout.text_decoration, TextDecoration::None); + assert_eq!(layout.letter_spacing, 0.0); + assert_eq!(layout.line_height, 1.2); + + let background = text_background_from(¶ms); + assert_eq!(background, TextBackground::default()); + assert!(!background.enabled); + assert_eq!(background.color, "#000000"); + assert_eq!(background.padding_x, 30.0); + assert_eq!(background.padding_y, 42.0); +} + +#[test] +fn params_read_typed_values_and_reject_wrong_types() { + let mut params = ParamValues::new(); + params.insert("content".into(), ParamValue::String("Hello".into())); + params.insert("fontSize".into(), ParamValue::Number(24.0)); + params.insert("fontFamily".into(), ParamValue::String("Inter".into())); + params.insert("fontWeight".into(), ParamValue::String("bold".into())); + params.insert("fontStyle".into(), ParamValue::String("italic".into())); + params.insert("textAlign".into(), ParamValue::String("right".into())); + params.insert( + "textDecoration".into(), + ParamValue::String("line-through".into()), + ); + params.insert("letterSpacing".into(), ParamValue::Number(2.5)); + params.insert("lineHeight".into(), ParamValue::Number(1.5)); + params.insert("background.enabled".into(), ParamValue::Bool(true)); + params.insert( + "background.color".into(), + ParamValue::String("#ff0000".into()), + ); + params.insert("background.cornerRadius".into(), ParamValue::Number(50.0)); + params.insert("background.paddingX".into(), ParamValue::Number(8.0)); + params.insert("background.paddingY".into(), ParamValue::Number(6.0)); + params.insert("background.offsetX".into(), ParamValue::Number(-4.0)); + params.insert("background.offsetY".into(), ParamValue::Number(3.0)); + params.insert("opacity".into(), ParamValue::String("not-a-number".into())); + + let layout = text_layout_params_from(¶ms); + assert_eq!(layout.content, "Hello"); + assert_eq!(layout.font_size, 24.0); + assert_eq!(layout.font_family, "Inter"); + assert_eq!(layout.font_weight, FontWeight::Bold); + assert_eq!(layout.font_style, FontStyle::Italic); + assert_eq!(layout.text_align, TextAlign::Right); + assert_eq!(layout.text_decoration, TextDecoration::LineThrough); + assert_eq!(layout.letter_spacing, 2.5); + assert_eq!(layout.line_height, 1.5); + + let background = text_background_from(¶ms); + assert!(background.enabled); + assert_eq!(background.color, "#ff0000"); + assert_eq!(background.corner_radius, 50.0); + assert_eq!(background.padding_x, 8.0); + assert_eq!(background.padding_y, 6.0); + assert_eq!(background.offset_x, -4.0); + assert_eq!(background.offset_y, 3.0); +} + +#[test] +fn params_ignore_mismatched_value_types() { + let mut params = ParamValues::new(); + params.insert("content".into(), ParamValue::Number(42.0)); + params.insert("fontSize".into(), ParamValue::String("big".into())); + params.insert("fontWeight".into(), ParamValue::String("heavy".into())); + params.insert("textAlign".into(), ParamValue::String("justify".into())); + params.insert("background.enabled".into(), ParamValue::String("yes".into())); + + let layout = text_layout_params_from(¶ms); + assert_eq!(layout.content, "Default text"); + assert_eq!(layout.font_size, 15.0); + assert_eq!(layout.font_weight, FontWeight::Normal); + assert_eq!(layout.text_align, TextAlign::Center); + assert!(!text_background_from(¶ms).enabled); +} + +#[test] +fn resolve_scales_font_size_with_canvas_height() { + let params = dejavu_params("Hello"); + let resolved = resolve_text_layout(¶ms, 90.0); + assert_eq!(resolved.scaled_font_size, 15.0); + assert_eq!(resolved.line_height_px, 18.0); + assert_eq!(resolved.font_size_ratio, 1.0); + + let doubled = resolve_text_layout(¶ms, 180.0); + assert_eq!(doubled.scaled_font_size, 30.0); + assert_eq!(doubled.line_height_px, 36.0); +} + +#[test] +fn measure_splits_explicit_lines_and_sizes_block() { + let mut engine = engine(); + let params = dejavu_params("ab\ncdef\ng"); + let measured = engine.measure(¶ms, 90.0); + + assert_eq!(measured.lines, vec!["ab", "cdef", "g"]); + assert_eq!(measured.line_metrics.len(), 3); + assert_eq!(measured.block.height, 54.0); + assert_eq!(measured.block.visual_center_offset, 18.0); + + let widest = measured.line_metrics[1].width; + assert_eq!(measured.block.max_width, widest); + assert!(widest > measured.line_metrics[0].width); + assert!(measured.line_metrics[0].width > measured.line_metrics[2].width); + assert!(measured.line_metrics[2].width > 0.0); +} + +#[test] +fn measure_trailing_empty_line_keeps_block_height() { + let mut engine = engine(); + let params = dejavu_params("ab\n"); + let measured = engine.measure(¶ms, 90.0); + + assert_eq!(measured.lines.len(), 2); + assert_eq!(measured.block.height, 36.0); + assert_eq!(measured.line_metrics[1].width, 0.0); + assert_eq!( + measured.line_metrics[1].ascent, + measured.resolved.scaled_font_size * 0.8 + ); + assert_eq!( + measured.line_metrics[1].descent, + measured.resolved.scaled_font_size * 0.2 + ); +} + +#[test] +fn wrap_width_controls_visual_line_count() { + let mut engine = engine(); + let long_word = "a".repeat(30); + let params = dejavu_params(&long_word); + + let unwrapped = engine.measure(¶ms, 90.0); + assert_eq!(unwrapped.lines.len(), 1); + + let ten_a = dejavu_params(&"a".repeat(10)); + let ten_width = engine.measure(&ten_a, 90.0).line_metrics[0].width; + let wrapped = engine.measure_with_wrap(¶ms, 90.0, Some(ten_width + 1.0)); + assert_eq!(wrapped.lines.len(), 3); + assert_eq!(wrapped.block.height, 3.0 * wrapped.resolved.line_height_px); + for metrics in &wrapped.line_metrics { + assert!(metrics.width <= ten_width + 1.0); + } +} + +#[test] +fn line_height_scales_block_height() { + let mut engine = engine(); + let single = dejavu_params("ab\ncd"); + let relaxed = TextLayoutParams { + line_height: 2.4, + ..single.clone() + }; + + let tight_layout = engine.measure(&single, 90.0); + let relaxed_layout = engine.measure(&relaxed, 90.0); + assert_eq!(relaxed_layout.block.height, tight_layout.block.height * 2.0); + assert_eq!( + relaxed_layout.block.visual_center_offset, + tight_layout.block.visual_center_offset * 2.0 + ); +} + +#[test] +fn letter_spacing_widens_lines_monotonically() { + let mut engine = engine(); + let content = "a".repeat(10); + let width_at = |engine: &mut TextEngine, spacing: f64| { + let params = TextLayoutParams { + content: content.clone(), + font_family: "DejaVu Sans".to_string(), + letter_spacing: spacing, + ..TextLayoutParams::default() + }; + engine.measure(¶ms, 90.0).line_metrics[0].width + }; + + let zero = width_at(&mut engine, 0.0); + let five = width_at(&mut engine, 5.0); + let ten = width_at(&mut engine, 10.0); + assert!(five > zero); + assert!(ten > five); + assert!((ten - zero - 90.0).abs() < 0.5); +} + +#[test] +fn block_measurement_matches_web_formulas() { + let metrics = [ + text::LineMetrics { + width: 40.0, + ascent: 12.0, + descent: 3.0, + }, + text::LineMetrics { + width: 75.0, + ascent: 12.0, + descent: 3.0, + }, + ]; + let block = measure_text_block(&metrics, 20.0); + assert_eq!(block.max_width, 75.0); + assert_eq!(block.height, 40.0); + assert_eq!(block.visual_center_offset, 10.0); + + assert_eq!(align_offset(TextAlign::Left, 50.0), 0.0); + assert_eq!(align_offset(TextAlign::Center, 50.0), -25.0); + assert_eq!(align_offset(TextAlign::Right, 50.0), -50.0); +} + +#[test] +fn background_rect_matches_hand_computed_rect() { + let mut engine = engine(); + let params = TextLayoutParams { + text_align: TextAlign::Left, + ..dejavu_params("abcd") + }; + let measured = engine.measure(¶ms, 90.0); + let block = measured.block; + let width = block.max_width; + + let background = TextBackground { + enabled: true, + color: "#ff0000".to_string(), + corner_radius: 0.0, + padding_x: 10.0, + padding_y: 5.0, + offset_x: 3.0, + offset_y: -2.0, + }; + + let rect = text_background_rect(TextAlign::Left, &block, &background, 1.0) + .expect("visible background"); + assert_eq!(rect.left, -7.0); + assert_eq!(rect.top, -16.0); + assert_eq!(rect.width, width + 20.0); + assert_eq!(rect.height, 28.0); + + let centered = text_background_rect(TextAlign::Center, &block, &background, 1.0) + .expect("visible background"); + assert_eq!(centered.left, -width / 2.0 - 7.0); + assert_eq!(centered.width, width + 20.0); + + let scaled = text_background_rect(TextAlign::Left, &block, &background, 2.0) + .expect("visible background"); + assert_eq!(scaled.left, -17.0); + assert_eq!(scaled.width, width + 40.0); + assert_eq!(scaled.height, 38.0); +} + +#[test] +fn visual_rect_unions_text_and_background() { + let mut engine = engine(); + let params = dejavu_params("abcd"); + let measured = engine.measure(¶ms, 90.0); + let block = measured.block; + + let disabled = TextBackground::default(); + let visual = text_visual_rect(TextAlign::Center, &block, &disabled, 1.0); + assert_eq!(visual, text_rect(TextAlign::Center, &block)); + + let background = TextBackground { + enabled: true, + padding_x: 10.0, + padding_y: 5.0, + ..TextBackground::default() + }; + let visual = text_visual_rect(TextAlign::Center, &block, &background, 1.0); + assert_eq!(visual.width, block.max_width + 20.0); + assert_eq!(visual.height, block.height + 10.0); + assert_eq!(visual.left, -block.max_width / 2.0 - 10.0); + assert_eq!(visual.top, -block.height / 2.0 - 5.0); + + let invisible = TextBackground { + enabled: true, + color: "transparent".to_string(), + ..TextBackground::default() + }; + assert_eq!( + text_background_rect(TextAlign::Center, &block, &invisible, 1.0), + None + ); +} + +#[test] +fn decoration_rect_matches_web_geometry() { + let underline = decoration_rect( + TextDecoration::Underline, + TextAlign::Left, + 100.0, + 0.0, + 12.0, + 3.0, + 20.0, + ) + .expect("underline"); + assert_eq!(underline.left, 0.0); + assert_eq!(underline.width, 100.0); + assert!((underline.height - 1.4).abs() < 1e-9); + assert!((underline.top - 4.4).abs() < 1e-9); + + let strike = decoration_rect( + TextDecoration::LineThrough, + TextAlign::Center, + 100.0, + 0.0, + 12.0, + 3.0, + 20.0, + ) + .expect("line-through"); + assert_eq!(strike.left, -50.0); + assert!((strike.top - -3.15).abs() < 1e-9); + assert!((strike.height - 1.4).abs() < 1e-9); + + let tiny = decoration_rect(TextDecoration::Underline, TextAlign::Right, 50.0, 0.0, 8.0, 2.0, 5.0) + .expect("underline"); + assert_eq!(tiny.height, 1.0); + assert_eq!(tiny.left, -50.0); + + assert_eq!( + decoration_rect(TextDecoration::None, TextAlign::Left, 100.0, 0.0, 12.0, 3.0, 20.0), + None + ); +} + +#[test] +fn color_parses_web_hex_formats() { + assert_eq!(Color::parse("#ff0000"), Some(Color::rgb(255, 0, 0))); + assert_eq!(Color::parse("#f00"), Some(Color::rgb(255, 0, 0))); + assert_eq!( + Color::parse("#ff000080"), + Some(Color::rgba(255, 0, 0, 128)) + ); + assert_eq!(Color::parse("transparent"), Some(Color::TRANSPARENT)); + assert_eq!(Color::parse("red"), None); + assert_eq!(Color::parse(""), None); +} diff --git a/rust/crates/text/tests/raster.rs b/rust/crates/text/tests/raster.rs new file mode 100644 index 00000000..2e6921a5 --- /dev/null +++ b/rust/crates/text/tests/raster.rs @@ -0,0 +1,172 @@ +use text::{ + Color, RasterOptions, StrokeStyle, TextAlign, TextBackground, TextEngine, TextLayoutParams, + rasterize, text_visual_rect, +}; + +const FONT_REGULAR: &[u8] = include_bytes!("fixtures/DejaVuSans.ttf"); +const FONT_BOLD: &[u8] = include_bytes!("fixtures/DejaVuSans-Bold.ttf"); + +fn engine() -> TextEngine { + TextEngine::with_font_bytes(&[FONT_REGULAR, FONT_BOLD]) +} + +fn params(content: &str) -> TextLayoutParams { + TextLayoutParams { + content: content.to_string(), + font_family: "DejaVu Sans".to_string(), + ..TextLayoutParams::default() + } +} + +fn opaque_pixels(buffer: &text::RgbaBuffer) -> usize { + buffer.pixels.chunks(4).filter(|px| px[3] > 0).count() +} + +#[test] +fn raster_produces_non_empty_output_with_visual_rect_dimensions() { + let mut engine = engine(); + let measured = engine.measure(¶ms("Hello"), 90.0); + let visual = text_visual_rect( + TextAlign::Center, + &measured.block, + &TextBackground::default(), + 1.0, + ); + let buffer = rasterize( + &mut engine, + &measured, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: None, + stroke: None, + }, + ); + + assert_eq!(buffer.width, visual.width.ceil() as u32); + assert_eq!(buffer.height, visual.height.ceil() as u32); + assert_eq!(buffer.pixels.len(), buffer.width as usize * buffer.height as usize * 4); + assert!(opaque_pixels(&buffer) > 0); + assert_eq!(buffer.pixel(0, 0).a, 0); +} + +#[test] +fn raster_fills_background_inside_padded_box() { + let mut engine = engine(); + let measured = engine.measure(¶ms("Hello"), 90.0); + let background = TextBackground { + enabled: true, + color: "#ff0000".to_string(), + ..TextBackground::default() + }; + let red = Color::rgb(255, 0, 0); + let buffer = rasterize( + &mut engine, + &measured, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: Some((&background, red)), + stroke: None, + }, + ); + + assert_eq!(buffer.height, 102); + let corner = buffer.pixel(2, 2); + assert!(corner.a > 200); + assert!(corner.r > 200); + assert!(corner.g < 60); + assert!(corner.b < 60); +} + +#[test] +fn raster_dimensions_grow_with_background_padding() { + let mut engine = engine(); + let measured = engine.measure(¶ms("Hello"), 90.0); + let plain = rasterize( + &mut engine, + &measured, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: None, + stroke: None, + }, + ); + let background = TextBackground { + enabled: true, + color: "#ff0000".to_string(), + ..TextBackground::default() + }; + let padded = rasterize( + &mut engine, + &measured, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: Some((&background, Color::rgb(255, 0, 0))), + stroke: None, + }, + ); + + assert_eq!(padded.width, plain.width + 60); + assert_eq!(padded.height, plain.height + 84); +} + +#[test] +fn raster_underline_adds_pixels_below_text() { + let mut engine = engine(); + let plain_params = params("Hello"); + let plain = engine.measure(&plain_params, 90.0); + let plain_buffer = rasterize( + &mut engine, + &plain, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: None, + stroke: None, + }, + ); + + let underlined_params = TextLayoutParams { + text_decoration: text::TextDecoration::Underline, + ..params("Hello") + }; + let underlined = engine.measure(&underlined_params, 90.0); + let underlined_buffer = rasterize( + &mut engine, + &underlined, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: None, + stroke: None, + }, + ); + + assert!(opaque_pixels(&underlined_buffer) > opaque_pixels(&plain_buffer)); +} + +#[test] +fn raster_stroke_widens_glyph_coverage() { + let mut engine = engine(); + let measured = engine.measure(¶ms("Hello"), 180.0); + let fill_only = rasterize( + &mut engine, + &measured, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: None, + stroke: None, + }, + ); + let stroked = rasterize( + &mut engine, + &measured, + &RasterOptions { + text_color: Color::rgb(255, 255, 255), + background: None, + stroke: Some(StrokeStyle { + color: Color::rgb(0, 0, 0), + width: 4.0, + }), + }, + ); + + assert!(opaque_pixels(&stroked) > opaque_pixels(&fill_only)); +} -- cgit v1.3.1