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/color.rs | 487 ++++++++++++++++++++++ rust/crates/graphics/src/definitions.rs | 531 ++++++++++++++++++++++++ rust/crates/graphics/src/error.rs | 13 + 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 +++++++ rust/crates/graphics/src/lib.rs | 31 ++ rust/crates/graphics/src/params.rs | 72 ++++ rust/crates/graphics/src/registry.rs | 107 +++++ rust/crates/graphics/src/stroke.rs | 71 ++++ rust/crates/graphics/src/types.rs | 77 ++++ 13 files changed, 2823 insertions(+) create mode 100644 rust/crates/graphics/src/color.rs create mode 100644 rust/crates/graphics/src/definitions.rs create mode 100644 rust/crates/graphics/src/error.rs 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 create mode 100644 rust/crates/graphics/src/params.rs create mode 100644 rust/crates/graphics/src/registry.rs create mode 100644 rust/crates/graphics/src/stroke.rs create mode 100644 rust/crates/graphics/src/types.rs (limited to 'rust/crates/graphics/src') diff --git a/rust/crates/graphics/src/color.rs b/rust/crates/graphics/src/color.rs new file mode 100644 index 00000000..25a94a96 --- /dev/null +++ b/rust/crates/graphics/src/color.rs @@ -0,0 +1,487 @@ +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rgba { + pub r: f64, + pub g: f64, + pub b: f64, + pub a: f64, +} + +const NAMED_COLORS: &[(&str, u8, u8, u8)] = &[ + ("aliceblue", 240, 248, 255), + ("antiquewhite", 250, 235, 215), + ("aqua", 0, 255, 255), + ("aquamarine", 127, 255, 212), + ("azure", 240, 255, 255), + ("beige", 245, 245, 220), + ("bisque", 255, 228, 196), + ("black", 0, 0, 0), + ("blanchedalmond", 255, 235, 205), + ("blue", 0, 0, 255), + ("blueviolet", 138, 43, 226), + ("brown", 165, 42, 42), + ("burlywood", 222, 184, 135), + ("cadetblue", 95, 158, 160), + ("chartreuse", 127, 255, 0), + ("chocolate", 210, 105, 30), + ("coral", 255, 127, 80), + ("cornflowerblue", 100, 149, 237), + ("cornsilk", 255, 248, 220), + ("crimson", 220, 20, 60), + ("cyan", 0, 255, 255), + ("darkblue", 0, 0, 139), + ("darkcyan", 0, 139, 139), + ("darkgoldenrod", 184, 134, 11), + ("darkgray", 169, 169, 169), + ("darkgreen", 0, 100, 0), + ("darkgrey", 169, 169, 169), + ("darkkhaki", 189, 183, 107), + ("darkmagenta", 139, 0, 139), + ("darkolivegreen", 85, 107, 47), + ("darkorange", 255, 140, 0), + ("darkorchid", 153, 50, 204), + ("darkred", 139, 0, 0), + ("darksalmon", 233, 150, 122), + ("darkseagreen", 143, 188, 143), + ("darkslateblue", 72, 61, 139), + ("darkslategray", 47, 79, 79), + ("darkslategrey", 47, 79, 79), + ("darkturquoise", 0, 206, 209), + ("darkviolet", 148, 0, 211), + ("deeppink", 255, 20, 147), + ("deepskyblue", 0, 191, 255), + ("dimgray", 105, 105, 105), + ("dimgrey", 105, 105, 105), + ("dodgerblue", 30, 144, 255), + ("firebrick", 178, 34, 34), + ("floralwhite", 255, 250, 240), + ("forestgreen", 34, 139, 34), + ("fuchsia", 255, 0, 255), + ("gainsboro", 220, 220, 220), + ("ghostwhite", 248, 248, 255), + ("gold", 255, 215, 0), + ("goldenrod", 218, 165, 32), + ("gray", 128, 128, 128), + ("green", 0, 128, 0), + ("greenyellow", 173, 255, 47), + ("grey", 128, 128, 128), + ("honeydew", 240, 255, 240), + ("hotpink", 255, 105, 180), + ("indianred", 205, 92, 92), + ("indigo", 75, 0, 130), + ("ivory", 255, 255, 240), + ("khaki", 240, 230, 140), + ("lavender", 230, 230, 250), + ("lavenderblush", 255, 240, 245), + ("lawngreen", 124, 252, 0), + ("lemonchiffon", 255, 250, 205), + ("lightblue", 173, 216, 230), + ("lightcoral", 240, 128, 128), + ("lightcyan", 224, 255, 255), + ("lightgoldenrodyellow", 250, 250, 210), + ("lightgray", 211, 211, 211), + ("lightgreen", 144, 238, 144), + ("lightgrey", 211, 211, 211), + ("lightpink", 255, 182, 193), + ("lightsalmon", 255, 160, 122), + ("lightseagreen", 32, 178, 170), + ("lightskyblue", 135, 206, 250), + ("lightslategray", 119, 136, 153), + ("lightslategrey", 119, 136, 153), + ("lightsteelblue", 176, 196, 222), + ("lightyellow", 255, 255, 224), + ("lime", 0, 255, 0), + ("limegreen", 50, 205, 50), + ("linen", 250, 240, 230), + ("magenta", 255, 0, 255), + ("maroon", 128, 0, 0), + ("mediumaquamarine", 102, 205, 170), + ("mediumblue", 0, 0, 205), + ("mediumorchid", 186, 85, 211), + ("mediumpurple", 147, 112, 219), + ("mediumseagreen", 60, 179, 113), + ("mediumslateblue", 123, 104, 238), + ("mediumspringgreen", 0, 250, 154), + ("mediumturquoise", 72, 209, 204), + ("mediumvioletred", 199, 21, 133), + ("midnightblue", 25, 25, 112), + ("mintcream", 245, 255, 250), + ("mistyrose", 255, 228, 225), + ("moccasin", 255, 228, 181), + ("navajowhite", 255, 222, 173), + ("navy", 0, 0, 128), + ("oldlace", 253, 245, 230), + ("olive", 128, 128, 0), + ("olivedrab", 107, 142, 35), + ("orange", 255, 165, 0), + ("orangered", 255, 69, 0), + ("orchid", 218, 112, 214), + ("palegoldenrod", 238, 232, 170), + ("palegreen", 152, 251, 152), + ("paleturquoise", 175, 238, 238), + ("palevioletred", 219, 112, 147), + ("papayawhip", 255, 239, 213), + ("peachpuff", 255, 218, 185), + ("peru", 205, 133, 63), + ("pink", 255, 192, 203), + ("plum", 221, 160, 221), + ("powderblue", 176, 224, 230), + ("purple", 128, 0, 128), + ("rebeccapurple", 102, 51, 153), + ("red", 255, 0, 0), + ("rosybrown", 188, 143, 143), + ("royalblue", 65, 105, 225), + ("saddlebrown", 139, 69, 19), + ("salmon", 250, 128, 114), + ("sandybrown", 244, 164, 96), + ("seagreen", 46, 139, 87), + ("seashell", 255, 245, 238), + ("sienna", 160, 82, 45), + ("silver", 192, 192, 192), + ("skyblue", 135, 206, 235), + ("slateblue", 106, 90, 205), + ("slategray", 112, 128, 144), + ("slategrey", 112, 128, 144), + ("snow", 255, 250, 250), + ("springgreen", 0, 255, 127), + ("steelblue", 70, 130, 180), + ("tan", 210, 180, 140), + ("teal", 0, 128, 128), + ("thistle", 216, 191, 216), + ("tomato", 255, 99, 71), + ("turquoise", 64, 224, 208), + ("violet", 238, 130, 238), + ("wheat", 245, 222, 179), + ("white", 255, 255, 255), + ("whitesmoke", 245, 245, 245), + ("yellow", 255, 255, 0), + ("yellowgreen", 154, 205, 50), +]; + +pub fn named_color(name: &str) -> Option { + let lowered = name.to_lowercase(); + if lowered == "transparent" { + return Some(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }); + } + NAMED_COLORS + .binary_search_by(|(key, _, _, _)| (*key).cmp(lowered.as_str())) + .ok() + .map(|index| { + let (_, r, g, b) = NAMED_COLORS[index]; + Rgba { + r: f64::from(r) / 255.0, + g: f64::from(g) / 255.0, + b: f64::from(b) / 255.0, + a: 1.0, + } + }) +} + +fn parse_hex_color(hex: &str) -> Option { + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + Some(Rgba { + r: f64::from(r) / 255.0, + g: f64::from(g) / 255.0, + b: f64::from(b) / 255.0, + a: 1.0, + }) + } + 4 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + let a = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?; + Some(Rgba { + r: f64::from(r) / 255.0, + g: f64::from(g) / 255.0, + b: f64::from(b) / 255.0, + a: f64::from(a) / 255.0, + }) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Rgba { + r: f64::from(r) / 255.0, + g: f64::from(g) / 255.0, + b: f64::from(b) / 255.0, + a: 1.0, + }) + } + 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + let a = u8::from_str_radix(&hex[6..8], 16).ok()?; + Some(Rgba { + r: f64::from(r) / 255.0, + g: f64::from(g) / 255.0, + b: f64::from(b) / 255.0, + a: f64::from(a) / 255.0, + }) + } + _ => None, + } +} + +fn parse_channel(text: &str) -> Option { + let trimmed = text.trim(); + if let Some(percent) = trimmed.strip_suffix('%') { + return percent.trim().parse::().ok().map(|v| v * 255.0 / 100.0); + } + trimmed.parse::().ok() +} + +fn parse_alpha(text: &str) -> Option { + let trimmed = text.trim(); + if let Some(percent) = trimmed.strip_suffix('%') { + return percent.trim().parse::().ok().map(|v| v / 100.0); + } + trimmed.parse::().ok() +} + +fn split_function_args(body: &str) -> Vec { + if body.contains(',') { + return body.split(',').map(|part| part.trim().to_string()).collect(); + } + let mut parts: Vec = Vec::new(); + let mut alpha: Option = None; + for token in body.split_whitespace() { + if let Some(rest) = token.strip_prefix('/') { + alpha = Some(rest.trim().to_string()); + continue; + } + parts.push(token.to_string()); + } + if let Some(alpha) = alpha { + if !alpha.is_empty() { + parts.push(alpha); + } + } + parts +} + +fn parse_rgb_function(body: &str) -> Option { + let parts = split_function_args(body); + if parts.len() < 3 || parts.len() > 4 { + return None; + } + let r = parse_channel(&parts[0])?; + let g = parse_channel(&parts[1])?; + let b = parse_channel(&parts[2])?; + let a = if parts.len() == 4 { + parse_alpha(&parts[3])? + } else { + 1.0 + }; + Some(Rgba { + r: r / 255.0, + g: g / 255.0, + b: b / 255.0, + a, + }) +} + +fn parse_hue(text: &str) -> Option { + let trimmed = text.trim(); + if let Some(deg) = trimmed.strip_suffix("deg") { + return deg.trim().parse::().ok(); + } + if let Some(turn) = trimmed.strip_suffix("turn") { + return turn.trim().parse::().ok().map(|v| v * 360.0); + } + if let Some(rad) = trimmed.strip_suffix("rad") { + return rad.trim().parse::().ok().map(|v| v.to_degrees()); + } + if let Some(grad) = trimmed.strip_suffix("grad") { + return grad.trim().parse::().ok().map(|v| v * 0.9); + } + trimmed.parse::().ok() +} + +fn parse_percentage(text: &str) -> Option { + text.trim() + .strip_suffix('%') + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v / 100.0) +} + +pub fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) { + let hue = h.rem_euclid(360.0); + let sat = s.clamp(0.0, 1.0); + let light = l.clamp(0.0, 1.0); + let chroma = (1.0 - (2.0 * light - 1.0).abs()) * sat; + let segment = hue / 60.0; + let x = chroma * (1.0 - (segment % 2.0 - 1.0).abs()); + let (r1, g1, b1) = match segment as u32 { + 0 => (chroma, x, 0.0), + 1 => (x, chroma, 0.0), + 2 => (0.0, chroma, x), + 3 => (0.0, x, chroma), + 4 => (x, 0.0, chroma), + _ => (chroma, 0.0, x), + }; + let m = light - chroma / 2.0; + (r1 + m, g1 + m, b1 + m) +} + +fn parse_hsl_function(body: &str) -> Option { + let parts = split_function_args(body); + if parts.len() < 3 || parts.len() > 4 { + return None; + } + let h = parse_hue(&parts[0])?; + let s = parse_percentage(&parts[1])?; + let l = parse_percentage(&parts[2])?; + let a = if parts.len() == 4 { + parse_alpha(&parts[3])? + } else { + 1.0 + }; + let (r, g, b) = hsl_to_rgb(h, s, l); + Some(Rgba { r, g, b, a }) +} + +fn clamp01(value: f64) -> f64 { + value.clamp(0.0, 1.0) +} + +pub fn parse_css_color(input: &str) -> Option { + let text = input.trim(); + if let Some(hex) = text.strip_prefix('#') { + return parse_hex_color(hex); + } + let lowered = text.to_lowercase(); + let function_body = |names: &[&str]| -> Option<&str> { + for name in names { + if let Some(rest) = lowered.strip_prefix(name) { + return rest.strip_suffix(')'); + } + } + None + }; + let parsed = if let Some(body) = function_body(&["rgba(", "rgb("]) { + parse_rgb_function(body) + } else if let Some(body) = function_body(&["hsla(", "hsl("]) { + parse_hsl_function(body) + } else { + named_color(&lowered) + }?; + Some(Rgba { + r: clamp01(parsed.r), + g: clamp01(parsed.g), + b: clamp01(parsed.b), + a: clamp01(parsed.a), + }) +} + +fn is_digits_and_dots(text: &str) -> bool { + !text.is_empty() && text.chars().all(|c| c.is_ascii_digit() || c == '.') +} + +pub fn is_transparent(color: &str) -> bool { + let lower = color.to_lowercase(); + let trimmed = lower.trim(); + if trimmed == "transparent" { + return true; + } + let Some(body) = trimmed + .strip_prefix("rgba(") + .and_then(|rest| rest.strip_suffix(')')) + else { + return false; + }; + let parts: Vec<&str> = body.split(',').map(str::trim).collect(); + if parts.len() != 4 { + return false; + } + if !parts[..3].iter().all(|part| is_digits_and_dots(part)) { + return false; + } + if !is_digits_and_dots(parts[3]) { + return false; + } + parts[3].parse::().is_ok_and(|alpha| alpha == 0.0) +} + +pub fn parse_color_to_rgb(color: &str) -> Option { + let lower = color.to_lowercase(); + let trimmed = lower.trim(); + if let Some(hex) = trimmed.strip_prefix('#') { + if hex.len() < 3 || hex.len() > 8 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + return Some(Rgba { + r: f64::from(r), + g: f64::from(g), + b: f64::from(b), + a: 1.0, + }); + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + return Some(Rgba { + r: f64::from(r), + g: f64::from(g), + b: f64::from(b), + a: 1.0, + }); + } + 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + let a = u8::from_str_radix(&hex[6..8], 16).ok()?; + return Some(Rgba { + r: f64::from(r), + g: f64::from(g), + b: f64::from(b), + a: f64::from(a) / 255.0, + }); + } + _ => return None, + } + } + if let Some(body) = trimmed + .strip_prefix("rgb(") + .and_then(|rest| rest.strip_suffix(')')) + { + let parts: Vec<&str> = body.split(',').map(str::trim).collect(); + if parts.len() == 3 && parts.iter().all(|part| is_digits_and_dots(part)) { + let r = parts[0].parse::().ok()?; + let g = parts[1].parse::().ok()?; + let b = parts[2].parse::().ok()?; + return Some(Rgba { r, g, b, a: 1.0 }); + } + return None; + } + if let Some(body) = trimmed + .strip_prefix("rgba(") + .and_then(|rest| rest.strip_suffix(')')) + { + let parts: Vec<&str> = body.split(',').map(str::trim).collect(); + if parts.len() == 4 && parts.iter().all(|part| is_digits_and_dots(part)) { + let r = parts[0].parse::().ok()?; + let g = parts[1].parse::().ok()?; + let b = parts[2].parse::().ok()?; + let a = parts[3].parse::().ok()?; + return Some(Rgba { r, g, b, a }); + } + return None; + } + None +} diff --git a/rust/crates/graphics/src/definitions.rs b/rust/crates/graphics/src/definitions.rs new file mode 100644 index 00000000..d14f465f --- /dev/null +++ b/rust/crates/graphics/src/definitions.rs @@ -0,0 +1,531 @@ +use scene::params::ParamValues; +use tiny_skia::{Color, FillRule, Paint, Path, PathBuilder, Point, Rect, Shader, Transform}; + +use crate::color::parse_css_color; +use crate::error::GraphicsError; +use crate::params::{ParamDefinition, ParamGroup, SelectOption}; +use crate::stroke::apply_aligned_stroke; +use crate::types::{GraphicDefinition, param_number, param_string}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StrokeAlign { + Inside, + Center, + Outside, +} + +impl StrokeAlign { + fn parse(value: &str) -> StrokeAlign { + match value { + "inside" => StrokeAlign::Inside, + "outside" => StrokeAlign::Outside, + _ => StrokeAlign::Center, + } + } +} + +pub const STROKE_ALIGN_PARAM: ParamDefinition = ParamDefinition::Select { + key: "strokeAlign", + label: "Stroke align", + group: Some(ParamGroup::Stroke), + default: "center", + options: &[ + SelectOption { value: "inside", label: "Inside" }, + SelectOption { value: "center", label: "Center" }, + SelectOption { value: "outside", label: "Outside" }, + ], +}; + +const FILL_PARAM: ParamDefinition = ParamDefinition::Color { + key: "fill", + label: "Fill", + group: None, + default: "#ffffff", +}; + +const STROKE_PARAM: ParamDefinition = ParamDefinition::Color { + key: "stroke", + label: "Color", + group: Some(ParamGroup::Stroke), + default: "#000000", +}; + +const STROKE_WIDTH_PARAM: ParamDefinition = ParamDefinition::Number { + key: "strokeWidth", + label: "Width", + group: Some(ParamGroup::Stroke), + default: 0.0, + min: 0.0, + max: Some(64.0), + step: 1.0, + short_label: Some("W"), +}; + +const CORNER_RADIUS_PARAM: ParamDefinition = ParamDefinition::Number { + key: "cornerRadius", + label: "Corner radius", + group: None, + default: 0.0, + min: 0.0, + max: Some(50.0), + step: 1.0, + short_label: Some("R"), +}; + +struct ShapeParams { + fill: Color, + stroke: Color, + stroke_width: f64, + stroke_align: StrokeAlign, + inset: f64, +} + +fn resolve_shape_params(params: &ParamValues) -> Result { + let fill_raw = param_string(params, "fill", "#ffffff"); + let stroke_raw = param_string(params, "stroke", "#000000"); + let fill = parse_css_color(&fill_raw) + .ok_or_else(|| GraphicsError::InvalidColor(fill_raw.clone()))?; + let stroke = parse_css_color(&stroke_raw) + .ok_or_else(|| GraphicsError::InvalidColor(stroke_raw.clone()))?; + let stroke_width = param_number(params, "strokeWidth", 0.0).max(0.0); + let stroke_align = StrokeAlign::parse(¶m_string(params, "strokeAlign", "center")); + let inset = if stroke_align == StrokeAlign::Center { + stroke_width / 2.0 + } else { + 0.0 + }; + Ok(ShapeParams { + fill: Color::from_rgba( + fill.r as f32, + fill.g as f32, + fill.b as f32, + fill.a as f32, + ) + .unwrap_or(Color::TRANSPARENT), + stroke: Color::from_rgba( + stroke.r as f32, + stroke.g as f32, + stroke.b as f32, + stroke.a as f32, + ) + .unwrap_or(Color::TRANSPARENT), + stroke_width, + stroke_align, + inset, + }) +} + +fn fill_and_stroke( + pixmap: &mut tiny_skia::Pixmap, + path: &Path, + params: &ShapeParams, +) { + let paint = Paint { + shader: Shader::SolidColor(params.fill), + anti_alias: true, + ..Paint::default() + }; + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::default(), None); + if params.stroke_width > 0.0 { + apply_aligned_stroke( + pixmap, + path, + params.stroke_width, + params.stroke_align, + params.stroke, + ); + } +} + +fn push_round_rect(builder: &mut PathBuilder, x: f64, y: f64, w: f64, h: f64, radius: f64) { + if radius <= 0.0 { + if let Some(rect) = Rect::from_xywh(x as f32, y as f32, w as f32, h as f32) { + builder.push_rect(rect); + } + return; + } + let radius = radius.min(w / 2.0).min(h / 2.0); + const KAPPA: f64 = 0.552_284_749_830_793_6; + let c = radius * KAPPA; + let (x, y, w, h, r) = (x as f32, y as f32, w as f32, h as f32, radius as f32); + let c = c as f32; + builder.move_to(x + r, y); + builder.line_to(x + w - r, y); + builder.cubic_to(x + w - r + c, y, x + w, y + r - c, x + w, y + r); + builder.line_to(x + w, y + h - r); + builder.cubic_to(x + w, y + h - r + c, x + w - r + c, y + h, x + w - r, y + h); + builder.line_to(x + r, y + h); + builder.cubic_to(x + r - c, y + h, x, y + h - r + c, x, y + h - r); + builder.line_to(x, y + r); + builder.cubic_to(x, y + r - c, x + r - c, y, x + r, y); + builder.close(); +} + +fn arc_to( + builder: &mut PathBuilder, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + radius: f64, +) { + let Some(last) = builder.last_point() else { + builder.move_to(x1 as f32, y1 as f32); + return; + }; + let (x0, y0) = (f64::from(last.x), f64::from(last.y)); + let coincident = |ax: f64, ay: f64, bx: f64, by: f64| ax == bx && ay == by; + if radius <= 0.0 + || coincident(x0, y0, x1, y1) + || coincident(x1, y1, x2, y2) + { + builder.line_to(x1 as f32, y1 as f32); + return; + } + let v1 = (x0 - x1, y0 - y1); + let v2 = (x2 - x1, y2 - y1); + let cross = v1.0 * v2.1 - v1.1 * v2.0; + if cross == 0.0 { + builder.line_to(x1 as f32, y1 as f32); + return; + } + let len1 = v1.0.hypot(v1.1); + let len2 = v2.0.hypot(v2.1); + let u1 = (v1.0 / len1, v1.1 / len1); + let u2 = (v2.0 / len2, v2.1 / len2); + let cos_angle = (u1.0 * u2.0 + u1.1 * u2.1).clamp(-1.0, 1.0); + let angle = cos_angle.acos(); + if angle == 0.0 || angle == std::f64::consts::PI { + builder.line_to(x1 as f32, y1 as f32); + return; + } + let tangent = radius / (angle / 2.0).tan(); + let t1 = (x1 + u1.0 * tangent, y1 + u1.1 * tangent); + let t2 = (x1 + u2.0 * tangent, y1 + u2.1 * tangent); + builder.line_to(t1.0 as f32, t1.1 as f32); + let bisector = (u1.0 + u2.0, u1.1 + u2.1); + let bisector_len = bisector.0.hypot(bisector.1); + if bisector_len == 0.0 { + return; + } + let center_dist = radius / (angle / 2.0).sin(); + let center = ( + x1 + bisector.0 / bisector_len * center_dist, + y1 + bisector.1 / bisector_len * center_dist, + ); + let start_angle = (t1.1 - center.1).atan2(t1.0 - center.0); + let mut end_angle = (t2.1 - center.1).atan2(t2.0 - center.0); + let sweep_clockwise = cross < 0.0; + if sweep_clockwise { + while end_angle <= start_angle { + end_angle += std::f64::consts::TAU; + } + } else { + while end_angle >= start_angle { + end_angle -= std::f64::consts::TAU; + } + } + let sweep = end_angle - start_angle; + let segments = ((sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil() as u32).max(1); + let step = sweep / f64::from(segments); + let kappa = 4.0 / 3.0 * (step / 4.0).tan(); + let mut current = start_angle; + for _ in 0..segments { + let next = current + step; + let p0 = ( + center.0 + radius * current.cos(), + center.1 + radius * current.sin(), + ); + let p3 = (center.0 + radius * next.cos(), center.1 + radius * next.sin()); + let d0 = (-current.sin(), current.cos()); + let d1 = (-next.sin(), next.cos()); + let p1 = (p0.0 + kappa * radius * d0.0, p0.1 + kappa * radius * d0.1); + let p2 = (p3.0 - kappa * radius * d1.0, p3.1 - kappa * radius * d1.1); + builder.cubic_to( + p1.0 as f32, + p1.1 as f32, + p2.0 as f32, + p2.1 as f32, + p3.0 as f32, + p3.1 as f32, + ); + current = next; + } +} + +pub static RECTANGLE_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition { + id: "rectangle", + name: "Rectangle", + keywords: &["rectangle", "square", "box"], + params: &[ + FILL_PARAM, + STROKE_PARAM, + STROKE_WIDTH_PARAM, + STROKE_ALIGN_PARAM, + CORNER_RADIUS_PARAM, + ], + render: render_rectangle, +}; + +fn render_rectangle(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> { + let params = resolve_shape_params(context.params)?; + let width = f64::from(context.width); + let height = f64::from(context.height); + let draw_width = (width - params.inset * 2.0).max(1.0); + let draw_height = (height - params.inset * 2.0).max(1.0); + let radius_percent = param_number(context.params, "cornerRadius", 0.0).max(0.0); + let radius = (draw_width.min(draw_height) / 2.0) * radius_percent.min(50.0) / 50.0; + let mut builder = PathBuilder::new(); + push_round_rect(&mut builder, params.inset, params.inset, draw_width, draw_height, radius); + let Some(path) = builder.finish() else { + return Ok(()); + }; + fill_and_stroke(context.pixmap, &path, ¶ms); + Ok(()) +} + +pub static ELLIPSE_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition { + id: "ellipse", + name: "Ellipse", + keywords: &["ellipse", "circle", "oval"], + params: &[ + FILL_PARAM, + STROKE_PARAM, + STROKE_WIDTH_PARAM, + STROKE_ALIGN_PARAM, + ], + render: render_ellipse, +}; + +fn render_ellipse(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> { + let params = resolve_shape_params(context.params)?; + let width = f64::from(context.width); + let height = f64::from(context.height); + let center_x = width / 2.0; + let center_y = height / 2.0; + let radius_x = (width / 2.0 - params.inset).max(1.0); + let radius_y = (height / 2.0 - params.inset).max(1.0); + let mut builder = PathBuilder::new(); + if let Some(rect) = Rect::from_xywh( + (center_x - radius_x) as f32, + (center_y - radius_y) as f32, + (radius_x * 2.0) as f32, + (radius_y * 2.0) as f32, + ) { + builder.push_oval(rect); + } + let Some(path) = builder.finish() else { + return Ok(()); + }; + fill_and_stroke(context.pixmap, &path, ¶ms); + Ok(()) +} + +const SIDES_PARAM: ParamDefinition = ParamDefinition::Number { + key: "sides", + label: "Sides", + group: None, + default: 5.0, + min: 3.0, + max: Some(12.0), + step: 1.0, + short_label: Some("S"), +}; + +pub static POLYGON_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition { + id: "polygon", + name: "Polygon", + keywords: &["polygon", "triangle", "pentagon", "hexagon", "diamond"], + params: &[ + FILL_PARAM, + STROKE_PARAM, + STROKE_WIDTH_PARAM, + STROKE_ALIGN_PARAM, + SIDES_PARAM, + CORNER_RADIUS_PARAM, + ], + render: render_polygon, +}; + +fn build_polygon_vertices( + center_x: f64, + center_y: f64, + radius: f64, + sides: usize, +) -> Vec { + (0..sides) + .map(|index| { + let angle = -std::f64::consts::FRAC_PI_2 + + (index as f64 * std::f64::consts::TAU) / sides as f64; + Point::from_xy( + (center_x + angle.cos() * radius) as f32, + (center_y + angle.sin() * radius) as f32, + ) + }) + .collect() +} + +fn trace_rounded_polygon_path( + builder: &mut PathBuilder, + vertices: &[Point], + radius: f64, +) { + if vertices.len() < 3 { + return; + } + if radius <= 0.0 { + builder.move_to(vertices[0].x, vertices[0].y); + for vertex in &vertices[1..] { + builder.line_to(vertex.x, vertex.y); + } + builder.close(); + return; + } + let count = vertices.len(); + for index in 0..count { + let previous = vertices[(index + count - 1) % count]; + let current = vertices[index]; + let next = vertices[(index + 1) % count]; + let to_previous = normalize( + f64::from(previous.x - current.x), + f64::from(previous.y - current.y), + ); + let to_next = normalize(f64::from(next.x - current.x), f64::from(next.y - current.y)); + let angle = (to_previous.0 * to_next.0 + to_previous.1 * to_next.1) + .clamp(-1.0, 1.0) + .acos(); + let dist_prev = (f64::from(previous.x - current.x)).hypot(f64::from(previous.y - current.y)); + let dist_next = (f64::from(next.x - current.x)).hypot(f64::from(next.y - current.y)); + let max_offset = dist_prev.min(dist_next) / 2.0; + let tangent_offset = (radius / (angle / 2.0).tan()).min(max_offset); + let start = ( + f64::from(current.x) + to_previous.0 * tangent_offset, + f64::from(current.y) + to_previous.1 * tangent_offset, + ); + let end = ( + f64::from(current.x) + to_next.0 * tangent_offset, + f64::from(current.y) + to_next.1 * tangent_offset, + ); + if index == 0 { + builder.move_to(start.0 as f32, start.1 as f32); + } else { + builder.line_to(start.0 as f32, start.1 as f32); + } + arc_to( + builder, + f64::from(current.x), + f64::from(current.y), + end.0, + end.1, + radius.min(max_offset), + ); + } + builder.close(); +} + +fn normalize(x: f64, y: f64) -> (f64, f64) { + let length = x.hypot(y); + if length == 0.0 { + return (0.0, 0.0); + } + (x / length, y / length) +} + +fn render_polygon(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> { + let params = resolve_shape_params(context.params)?; + let width = f64::from(context.width); + let height = f64::from(context.height); + let sides = (param_number(context.params, "sides", 5.0).round() as i64).clamp(3, 12) as usize; + let radius = (width.min(height) / 2.0 - params.inset).max(1.0); + let max_corner_radius = radius * (std::f64::consts::PI / sides as f64).sin(); + let corner_radius_percent = param_number(context.params, "cornerRadius", 0.0).max(0.0); + let corner_radius = max_corner_radius * corner_radius_percent.min(50.0) / 50.0; + let vertices = build_polygon_vertices(width / 2.0, height / 2.0, radius, sides); + let mut builder = PathBuilder::new(); + trace_rounded_polygon_path(&mut builder, &vertices, corner_radius); + let Some(path) = builder.finish() else { + return Ok(()); + }; + fill_and_stroke(context.pixmap, &path, ¶ms); + Ok(()) +} + +const POINTS_PARAM: ParamDefinition = ParamDefinition::Number { + key: "points", + label: "Points", + group: None, + default: 5.0, + min: 3.0, + max: Some(12.0), + step: 1.0, + short_label: Some("P"), +}; + +const DEPTH_PARAM: ParamDefinition = ParamDefinition::Number { + key: "depth", + label: "Depth", + group: None, + default: 45.0, + min: 1.0, + max: Some(99.0), + step: 1.0, + short_label: Some("D"), +}; + +pub static STAR_GRAPHIC_DEFINITION: GraphicDefinition = GraphicDefinition { + id: "star", + name: "Star", + keywords: &["star", "sparkle", "burst"], + params: &[ + FILL_PARAM, + STROKE_PARAM, + STROKE_WIDTH_PARAM, + STROKE_ALIGN_PARAM, + POINTS_PARAM, + DEPTH_PARAM, + ], + render: render_star, +}; + +fn render_star(context: &mut crate::types::GraphicRenderContext) -> Result<(), GraphicsError> { + let params = resolve_shape_params(context.params)?; + let width = f64::from(context.width); + let height = f64::from(context.height); + let points = (param_number(context.params, "points", 5.0).round() as i64).clamp(3, 12) as usize; + let depth = (param_number(context.params, "depth", 45.0).clamp(1.0, 99.0)) / 100.0; + let outer_radius = (width.min(height) / 2.0 - params.inset).max(1.0); + let inner_radius = outer_radius * depth; + let center_x = width / 2.0; + let center_y = height / 2.0; + let mut builder = PathBuilder::new(); + for index in 0..points * 2 { + let radius = if index % 2 == 0 { + outer_radius + } else { + inner_radius + }; + let angle = -std::f64::consts::FRAC_PI_2 + (index as f64 * std::f64::consts::PI) / points as f64; + let x = center_x + angle.cos() * radius; + let y = center_y + angle.sin() * radius; + if index == 0 { + builder.move_to(x as f32, y as f32); + } else { + builder.line_to(x as f32, y as f32); + } + } + builder.close(); + let Some(path) = builder.finish() else { + return Ok(()); + }; + fill_and_stroke(context.pixmap, &path, ¶ms); + Ok(()) +} + +pub fn default_graphic_definitions() -> Vec<&'static GraphicDefinition> { + vec![ + &RECTANGLE_GRAPHIC_DEFINITION, + &ELLIPSE_GRAPHIC_DEFINITION, + &POLYGON_GRAPHIC_DEFINITION, + &STAR_GRAPHIC_DEFINITION, + ] +} diff --git a/rust/crates/graphics/src/error.rs b/rust/crates/graphics/src/error.rs new file mode 100644 index 00000000..4404520f --- /dev/null +++ b/rust/crates/graphics/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +use crate::gradient::GradientParseError; + +#[derive(Debug, Error, PartialEq)] +pub enum GraphicsError { + #[error("Unknown graphic: {0}")] + UnknownGraphic(String), + #[error("Invalid color: {0}")] + InvalidColor(String), + #[error(transparent)] + GradientParse(#[from] GradientParseError), +} 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() +} diff --git a/rust/crates/graphics/src/lib.rs b/rust/crates/graphics/src/lib.rs index 8b137891..3261959b 100644 --- a/rust/crates/graphics/src/lib.rs +++ b/rust/crates/graphics/src/lib.rs @@ -1 +1,32 @@ +pub mod color; +pub mod definitions; +pub mod error; +pub mod gradient; +pub mod params; +pub mod registry; +pub mod stroke; +pub mod types; +pub use color::{Rgba, hsl_to_rgb, is_transparent, named_color, parse_color_to_rgb, parse_css_color}; +pub use definitions::{ + ELLIPSE_GRAPHIC_DEFINITION, POLYGON_GRAPHIC_DEFINITION, RECTANGLE_GRAPHIC_DEFINITION, + STAR_GRAPHIC_DEFINITION, STROKE_ALIGN_PARAM, StrokeAlign, default_graphic_definitions, +}; +pub use error::GraphicsError; +pub use gradient::{ + BackgroundLayer, Color, ColorStop, Distance, GradientAst, GradientKind, GradientOrientation, + GradientParseError, LinearOrientation, NormalizedColorStop, Position, RadialOrientation, + Shape, ShapeKind, ShapeStyle, color_to_string, draw_css_background, is_gradient_layer, + normalize_color_stops, parse_background_layers, parse_gradient, split_css_layers, +}; +pub use params::{ParamDefinition, ParamGroup, SelectOption, build_default_param_values}; +pub use registry::{ + GraphicsRegistry, build_default_graphic_instance, get_graphic_definition, graphics_registry, + render_graphic, render_graphic_rgba8, resolve_graphic_params, +}; +pub use scene::params::{ParamValue, ParamValues}; +pub use stroke::apply_aligned_stroke; +pub use types::{ + DEFAULT_GRAPHIC_SOURCE_SIZE, GraphicDefinition, GraphicInstance, GraphicRenderContext, + param_number, param_string, +}; diff --git a/rust/crates/graphics/src/params.rs b/rust/crates/graphics/src/params.rs new file mode 100644 index 00000000..6c0fedd2 --- /dev/null +++ b/rust/crates/graphics/src/params.rs @@ -0,0 +1,72 @@ +use scene::params::{ParamValue, ParamValues}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParamGroup { + Stroke, +} + +impl ParamGroup { + pub fn as_str(&self) -> &'static str { + match self { + ParamGroup::Stroke => "stroke", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SelectOption { + pub value: &'static str, + pub label: &'static str, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ParamDefinition { + Number { + key: &'static str, + label: &'static str, + group: Option, + default: f64, + min: f64, + max: Option, + step: f64, + short_label: Option<&'static str>, + }, + Color { + key: &'static str, + label: &'static str, + group: Option, + default: &'static str, + }, + Select { + key: &'static str, + label: &'static str, + group: Option, + default: &'static str, + options: &'static [SelectOption], + }, +} + +impl ParamDefinition { + pub fn key(&self) -> &'static str { + match self { + ParamDefinition::Number { key, .. } => key, + ParamDefinition::Color { key, .. } => key, + ParamDefinition::Select { key, .. } => key, + } + } + + pub fn default_value(&self) -> ParamValue { + match self { + ParamDefinition::Number { default, .. } => ParamValue::Number(*default), + ParamDefinition::Color { default, .. } => ParamValue::String((*default).to_string()), + ParamDefinition::Select { default, .. } => ParamValue::String((*default).to_string()), + } + } +} + +pub fn build_default_param_values(params: &[ParamDefinition]) -> ParamValues { + params + .iter() + .map(|param| (param.key().to_string(), param.default_value())) + .collect() +} diff --git a/rust/crates/graphics/src/registry.rs b/rust/crates/graphics/src/registry.rs new file mode 100644 index 00000000..199d3162 --- /dev/null +++ b/rust/crates/graphics/src/registry.rs @@ -0,0 +1,107 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use scene::params::ParamValues; + +use crate::definitions::default_graphic_definitions; +use crate::error::GraphicsError; +use crate::params::build_default_param_values; +use crate::types::{GraphicDefinition, GraphicInstance}; + +#[derive(Default)] +pub struct GraphicsRegistry { + definitions: BTreeMap, +} + +impl GraphicsRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn with_defaults() -> Self { + let mut registry = Self::new(); + registry.register_defaults(); + registry + } + + pub fn register_defaults(&mut self) { + for definition in default_graphic_definitions() { + if self.has(definition.id) { + continue; + } + self.register(definition.id, definition); + } + } + + pub fn register(&mut self, key: &str, definition: &'static GraphicDefinition) { + self.definitions.insert(key.to_string(), definition); + } + + pub fn has(&self, key: &str) -> bool { + self.definitions.contains_key(key) + } + + pub fn get(&self, key: &str) -> Result<&'static GraphicDefinition, GraphicsError> { + self.definitions + .get(key) + .copied() + .ok_or_else(|| GraphicsError::UnknownGraphic(key.to_string())) + } + + pub fn get_all(&self) -> Vec<&'static GraphicDefinition> { + self.definitions.values().copied().collect() + } +} + +static REGISTRY: LazyLock = LazyLock::new(GraphicsRegistry::with_defaults); + +pub fn graphics_registry() -> &'static GraphicsRegistry { + ®ISTRY +} + +pub fn get_graphic_definition( + definition_id: &str, +) -> Result<&'static GraphicDefinition, GraphicsError> { + graphics_registry().get(definition_id) +} + +pub fn build_default_graphic_instance( + definition_id: &str, +) -> Result { + let definition = get_graphic_definition(definition_id)?; + Ok(GraphicInstance { + definition_id: definition_id.to_string(), + params: build_default_param_values(definition.params), + }) +} + +pub fn resolve_graphic_params( + definition: &GraphicDefinition, + params: Option<&ParamValues>, +) -> ParamValues { + let mut resolved = build_default_param_values(definition.params); + if let Some(params) = params { + resolved.extend(params.clone()); + } + resolved +} + +pub fn render_graphic( + definition_id: &str, + params: Option<&ParamValues>, + width: u32, + height: u32, +) -> Result { + let definition = get_graphic_definition(definition_id)?; + let resolved = resolve_graphic_params(definition, params); + definition.render_to_pixmap(&resolved, width, height) +} + +pub fn render_graphic_rgba8( + definition_id: &str, + params: Option<&ParamValues>, + width: u32, + height: u32, +) -> Result, GraphicsError> { + Ok(render_graphic(definition_id, params, width, height)?.take()) +} diff --git a/rust/crates/graphics/src/stroke.rs b/rust/crates/graphics/src/stroke.rs new file mode 100644 index 00000000..4f2b6f31 --- /dev/null +++ b/rust/crates/graphics/src/stroke.rs @@ -0,0 +1,71 @@ +use tiny_skia::{BlendMode, Color, FillRule, Paint, Path, Pixmap, PixmapPaint, Stroke, Transform}; + +use crate::definitions::StrokeAlign; + +pub fn apply_aligned_stroke( + pixmap: &mut Pixmap, + path: &Path, + stroke_width: f64, + stroke_align: StrokeAlign, + stroke_color: Color, +) { + if stroke_width <= 0.0 { + return; + } + + match stroke_align { + StrokeAlign::Inside => { + let Some(mut stroke_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) else { + return; + }; + stroke_path(&mut stroke_pixmap, path, stroke_width * 2.0, stroke_color); + erase_outside(&mut stroke_pixmap, path, BlendMode::DestinationIn); + composite(pixmap, &stroke_pixmap); + } + StrokeAlign::Outside => { + let Some(mut stroke_pixmap) = Pixmap::new(pixmap.width(), pixmap.height()) else { + return; + }; + stroke_path(&mut stroke_pixmap, path, stroke_width * 2.0, stroke_color); + erase_outside(&mut stroke_pixmap, path, BlendMode::DestinationOut); + composite(pixmap, &stroke_pixmap); + } + StrokeAlign::Center => { + stroke_path(pixmap, path, stroke_width, stroke_color); + } + } +} + +fn stroke_path(pixmap: &mut Pixmap, path: &Path, width: f64, color: Color) { + let paint = Paint { + shader: tiny_skia::Shader::SolidColor(color), + anti_alias: true, + ..Paint::default() + }; + let stroke = Stroke { + width: width as f32, + ..Stroke::default() + }; + pixmap.stroke_path(path, &paint, &stroke, Transform::default(), None); +} + +fn erase_outside(pixmap: &mut Pixmap, path: &Path, blend_mode: BlendMode) { + let paint = Paint { + shader: tiny_skia::Shader::SolidColor(Color::BLACK), + anti_alias: true, + blend_mode, + ..Paint::default() + }; + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::default(), None); +} + +fn composite(target: &mut Pixmap, source: &Pixmap) { + target.draw_pixmap( + 0, + 0, + source.as_ref(), + &PixmapPaint::default(), + Transform::default(), + None, + ); +} diff --git a/rust/crates/graphics/src/types.rs b/rust/crates/graphics/src/types.rs new file mode 100644 index 00000000..0a93f53e --- /dev/null +++ b/rust/crates/graphics/src/types.rs @@ -0,0 +1,77 @@ +use scene::params::{ParamValue, ParamValues}; +use tiny_skia::Pixmap; + +use crate::error::GraphicsError; +use crate::params::ParamDefinition; + +pub const DEFAULT_GRAPHIC_SOURCE_SIZE: u32 = 512; + +pub struct GraphicRenderContext<'a> { + pub pixmap: &'a mut Pixmap, + pub params: &'a ParamValues, + pub width: u32, + pub height: u32, +} + +pub struct GraphicDefinition { + pub id: &'static str, + pub name: &'static str, + pub keywords: &'static [&'static str], + pub params: &'static [ParamDefinition], + pub render: fn(&mut GraphicRenderContext) -> Result<(), GraphicsError>, +} + +impl GraphicDefinition { + pub fn render_to_pixmap( + &self, + params: &ParamValues, + width: u32, + height: u32, + ) -> Result { + let mut pixmap = Pixmap::new(width, height) + .ok_or_else(|| GraphicsError::InvalidColor("invalid size".to_string()))?; + let mut context = GraphicRenderContext { + pixmap: &mut pixmap, + params, + width, + height, + }; + (self.render)(&mut context)?; + Ok(pixmap) + } + + pub fn render_rgba8( + &self, + params: &ParamValues, + width: u32, + height: u32, + ) -> Result, GraphicsError> { + Ok(self.render_to_pixmap(params, width, height)?.take()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GraphicInstance { + pub definition_id: String, + pub params: ParamValues, +} + +pub fn param_string(params: &ParamValues, key: &str, default: &str) -> String { + match params.get(key) { + Some(ParamValue::String(value)) => value.clone(), + Some(ParamValue::Number(value)) => value.to_string(), + Some(ParamValue::Bool(value)) => value.to_string(), + None => default.to_string(), + } +} + +pub fn param_number(params: &ParamValues, key: &str, default: f64) -> f64 { + match params.get(key) { + Some(ParamValue::Number(value)) => *value, + Some(ParamValue::String(value)) => value.trim().parse::().unwrap_or(default), + Some(ParamValue::Bool(value)) => { + if *value { 1.0 } else { 0.0 } + } + None => default, + } +} -- cgit v1.3.1