summaryrefslogtreecommitdiff
path: root/rust/crates/text
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/text
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz
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)
Diffstat (limited to 'rust/crates/text')
-rw-r--r--rust/crates/text/src/color.rs74
-rw-r--r--rust/crates/text/src/engine.rs194
-rw-r--r--rust/crates/text/src/layout.rs163
-rw-r--r--rust/crates/text/src/lib.rs21
-rw-r--r--rust/crates/text/src/params.rs166
-rw-r--r--rust/crates/text/src/raster.rs265
-rw-r--r--rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttfbin0 -> 708876 bytes
-rw-r--r--rust/crates/text/tests/fixtures/DejaVuSans.ttfbin0 -> 759676 bytes
-rw-r--r--rust/crates/text/tests/layout.rs370
-rw-r--r--rust/crates/text/tests/raster.rs172
10 files changed, 1425 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(&params.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,
+ &params.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));
+ }
+ }
+ }
+}
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
--- /dev/null
+++ b/rust/crates/text/tests/fixtures/DejaVuSans-Bold.ttf
Binary files 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
--- /dev/null
+++ b/rust/crates/text/tests/fixtures/DejaVuSans.ttf
Binary files 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(&params);
+ 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(&params);
+ 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(&params);
+ 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(&params);
+ 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(&params);
+ 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(&params).enabled);
+}
+
+#[test]
+fn resolve_scales_font_size_with_canvas_height() {
+ let params = dejavu_params("Hello");
+ let resolved = resolve_text_layout(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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(&params("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(&params("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(&params("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(&params("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));
+}