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/graphics/src/gradient/ast.rs | 105 +++++ rust/crates/graphics/src/gradient/mod.rs | 15 + rust/crates/graphics/src/gradient/parser.rs | 614 ++++++++++++++++++++++++++++ rust/crates/graphics/src/gradient/raster.rs | 549 +++++++++++++++++++++++++ rust/crates/graphics/src/gradient/stops.rs | 151 +++++++ 5 files changed, 1434 insertions(+) create mode 100644 rust/crates/graphics/src/gradient/ast.rs create mode 100644 rust/crates/graphics/src/gradient/mod.rs create mode 100644 rust/crates/graphics/src/gradient/parser.rs create mode 100644 rust/crates/graphics/src/gradient/raster.rs create mode 100644 rust/crates/graphics/src/gradient/stops.rs (limited to 'rust/crates/graphics/src/gradient') diff --git a/rust/crates/graphics/src/gradient/ast.rs b/rust/crates/graphics/src/gradient/ast.rs new file mode 100644 index 00000000..d3336fea --- /dev/null +++ b/rust/crates/graphics/src/gradient/ast.rs @@ -0,0 +1,105 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GradientKind { + Linear, + RepeatingLinear, + Radial, + RepeatingRadial, +} + +impl GradientKind { + pub fn as_str(&self) -> &'static str { + match self { + GradientKind::Linear => "linear-gradient", + GradientKind::RepeatingLinear => "repeating-linear-gradient", + GradientKind::Radial => "radial-gradient", + GradientKind::RepeatingRadial => "repeating-radial-gradient", + } + } + + pub fn is_linear(&self) -> bool { + matches!(self, GradientKind::Linear | GradientKind::RepeatingLinear) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LinearOrientation { + Directional(String), + Angular(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Distance { + Percent(String), + PositionKeyword(String), + Calc(String), + Px(String), + Em(String), +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Position { + pub x: Option, + pub y: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShapeKind { + Circle, + Ellipse, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ShapeStyle { + Distance(Distance), + ExtentKeyword(String), + Position(Position), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Shape { + pub value: ShapeKind, + pub style: Option, + pub at: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RadialOrientation { + Shape(Shape), + ExtentKeyword { + value: String, + at: Option, + }, + DefaultRadial { + at: Position, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GradientOrientation { + Linear(LinearOrientation), + Radial(Vec), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Color { + Hex(String), + Literal(String), + Rgb(Vec), + Rgba(Vec), + Hsl([String; 3]), + Hsla([String; 4]), + Var(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ColorStop { + pub color: Color, + pub length: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GradientAst { + pub kind: GradientKind, + pub orientation: Option, + pub color_stops: Vec, +} diff --git a/rust/crates/graphics/src/gradient/mod.rs b/rust/crates/graphics/src/gradient/mod.rs new file mode 100644 index 00000000..ec597ad0 --- /dev/null +++ b/rust/crates/graphics/src/gradient/mod.rs @@ -0,0 +1,15 @@ +pub mod ast; +pub mod parser; +pub mod raster; +pub mod stops; + +pub use ast::{ + Color, ColorStop, Distance, GradientAst, GradientKind, GradientOrientation, + LinearOrientation, Position, RadialOrientation, Shape, ShapeKind, ShapeStyle, +}; +pub use parser::{GradientParseError, parse_gradient}; +pub use raster::{ + BackgroundLayer, draw_css_background, is_gradient_layer, parse_background_layers, + split_css_layers, +}; +pub use stops::{NormalizedColorStop, color_to_string, normalize_color_stops}; diff --git a/rust/crates/graphics/src/gradient/parser.rs b/rust/crates/graphics/src/gradient/parser.rs new file mode 100644 index 00000000..d19a05ae --- /dev/null +++ b/rust/crates/graphics/src/gradient/parser.rs @@ -0,0 +1,614 @@ +use thiserror::Error; + +use super::ast::{ + Color, ColorStop, Distance, GradientAst, GradientKind, GradientOrientation, + LinearOrientation, Position, RadialOrientation, Shape, ShapeKind, ShapeStyle, +}; + +#[derive(Debug, Clone, Error, PartialEq)] +#[error("{input}: {message}")] +pub struct GradientParseError { + pub input: String, + pub message: String, +} + +type PResult = Result; + +type TokenMatch = (usize, usize, usize); + +fn match_ci_prefix(s: &str, keyword: &str) -> Option { + if s.len() >= keyword.len() && s[..keyword.len()].eq_ignore_ascii_case(keyword) { + Some(keyword.len()) + } else { + None + } +} + +fn scan_number_str(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut index = 0; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + let int_len = index; + if index < bytes.len() && bytes[index] == b'.' { + index += 1; + let frac_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if index > frac_start { + return Some(index); + } + if int_len > 0 { + return Some(frac_start); + } + return None; + } + if int_len > 0 { Some(int_len) } else { None } +} + +fn scan_signed_number_unit(s: &str, unit: &str) -> Option { + let unsigned = s.strip_prefix('-').unwrap_or(s); + let sign_len = s.len() - unsigned.len(); + let number_len = scan_number_str(unsigned)?; + if unsigned[number_len..].starts_with(unit) { + Some((sign_len + number_len + unit.len(), 0, sign_len + number_len)) + } else { + None + } +} + +fn whole(len: usize) -> Option { + Some((len, 0, len)) +} + +fn scan_percentage(s: &str) -> Option { + scan_signed_number_unit(s, "%") +} + +fn scan_px(s: &str) -> Option { + scan_signed_number_unit(s, "px") +} + +fn scan_em(s: &str) -> Option { + scan_signed_number_unit(s, "em") +} + +fn scan_deg(s: &str) -> Option { + scan_signed_number_unit(s, "deg") +} + +fn scan_rad(s: &str) -> Option { + scan_signed_number_unit(s, "rad") +} + +fn scan_number_token(s: &str) -> Option { + scan_number_str(s).and_then(whole) +} + +fn scan_hex_color(s: &str) -> Option { + let rest = s.strip_prefix('#')?; + let digits_len = rest.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(rest.len()); + if digits_len == 0 { + return None; + } + Some((1 + digits_len, 1, 1 + digits_len)) +} + +fn scan_literal_color(s: &str) -> Option { + let len = s.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(s.len()); + if len == 0 { + return None; + } + whole(len) +} + +fn scan_variable_name(s: &str) -> Option { + let rest = s.strip_prefix("--")?; + let len = rest + .find(|c: char| { + !(c.is_ascii_alphanumeric() || c == '-' || c == ',' || c == '#' || c.is_whitespace()) + }) + .unwrap_or(rest.len()); + if len == 0 { + return None; + } + whole(2 + len) +} + +fn scan_position_keyword(s: &str) -> Option { + for keyword in ["left", "center", "right", "top", "bottom"] { + if let Some(len) = match_ci_prefix(s, keyword) { + return whole(len); + } + } + None +} + +fn scan_extent_keyword(s: &str) -> Option { + for keyword in [ + "closest-side", + "closest-corner", + "farthest-side", + "farthest-corner", + "contain", + "cover", + ] { + if s.starts_with(keyword) { + return whole(keyword.len()); + } + } + None +} + +fn scan_side_or_corner(s: &str) -> Option { + match_ci_prefix(s, "to ")?; + let rest = &s[3..]; + let pairs: [(&str, &[&str]); 4] = [ + ("left", &["top", "bottom"]), + ("right", &["top", "bottom"]), + ("top", &["left", "right"]), + ("bottom", &["left", "right"]), + ]; + for (first, seconds) in pairs { + if let Some(first_len) = match_ci_prefix(rest, first) { + let after_first = &rest[first_len..]; + for second in seconds { + let with_space = format!(" {second}"); + if let Some(second_len) = match_ci_prefix(after_first, &with_space) { + let end = 3 + first_len + second_len; + return Some((end, 3, end)); + } + } + } + } + for keyword in ["left", "right", "top", "bottom"] { + if let Some(len) = match_ci_prefix(rest, keyword) { + let end = 3 + len; + return Some((end, 3, end)); + } + } + None +} + +fn gradient_prefix(s: &str, name: &str) -> Option { + let mut offset = 0; + if s.starts_with('-') { + for vendor in ["webkit", "o", "ms", "moz"] { + let prefix = format!("-{vendor}-"); + if let Some(len) = match_ci_prefix(s, &prefix) { + offset = len; + break; + } + } + if offset == 0 { + return None; + } + } + let name_len = match_ci_prefix(&s[offset..], name)?; + whole(offset + name_len) +} + +struct Parser<'a> { + rest: &'a str, +} + +impl<'a> Parser<'a> { + fn error(&self, message: &str) -> GradientParseError { + GradientParseError { + input: self.rest.to_string(), + message: message.to_string(), + } + } + + fn skip_whitespace(&mut self) { + self.rest = self.rest.trim_start(); + } + + fn consume(&mut self, size: usize) { + self.rest = &self.rest[size..]; + } + + fn scan_token(&mut self, pattern: impl Fn(&str) -> Option) -> Option { + self.skip_whitespace(); + let (total, capture_start, capture_end) = pattern(self.rest)?; + let capture = self.rest[capture_start..capture_end].to_string(); + self.consume(total); + Some(capture) + } + + fn scan_char(&mut self, target: u8) -> bool { + self.skip_whitespace(); + if self.rest.as_bytes().first() == Some(&target) { + self.consume(1); + return true; + } + false + } + + fn scan_comma(&mut self) -> bool { + self.scan_char(b',') + } + + fn match_call( + &mut self, + pattern: impl Fn(&str) -> Option, + callback: impl FnOnce(&mut Self) -> PResult, + ) -> PResult> { + self.skip_whitespace(); + let Some((total, _, _)) = pattern(self.rest) else { + return Ok(None); + }; + self.consume(total); + if !self.scan_char(b'(') { + return Err(self.error("Missing (")); + } + let result = callback(self)?; + if !self.scan_char(b')') { + return Err(self.error("Missing )")); + } + Ok(Some(result)) + } + + fn match_listing( + &mut self, + matcher: fn(&mut Self) -> PResult>, + ) -> PResult> { + let mut result = Vec::new(); + let Some(first) = matcher(self)? else { + return Ok(result); + }; + result.push(first); + while self.scan_comma() { + let next = matcher(self)?.ok_or_else(|| self.error("One extra comma"))?; + result.push(next); + } + Ok(result) + } + + fn match_definition(&mut self) -> PResult> { + for (kind, name) in [ + (GradientKind::Linear, "linear-gradient"), + (GradientKind::RepeatingLinear, "repeating-linear-gradient"), + (GradientKind::Radial, "radial-gradient"), + (GradientKind::RepeatingRadial, "repeating-radial-gradient"), + ] { + if let Some(ast) = self.match_gradient(kind, name)? { + return Ok(Some(ast)); + } + } + Ok(None) + } + + fn match_gradient( + &mut self, + kind: GradientKind, + name: &'static str, + ) -> PResult> { + self.match_call( + |s| gradient_prefix(s, name), + |parser| { + let orientation = if kind.is_linear() { + parser.match_linear_orientation()? + } else { + parser.match_list_radial_orientations()? + }; + if orientation.is_some() && !parser.scan_comma() { + return Err(parser.error("Missing comma before color stops")); + } + Ok(GradientAst { + kind, + orientation, + color_stops: parser.match_listing(Self::match_color_stop)?, + }) + }, + ) + } + + fn match_linear_orientation(&mut self) -> PResult> { + if let Some(value) = self.scan_token(scan_side_or_corner) { + return Ok(Some(GradientOrientation::Linear( + LinearOrientation::Directional(value), + ))); + } + if let Some(value) = self.scan_token(scan_position_keyword) { + return Ok(Some(GradientOrientation::Linear( + LinearOrientation::Directional(value), + ))); + } + let angular = self + .scan_token(scan_deg) + .or_else(|| self.scan_token(scan_rad)); + Ok(angular.map(|value| GradientOrientation::Linear(LinearOrientation::Angular(value)))) + } + + fn match_list_radial_orientations(&mut self) -> PResult> { + let Some(first) = self.match_radial_orientation()? else { + return Ok(None); + }; + let mut orientations = vec![first]; + let lookahead = self.rest; + if !self.scan_comma() { + return Ok(Some(GradientOrientation::Radial(orientations))); + } + let Some(next) = self.match_radial_orientation()? else { + self.rest = lookahead; + return Ok(Some(GradientOrientation::Radial(orientations))); + }; + orientations.push(next); + Ok(Some(GradientOrientation::Radial(orientations))) + } + + fn match_radial_orientation(&mut self) -> PResult> { + let shape = self.match_circle()?.or(self.match_ellipse()?); + if let Some(mut shape) = shape { + shape.at = self.match_at_position()?; + return Ok(Some(RadialOrientation::Shape(shape))); + } + + if let Some(value) = self.scan_token(scan_extent_keyword) { + let at = self.match_at_position()?; + return Ok(Some(RadialOrientation::ExtentKeyword { value, at })); + } + + if let Some(implicit) = self.match_implicit_ellipse()? { + return Ok(Some(RadialOrientation::Shape(implicit))); + } + + if let Some(at) = self.match_at_position()? { + return Ok(Some(RadialOrientation::DefaultRadial { at })); + } + + if let Some(at) = self.match_positioning()? { + return Ok(Some(RadialOrientation::DefaultRadial { at })); + } + + Ok(None) + } + + fn match_implicit_ellipse(&mut self) -> PResult> { + let lookahead = self.rest; + let Some(width) = self.match_distance()? else { + return Ok(None); + }; + let Some(height) = self.match_distance()? else { + self.rest = lookahead; + return Ok(None); + }; + let Some(at) = self.match_at_position()? else { + self.rest = lookahead; + return Ok(None); + }; + Ok(Some(Shape { + value: ShapeKind::Ellipse, + style: Some(ShapeStyle::Position(Position { + x: Some(width), + y: Some(height), + })), + at: Some(at), + })) + } + + fn match_circle(&mut self) -> PResult> { + if self.scan_token(|s| match_ci_prefix(s, "circle").and_then(whole)).is_none() { + return Ok(None); + } + let style = self + .match_length() + .map(ShapeStyle::Distance) + .or_else(|| self.scan_token(scan_extent_keyword).map(ShapeStyle::ExtentKeyword)); + Ok(Some(Shape { + value: ShapeKind::Circle, + style, + at: None, + })) + } + + fn match_ellipse(&mut self) -> PResult> { + if self.scan_token(|s| match_ci_prefix(s, "ellipse").and_then(whole)).is_none() { + return Ok(None); + } + let style = self + .match_positioning()? + .map(ShapeStyle::Position) + .or_else(|| self.match_distance().ok().flatten().map(ShapeStyle::Distance)) + .or_else(|| self.scan_token(scan_extent_keyword).map(ShapeStyle::ExtentKeyword)); + Ok(Some(Shape { + value: ShapeKind::Ellipse, + style, + at: None, + })) + } + + fn match_at_position(&mut self) -> PResult> { + if self.scan_token(|s| match_ci_prefix(s, "at").and_then(whole)).is_none() { + return Ok(None); + } + let Some(position) = self.match_positioning()? else { + return Err(self.error("Missing positioning value")); + }; + Ok(Some(position)) + } + + fn match_positioning(&mut self) -> PResult> { + let position = Position { + x: self.match_distance()?, + y: self.match_distance()?, + }; + if position.x.is_none() && position.y.is_none() { + return Ok(None); + } + Ok(Some(position)) + } + + fn match_color_stop(&mut self) -> PResult> { + let color = self + .match_color()? + .ok_or_else(|| self.error("Expected color definition"))?; + let length = self.match_distance()?; + Ok(Some(ColorStop { color, length })) + } + + fn match_color(&mut self) -> PResult> { + if let Some(hex) = self.scan_token(scan_hex_color) { + return Ok(Some(Color::Hex(hex))); + } + if let Some(hsla) = self.match_hsla_color()? { + return Ok(Some(hsla)); + } + if let Some(hsl) = self.match_hsl_color()? { + return Ok(Some(hsl)); + } + if let Some(rgba) = self.match_rgba_color()? { + return Ok(Some(rgba)); + } + if let Some(rgb) = self.match_rgb_color()? { + return Ok(Some(rgb)); + } + if let Some(var) = self.match_var_color()? { + return Ok(Some(var)); + } + Ok(self.scan_token(scan_literal_color).map(Color::Literal)) + } + + fn match_rgb_color(&mut self) -> PResult> { + self.match_call( + |s| match_ci_prefix(s, "rgb").and_then(whole), + |parser| { + let values = parser.match_listing(|p| p.match_number().map(Some))?; + Ok(Color::Rgb(values)) + }, + ) + } + + fn match_rgba_color(&mut self) -> PResult> { + self.match_call( + |s| match_ci_prefix(s, "rgba").and_then(whole), + |parser| { + let values = parser.match_listing(|p| p.match_number().map(Some))?; + Ok(Color::Rgba(values)) + }, + ) + } + + fn match_var_color(&mut self) -> PResult> { + self.match_call( + |s| match_ci_prefix(s, "var").and_then(whole), + |parser| { + let name = parser + .scan_token(scan_variable_name) + .ok_or_else(|| parser.error("Expected CSS variable name"))?; + Ok(Color::Var(name)) + }, + ) + } + + fn match_hsl_color(&mut self) -> PResult> { + self.match_call( + |s| match_ci_prefix(s, "hsl").and_then(whole), + |parser| { + if parser.scan_token(scan_percentage).is_some() { + return Err(parser.error( + "HSL hue value must be a number in degrees (0-360) or normalized (-360 to 360), not a percentage", + )); + } + let hue = parser.match_number()?; + parser.scan_comma(); + let sat = parser.scan_token(scan_percentage); + parser.scan_comma(); + let light = parser.scan_token(scan_percentage); + let sat = sat.ok_or_else(|| { + parser.error("Expected percentage value for saturation and lightness in HSL") + })?; + let light = light.ok_or_else(|| { + parser.error("Expected percentage value for saturation and lightness in HSL") + })?; + Ok(Color::Hsl([hue, sat, light])) + }, + ) + } + + fn match_hsla_color(&mut self) -> PResult> { + self.match_call( + |s| match_ci_prefix(s, "hsla").and_then(whole), + |parser| { + let hue = parser.match_number()?; + parser.scan_comma(); + let sat = parser.scan_token(scan_percentage); + parser.scan_comma(); + let light = parser.scan_token(scan_percentage); + parser.scan_comma(); + let alpha = parser.match_number()?; + let sat = sat.ok_or_else(|| { + parser.error("Expected percentage value for saturation and lightness in HSLA") + })?; + let light = light.ok_or_else(|| { + parser.error("Expected percentage value for saturation and lightness in HSLA") + })?; + Ok(Color::Hsla([hue, sat, light, alpha])) + }, + ) + } + + fn match_number(&mut self) -> PResult { + self.scan_token(scan_number_token) + .ok_or_else(|| self.error("Expected number")) + } + + fn match_distance(&mut self) -> PResult> { + if let Some(value) = self.scan_token(scan_percentage) { + return Ok(Some(Distance::Percent(value))); + } + if let Some(value) = self.scan_token(scan_position_keyword) { + return Ok(Some(Distance::PositionKeyword(value))); + } + if let Some(calc) = self.match_calc()? { + return Ok(Some(calc)); + } + Ok(self.match_length()) + } + + fn match_calc(&mut self) -> PResult> { + self.match_call( + |s| match_ci_prefix(s, "calc").and_then(whole), + |parser| { + let mut open_parens = 1i32; + let mut index = 0usize; + let bytes = parser.rest.as_bytes(); + while open_parens > 0 && index < bytes.len() { + match bytes[index] { + b'(' => open_parens += 1, + b')' => open_parens -= 1, + _ => {} + } + index += 1; + } + if open_parens > 0 { + return Err(parser.error("Missing closing parenthesis in calc() expression")); + } + let content = parser.rest[..index - 1].to_string(); + parser.consume(index - 1); + Ok(Distance::Calc(content)) + }, + ) + } + + fn match_length(&mut self) -> Option { + if let Some(value) = self.scan_token(scan_px) { + return Some(Distance::Px(value)); + } + self.scan_token(scan_em).map(Distance::Em) + } +} + +pub fn parse_gradient(code: &str) -> Result, GradientParseError> { + let trimmed = code.trim(); + let source = trimmed.strip_suffix(';').unwrap_or(trimmed); + let mut parser = Parser { rest: source }; + let ast = parser.match_listing(Parser::match_definition)?; + if !parser.rest.is_empty() { + return Err(parser.error("Invalid input not EOF")); + } + Ok(ast) +} diff --git a/rust/crates/graphics/src/gradient/raster.rs b/rust/crates/graphics/src/gradient/raster.rs new file mode 100644 index 00000000..12d30a23 --- /dev/null +++ b/rust/crates/graphics/src/gradient/raster.rs @@ -0,0 +1,549 @@ +use tiny_skia::{ + Color as SkColor, GradientStop, LinearGradient, Paint, Pixmap, Point, RadialGradient, Rect, + Shader, SpreadMode, Transform, +}; + +use super::ast::{ + Distance, GradientAst, GradientOrientation, LinearOrientation, Position, RadialOrientation, + ShapeKind, ShapeStyle, +}; +use super::parser::parse_gradient; +use super::stops::normalize_color_stops; +use crate::color::{Rgba, parse_css_color}; +use crate::error::GraphicsError; + +#[derive(Debug, Clone, PartialEq)] +pub enum BackgroundLayer { + Color(String), + Gradient(GradientAst), +} + +pub fn is_gradient_layer(segment: &str) -> bool { + let names = [ + "linear-gradient", + "repeating-linear-gradient", + "radial-gradient", + "repeating-radial-gradient", + ]; + let mut offset = 0; + let mut rest = segment; + if rest.starts_with('-') { + for vendor in ["-webkit-", "-o-", "-ms-", "-moz-"] { + if rest.len() >= vendor.len() && rest[..vendor.len()].eq_ignore_ascii_case(vendor) { + offset = vendor.len(); + break; + } + } + if offset == 0 { + return false; + } + rest = &rest[offset..]; + } + names + .iter() + .any(|name| rest.len() >= name.len() && rest[..name.len()].eq_ignore_ascii_case(name)) +} + +pub fn split_css_layers(css: &str) -> Vec { + let mut layers = Vec::new(); + let mut current = String::new(); + let mut depth = 0i32; + for ch in css.chars() { + if ch == '(' { + depth += 1; + } + if ch == ')' { + depth = depth.saturating_sub(1); + } + if ch == ',' && depth == 0 { + layers.push(current.trim().to_string()); + current = String::new(); + continue; + } + current.push(ch); + } + if !current.trim().is_empty() { + layers.push(current.trim().to_string()); + } + layers +} + +pub fn parse_background_layers(css: &str) -> Vec { + let mut layers = Vec::new(); + for segment in split_css_layers(css) { + if segment.is_empty() { + continue; + } + if is_gradient_layer(&segment) { + match parse_gradient(segment.trim()) { + Ok(parsed) => { + for gradient in parsed { + layers.push(BackgroundLayer::Gradient(gradient)); + } + continue; + } + Err(_) => { + layers.push(BackgroundLayer::Color(segment)); + continue; + } + } + } + layers.push(BackgroundLayer::Color(segment)); + } + layers +} + +pub fn draw_css_background(pixmap: &mut Pixmap, css: &str) -> Result<(), GraphicsError> { + let width = f64::from(pixmap.width()); + let height = f64::from(pixmap.height()); + let layers = parse_background_layers(css); + for layer in layers.iter().rev() { + match layer { + BackgroundLayer::Color(value) => { + let color = parse_css_color(value) + .ok_or_else(|| GraphicsError::InvalidColor(value.clone()))?; + fill_solid(pixmap, color_to_sk(color)); + } + BackgroundLayer::Gradient(gradient) => { + draw_gradient_layer(pixmap, width, height, gradient)?; + } + } + } + Ok(()) +} + +fn fill_solid(pixmap: &mut Pixmap, color: SkColor) { + let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32) + else { + return; + }; + let paint = Paint { + shader: Shader::SolidColor(color), + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); +} + +fn color_to_sk(color: Rgba) -> SkColor { + SkColor::from_rgba( + color.r.clamp(0.0, 1.0) as f32, + color.g.clamp(0.0, 1.0) as f32, + color.b.clamp(0.0, 1.0) as f32, + color.a.clamp(0.0, 1.0) as f32, + ) + .unwrap_or(SkColor::TRANSPARENT) +} + +fn build_stops( + gradient: &GradientAst, + gradient_length: f64, +) -> Result, GraphicsError> { + let normalized = normalize_color_stops(&gradient.color_stops, gradient_length); + let mut stops = Vec::with_capacity(normalized.len()); + for stop in normalized { + let color = parse_css_color(&stop.color) + .ok_or_else(|| GraphicsError::InvalidColor(stop.color.clone()))?; + stops.push(GradientStop::new(stop.offset as f32, color_to_sk(color))); + } + Ok(stops) +} + +fn full_rect(width: f64, height: f64) -> Option { + Rect::from_xywh(0.0, 0.0, width as f32, height as f32) +} + +fn draw_gradient_layer( + pixmap: &mut Pixmap, + width: f64, + height: f64, + gradient: &GradientAst, +) -> Result<(), GraphicsError> { + if gradient.kind.is_linear() { + draw_linear_gradient(pixmap, width, height, gradient) + } else { + draw_radial_gradient(pixmap, width, height, gradient) + } +} + +struct LinearPoints { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + length: f64, +} + +fn resolve_linear_points( + width: f64, + height: f64, + orientation: Option<&GradientOrientation>, +) -> LinearPoints { + let angle = resolve_linear_angle(orientation); + let radians = angle.to_radians(); + let dx = radians.sin(); + let dy = -radians.cos(); + let center_x = width / 2.0; + let center_y = height / 2.0; + let half_length = ((width * dx).abs() + (height * dy).abs()) / 2.0; + let x0 = center_x - dx * half_length; + let y0 = center_y - dy * half_length; + let x1 = center_x + dx * half_length; + let y1 = center_y + dy * half_length; + let length = (x1 - x0).hypot(y1 - y0); + LinearPoints { x0, y0, x1, y1, length } +} + +fn resolve_linear_angle(orientation: Option<&GradientOrientation>) -> f64 { + let Some(orientation) = orientation else { + return 180.0; + }; + if let GradientOrientation::Linear(linear) = orientation { + match linear { + LinearOrientation::Angular(value) => { + return value.parse::().unwrap_or(180.0); + } + LinearOrientation::Directional(value) => { + return angle_from_directional(value); + } + } + } + 180.0 +} + +fn angle_from_directional(value: &str) -> f64 { + let normalized = value.to_lowercase().replace("to", ""); + let mut dx: f64 = 0.0; + let mut dy: f64 = 0.0; + for part in normalized.split_whitespace() { + match part { + "left" => dx = -1.0, + "right" => dx = 1.0, + "top" => dy = -1.0, + "bottom" => dy = 1.0, + _ => {} + } + } + if dx == 0.0 && dy == 0.0 { + return 180.0; + } + let angle = dx.atan2(-dy).to_degrees(); + (angle + 360.0) % 360.0 +} + +fn draw_linear_gradient( + pixmap: &mut Pixmap, + width: f64, + height: f64, + gradient: &GradientAst, +) -> Result<(), GraphicsError> { + let points = resolve_linear_points(width, height, gradient.orientation.as_ref()); + let stops = build_stops(gradient, points.length)?; + let (Some(shader), Some(rect)) = ( + LinearGradient::new( + Point::from_xy(points.x0 as f32, points.y0 as f32), + Point::from_xy(points.x1 as f32, points.y1 as f32), + stops, + SpreadMode::Pad, + Transform::default(), + ), + full_rect(width, height), + ) else { + return Ok(()); + }; + let paint = Paint { + shader, + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); + Ok(()) +} + +struct RadialDimensions { + cx: f64, + cy: f64, + rx: f64, + ry: f64, +} + +fn resolve_radial_dimensions( + width: f64, + height: f64, + orientation: Option<&GradientOrientation>, +) -> RadialDimensions { + let center_fallback = (width / 2.0, height / 2.0); + let radial = match orientation { + Some(GradientOrientation::Radial(list)) => list.first(), + _ => None, + }; + + let Some(radial) = radial else { + let (rx, ry) = resolve_radial_extents( + width, + height, + center_fallback.0, + center_fallback.1, + ShapeKind::Ellipse, + "farthest-corner", + ); + return RadialDimensions { + cx: center_fallback.0, + cy: center_fallback.1, + rx, + ry, + }; + }; + + match radial { + RadialOrientation::Shape(shape) => { + let (cx, cy) = resolve_radial_center(width, height, shape.at.as_ref()); + if let Some(ShapeStyle::Position(position)) = &shape.style { + let rx = position + .x + .as_ref() + .map(|d| resolve_ellipse_dimension(d, width)) + .unwrap_or(width / 2.0); + let ry = position + .y + .as_ref() + .map(|d| resolve_ellipse_dimension(d, height)) + .unwrap_or(height / 2.0); + return RadialDimensions { cx, cy, rx, ry }; + } + if let Some(ShapeStyle::Distance(distance)) = &shape.style { + let resolved = + resolve_distance_in_pixels(distance, width.max(height)).unwrap_or(0.0); + return RadialDimensions { + cx, + cy, + rx: resolved, + ry: resolved, + }; + } + let extent = match &shape.style { + Some(ShapeStyle::ExtentKeyword(value)) => value.as_str(), + _ => "farthest-corner", + }; + let (rx, ry) = resolve_radial_extents(width, height, cx, cy, shape.value, extent); + RadialDimensions { cx, cy, rx, ry } + } + RadialOrientation::ExtentKeyword { value, at } => { + let (cx, cy) = resolve_radial_center(width, height, at.as_ref()); + let (rx, ry) = + resolve_radial_extents(width, height, cx, cy, ShapeKind::Ellipse, value); + RadialDimensions { cx, cy, rx, ry } + } + RadialOrientation::DefaultRadial { at } => { + let (cx, cy) = resolve_radial_center(width, height, Some(at)); + let (rx, ry) = resolve_radial_extents( + width, + height, + cx, + cy, + ShapeKind::Ellipse, + "farthest-corner", + ); + RadialDimensions { cx, cy, rx, ry } + } + } +} + +fn resolve_radial_center(width: f64, height: f64, position: Option<&Position>) -> (f64, f64) { + let Some(position) = position else { + return (width / 2.0, height / 2.0); + }; + let normalized = normalize_position_keywords(position); + let cx = resolve_position_value(normalized.x.as_ref(), width, true); + let cy = resolve_position_value(normalized.y.as_ref(), height, false); + (cx, cy) +} + +fn normalize_position_keywords(position: &Position) -> Position { + if let (Some(Distance::PositionKeyword(x_kw)), Some(Distance::PositionKeyword(y_kw))) = + (&position.x, &position.y) + { + let x_keyword = x_kw.to_lowercase(); + let y_keyword = y_kw.to_lowercase(); + let x_is_vertical = x_keyword == "top" || x_keyword == "bottom"; + let y_is_horizontal = y_keyword == "left" || y_keyword == "right"; + if x_is_vertical && y_is_horizontal { + return Position { + x: Some(Distance::PositionKeyword(y_keyword)), + y: Some(Distance::PositionKeyword(x_keyword)), + }; + } + } + position.clone() +} + +fn resolve_radial_extents( + width: f64, + height: f64, + cx: f64, + cy: f64, + shape: ShapeKind, + extent: &str, +) -> (f64, f64) { + let left = cx; + let right = width - cx; + let top = cy; + let bottom = height - cy; + + if shape == ShapeKind::Circle { + let distances = [ + left.hypot(top), + right.hypot(top), + left.hypot(bottom), + right.hypot(bottom), + ]; + return match extent { + "closest-side" => (left.min(right).min(top).min(bottom), 0.0), + "farthest-side" => (left.max(right).max(top).max(bottom), 0.0), + "closest-corner" => (distances.iter().cloned().fold(f64::INFINITY, f64::min), 0.0), + _ => (distances.iter().cloned().fold(f64::NEG_INFINITY, f64::max), 0.0), + }; + } + + match extent { + "closest-side" => (left.min(right), top.min(bottom)), + "farthest-side" => (left.max(right), top.max(bottom)), + _ => { + let corners = [(left, top), (right, top), (left, bottom), (right, bottom)]; + let chosen = if extent == "closest-corner" { + corners + .iter() + .min_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1))) + } else { + corners + .iter() + .max_by(|a, b| a.0.hypot(a.1).total_cmp(&b.0.hypot(b.1))) + }; + let chosen = chosen.copied().unwrap_or((width / 2.0, height / 2.0)); + (chosen.0.abs(), chosen.1.abs()) + } + } +} + +fn resolve_position_value(distance: Option<&Distance>, axis_size: f64, is_x: bool) -> f64 { + let Some(distance) = distance else { + return axis_size / 2.0; + }; + match distance { + Distance::Percent(value) => value + .parse::() + .map(|v| (v / 100.0) * axis_size) + .unwrap_or(axis_size / 2.0), + Distance::PositionKeyword(value) => keyword_to_position(value, axis_size, is_x), + Distance::Px(value) => value.parse::().unwrap_or(axis_size / 2.0), + Distance::Em(value) => value + .parse::() + .map(|v| v * 16.0) + .unwrap_or(axis_size / 2.0), + _ => axis_size / 2.0, + } +} + +fn keyword_to_position(value: &str, axis_size: f64, is_x: bool) -> f64 { + let keyword = value.to_lowercase(); + if keyword == "center" { + return axis_size / 2.0; + } + if is_x { + if keyword == "left" { + return 0.0; + } + if keyword == "right" { + return axis_size; + } + } else { + if keyword == "top" { + return 0.0; + } + if keyword == "bottom" { + return axis_size; + } + } + axis_size / 2.0 +} + +fn resolve_distance_in_pixels(distance: &Distance, axis_size: f64) -> Option { + match distance { + Distance::Percent(value) => value.parse::().ok().map(|v| (v / 100.0) * axis_size), + Distance::Px(value) => value.parse::().ok(), + Distance::Em(value) => value.parse::().ok().map(|v| v * 16.0), + _ => None, + } +} + +fn resolve_ellipse_dimension(distance: &Distance, axis_size: f64) -> f64 { + match distance { + Distance::Percent(value) => value + .parse::() + .map(|v| (v / 100.0) * axis_size) + .unwrap_or(axis_size / 2.0), + Distance::Px(value) => value.parse::().unwrap_or(axis_size / 2.0), + Distance::Em(value) => value + .parse::() + .map(|v| v * 16.0) + .unwrap_or(axis_size / 2.0), + _ => axis_size / 2.0, + } +} + +fn draw_radial_gradient( + pixmap: &mut Pixmap, + width: f64, + height: f64, + gradient: &GradientAst, +) -> Result<(), GraphicsError> { + let dims = resolve_radial_dimensions(width, height, gradient.orientation.as_ref()); + let gradient_length = dims.rx.max(dims.ry); + let stops = build_stops(gradient, gradient_length)?; + + if dims.rx == dims.ry || dims.ry == 0.0 { + let Some(shader) = RadialGradient::new( + Point::from_xy(dims.cx as f32, dims.cy as f32), + Point::from_xy(dims.cx as f32, dims.cy as f32), + dims.rx as f32, + stops, + SpreadMode::Pad, + Transform::default(), + ) else { + return Ok(()); + }; + if let Some(rect) = full_rect(width, height) { + let paint = Paint { + shader, + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); + } + return Ok(()); + } + + if dims.rx <= 0.0 { + return Ok(()); + } + let scale_y = dims.ry / dims.rx; + if !scale_y.is_finite() || scale_y <= 0.0 { + return Ok(()); + } + let transform = Transform::from_row(1.0, 0.0, 0.0, scale_y as f32, dims.cx as f32, dims.cy as f32); + let Some(shader) = RadialGradient::new( + Point::from_xy(0.0, 0.0), + Point::from_xy(0.0, 0.0), + dims.rx as f32, + stops, + SpreadMode::Pad, + transform, + ) else { + return Ok(()); + }; + if let Some(rect) = full_rect(width, height) { + let paint = Paint { + shader, + ..Paint::default() + }; + pixmap.fill_rect(rect, &paint, Transform::default(), None); + } + Ok(()) +} diff --git a/rust/crates/graphics/src/gradient/stops.rs b/rust/crates/graphics/src/gradient/stops.rs new file mode 100644 index 00000000..e719ae96 --- /dev/null +++ b/rust/crates/graphics/src/gradient/stops.rs @@ -0,0 +1,151 @@ +use super::ast::{Color, ColorStop, Distance}; +use crate::color::{is_transparent, parse_color_to_rgb}; + +#[derive(Debug, Clone, PartialEq)] +pub struct NormalizedColorStop { + pub color: String, + pub offset: f64, +} + +pub fn color_to_string(color: &Color) -> String { + match color { + Color::Hex(value) => format!("#{value}"), + Color::Literal(value) => value.clone(), + Color::Rgb(values) => format!("rgb({})", values.join(",")), + Color::Rgba(values) => format!("rgba({})", values.join(",")), + Color::Hsl(values) => format!("hsl({})", values.join(",")), + Color::Hsla(values) => format!("hsla({})", values.join(",")), + Color::Var(value) => format!("var({value})"), + } +} + +fn clamp01(value: f64) -> f64 { + value.clamp(0.0, 1.0) +} + +fn resolve_stop_offset(stop: &ColorStop, gradient_length: f64) -> Option { + match stop.length.as_ref()? { + Distance::Percent(value) => value.parse::().ok().map(|v| v / 100.0), + Distance::Px(value) => value.parse::().ok().map(|v| v / gradient_length), + Distance::Em(value) => value + .parse::() + .ok() + .map(|v| (v * 16.0) / gradient_length), + _ => None, + } +} + +fn fix_transparent_stops(stops: &[(String, Option)]) -> Vec<(String, Option)> { + let mut result = stops.to_vec(); + for i in 0..result.len() { + if !is_transparent(&result[i].0) { + continue; + } + let mut donor = None; + for j in (0..i).rev() { + if !is_transparent(&result[j].0) { + donor = parse_color_to_rgb(&result[j].0); + if donor.is_some() { + break; + } + } + } + if donor.is_none() { + for item in result.iter().skip(i + 1) { + if !is_transparent(&item.0) { + donor = parse_color_to_rgb(&item.0); + if donor.is_some() { + break; + } + } + } + } + if let Some(donor) = donor { + result[i].0 = format!("rgba({},{},{},0)", donor.r, donor.g, donor.b); + } + } + result +} + +pub fn normalize_color_stops( + color_stops: &[ColorStop], + gradient_length: f64, +) -> Vec { + let mapped: Vec<(String, Option)> = color_stops + .iter() + .map(|stop| { + ( + color_to_string(&stop.color), + resolve_stop_offset(stop, gradient_length), + ) + }) + .collect(); + + let fixed = fix_transparent_stops(&mapped); + let mut resolved = fixed; + let known_indices: Vec = resolved + .iter() + .enumerate() + .filter_map(|(index, stop)| stop.1.map(|_| index)) + .collect(); + + if known_indices.is_empty() { + let step = if resolved.len() > 1 { + 1.0 / (resolved.len() - 1) as f64 + } else { + 1.0 + }; + for (index, stop) in resolved.iter_mut().enumerate() { + stop.1 = Some(step * index as f64); + } + return resolved + .into_iter() + .map(|(color, offset)| NormalizedColorStop { + color, + offset: clamp01(offset.unwrap_or(0.0)), + }) + .collect(); + } + + let first_known = known_indices[0]; + if resolved[first_known].1.is_none() { + resolved[first_known].1 = Some(0.0); + } + for index in 0..first_known { + let next_offset = resolved[first_known].1.unwrap_or(0.0); + resolved[index].1 = Some((next_offset * index as f64) / first_known as f64); + } + + for window in known_indices.windows(2) { + let start_index = window[0]; + let end_index = window[1]; + let start_offset = resolved[start_index].1.unwrap_or(0.0); + let end_offset = resolved[end_index].1.unwrap_or(start_offset); + let gap = end_index - start_index; + if gap <= 1 { + continue; + } + let step = (end_offset - start_offset) / gap as f64; + for index in 1..gap { + resolved[start_index + index].1 = Some(start_offset + step * index as f64); + } + } + + let last_known = known_indices[known_indices.len() - 1]; + let last_offset = resolved[last_known].1.unwrap_or(1.0); + if last_known < resolved.len() - 1 { + let gap = resolved.len() - 1 - last_known; + let step = (1.0 - last_offset) / gap as f64; + for index in 1..=gap { + resolved[last_known + index].1 = Some(last_offset + step * index as f64); + } + } + + resolved + .into_iter() + .map(|(color, offset)| NormalizedColorStop { + color, + offset: clamp01(offset.unwrap_or(0.0)), + }) + .collect() +} -- cgit v1.3.1