summaryrefslogtreecommitdiff
path: root/rust/crates/graphics/src
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
committercashmere <cashmere@autistici.org>2026-07-28 20:14:51 +0200
commit32438ce25f69de99d2523b98237f62cfa90890d7 (patch)
tree3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/graphics/src
parentf5a82152394fde4d8f740f664ab7737b82861618 (diff)
downloadopencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures). - timeline (45): placement, resize/ripple, split, snap, retime, audio separation, scene helpers - animation (73): bezier solver, channel sampling, keyframe ops, split/clamp/clone, param definitions - commands (9): Command trait, BatchCommand, history, 19 concrete commands, freeze-frame port 1:1 - audio (22): audible collection, range mixing, mastering limiter, rms/peak buckets - text (19): cosmic-text layout/measure/raster, backgrounds, decorations - graphics (52): full CSS gradient parser, tiny-skia raster, 4 shape definitions - effect-defs (16): blur definition, registry, pass resolution - mask-defs (19): all 9 mask renderers via tiny-skia - renderer (6): node model + scene builder port - desktop: GPUI projects screen wired to ProjectStore - scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/graphics/src')
-rw-r--r--rust/crates/graphics/src/color.rs487
-rw-r--r--rust/crates/graphics/src/definitions.rs531
-rw-r--r--rust/crates/graphics/src/error.rs13
-rw-r--r--rust/crates/graphics/src/gradient/ast.rs105
-rw-r--r--rust/crates/graphics/src/gradient/mod.rs15
-rw-r--r--rust/crates/graphics/src/gradient/parser.rs614
-rw-r--r--rust/crates/graphics/src/gradient/raster.rs549
-rw-r--r--rust/crates/graphics/src/gradient/stops.rs151
-rw-r--r--rust/crates/graphics/src/lib.rs31
-rw-r--r--rust/crates/graphics/src/params.rs72
-rw-r--r--rust/crates/graphics/src/registry.rs107
-rw-r--r--rust/crates/graphics/src/stroke.rs71
-rw-r--r--rust/crates/graphics/src/types.rs77
13 files changed, 2823 insertions, 0 deletions
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<Rgba> {
+ 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<Rgba> {
+ 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<f64> {
+ let trimmed = text.trim();
+ if let Some(percent) = trimmed.strip_suffix('%') {
+ return percent.trim().parse::<f64>().ok().map(|v| v * 255.0 / 100.0);
+ }
+ trimmed.parse::<f64>().ok()
+}
+
+fn parse_alpha(text: &str) -> Option<f64> {
+ let trimmed = text.trim();
+ if let Some(percent) = trimmed.strip_suffix('%') {
+ return percent.trim().parse::<f64>().ok().map(|v| v / 100.0);
+ }
+ trimmed.parse::<f64>().ok()
+}
+
+fn split_function_args(body: &str) -> Vec<String> {
+ if body.contains(',') {
+ return body.split(',').map(|part| part.trim().to_string()).collect();
+ }
+ let mut parts: Vec<String> = Vec::new();
+ let mut alpha: Option<String> = 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<Rgba> {
+ 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<f64> {
+ let trimmed = text.trim();
+ if let Some(deg) = trimmed.strip_suffix("deg") {
+ return deg.trim().parse::<f64>().ok();
+ }
+ if let Some(turn) = trimmed.strip_suffix("turn") {
+ return turn.trim().parse::<f64>().ok().map(|v| v * 360.0);
+ }
+ if let Some(rad) = trimmed.strip_suffix("rad") {
+ return rad.trim().parse::<f64>().ok().map(|v| v.to_degrees());
+ }
+ if let Some(grad) = trimmed.strip_suffix("grad") {
+ return grad.trim().parse::<f64>().ok().map(|v| v * 0.9);
+ }
+ trimmed.parse::<f64>().ok()
+}
+
+fn parse_percentage(text: &str) -> Option<f64> {
+ text.trim()
+ .strip_suffix('%')
+ .and_then(|v| v.trim().parse::<f64>().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<Rgba> {
+ 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<Rgba> {
+ 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::<f64>().is_ok_and(|alpha| alpha == 0.0)
+}
+
+pub fn parse_color_to_rgb(color: &str) -> Option<Rgba> {
+ 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::<f64>().ok()?;
+ let g = parts[1].parse::<f64>().ok()?;
+ let b = parts[2].parse::<f64>().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::<f64>().ok()?;
+ let g = parts[1].parse::<f64>().ok()?;
+ let b = parts[2].parse::<f64>().ok()?;
+ let a = parts[3].parse::<f64>().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<ShapeParams, GraphicsError> {
+ 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(&param_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, &params);
+ 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, &params);
+ 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<Point> {
+ (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, &params);
+ 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, &params);
+ 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<Distance>,
+ pub y: Option<Distance>,
+}
+
+#[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<ShapeStyle>,
+ pub at: Option<Position>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum RadialOrientation {
+ Shape(Shape),
+ ExtentKeyword {
+ value: String,
+ at: Option<Position>,
+ },
+ DefaultRadial {
+ at: Position,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum GradientOrientation {
+ Linear(LinearOrientation),
+ Radial(Vec<RadialOrientation>),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Color {
+ Hex(String),
+ Literal(String),
+ Rgb(Vec<String>),
+ Rgba(Vec<String>),
+ Hsl([String; 3]),
+ Hsla([String; 4]),
+ Var(String),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ColorStop {
+ pub color: Color,
+ pub length: Option<Distance>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct GradientAst {
+ pub kind: GradientKind,
+ pub orientation: Option<GradientOrientation>,
+ pub color_stops: Vec<ColorStop>,
+}
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<T> = Result<T, GradientParseError>;
+
+type TokenMatch = (usize, usize, usize);
+
+fn match_ci_prefix(s: &str, keyword: &str) -> Option<usize> {
+ 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<usize> {
+ 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<TokenMatch> {
+ 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<TokenMatch> {
+ Some((len, 0, len))
+}
+
+fn scan_percentage(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "%")
+}
+
+fn scan_px(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "px")
+}
+
+fn scan_em(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "em")
+}
+
+fn scan_deg(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "deg")
+}
+
+fn scan_rad(s: &str) -> Option<TokenMatch> {
+ scan_signed_number_unit(s, "rad")
+}
+
+fn scan_number_token(s: &str) -> Option<TokenMatch> {
+ scan_number_str(s).and_then(whole)
+}
+
+fn scan_hex_color(s: &str) -> Option<TokenMatch> {
+ 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<TokenMatch> {
+ 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<TokenMatch> {
+ 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<TokenMatch> {
+ 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<TokenMatch> {
+ 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<TokenMatch> {
+ 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<TokenMatch> {
+ 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<TokenMatch>) -> Option<String> {
+ 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<T>(
+ &mut self,
+ pattern: impl Fn(&str) -> Option<TokenMatch>,
+ callback: impl FnOnce(&mut Self) -> PResult<T>,
+ ) -> PResult<Option<T>> {
+ 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<T>(
+ &mut self,
+ matcher: fn(&mut Self) -> PResult<Option<T>>,
+ ) -> PResult<Vec<T>> {
+ 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<Option<GradientAst>> {
+ 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<Option<GradientAst>> {
+ 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<Option<GradientOrientation>> {
+ 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<Option<GradientOrientation>> {
+ 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<Option<RadialOrientation>> {
+ 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<Option<Shape>> {
+ 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<Option<Shape>> {
+ 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<Option<Shape>> {
+ 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<Option<Position>> {
+ 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<Option<Position>> {
+ 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<Option<ColorStop>> {
+ 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<Option<Color>> {
+ 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<Option<Color>> {
+ 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<Option<Color>> {
+ 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<Option<Color>> {
+ 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<Option<Color>> {
+ 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<Option<Color>> {
+ 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<String> {
+ self.scan_token(scan_number_token)
+ .ok_or_else(|| self.error("Expected number"))
+ }
+
+ fn match_distance(&mut self) -> PResult<Option<Distance>> {
+ 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<Option<Distance>> {
+ 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<Distance> {
+ 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<Vec<GradientAst>, 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<String> {
+ 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<BackgroundLayer> {
+ 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<Vec<GradientStop>, 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> {
+ 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::<f64>().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::<f64>()
+ .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::<f64>().unwrap_or(axis_size / 2.0),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .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<f64> {
+ match distance {
+ Distance::Percent(value) => value.parse::<f64>().ok().map(|v| (v / 100.0) * axis_size),
+ Distance::Px(value) => value.parse::<f64>().ok(),
+ Distance::Em(value) => value.parse::<f64>().ok().map(|v| v * 16.0),
+ _ => None,
+ }
+}
+
+fn resolve_ellipse_dimension(distance: &Distance, axis_size: f64) -> f64 {
+ match distance {
+ Distance::Percent(value) => value
+ .parse::<f64>()
+ .map(|v| (v / 100.0) * axis_size)
+ .unwrap_or(axis_size / 2.0),
+ Distance::Px(value) => value.parse::<f64>().unwrap_or(axis_size / 2.0),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .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<f64> {
+ match stop.length.as_ref()? {
+ Distance::Percent(value) => value.parse::<f64>().ok().map(|v| v / 100.0),
+ Distance::Px(value) => value.parse::<f64>().ok().map(|v| v / gradient_length),
+ Distance::Em(value) => value
+ .parse::<f64>()
+ .ok()
+ .map(|v| (v * 16.0) / gradient_length),
+ _ => None,
+ }
+}
+
+fn fix_transparent_stops(stops: &[(String, Option<f64>)]) -> Vec<(String, Option<f64>)> {
+ 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<NormalizedColorStop> {
+ let mapped: Vec<(String, Option<f64>)> = 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<usize> = 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<ParamGroup>,
+ default: f64,
+ min: f64,
+ max: Option<f64>,
+ step: f64,
+ short_label: Option<&'static str>,
+ },
+ Color {
+ key: &'static str,
+ label: &'static str,
+ group: Option<ParamGroup>,
+ default: &'static str,
+ },
+ Select {
+ key: &'static str,
+ label: &'static str,
+ group: Option<ParamGroup>,
+ 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<String, &'static GraphicDefinition>,
+}
+
+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<GraphicsRegistry> = LazyLock::new(GraphicsRegistry::with_defaults);
+
+pub fn graphics_registry() -> &'static GraphicsRegistry {
+ &REGISTRY
+}
+
+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<GraphicInstance, GraphicsError> {
+ 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<tiny_skia::Pixmap, GraphicsError> {
+ 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<Vec<u8>, 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<Pixmap, GraphicsError> {
+ 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<Vec<u8>, 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::<f64>().unwrap_or(default),
+ Some(ParamValue::Bool(value)) => {
+ if *value { 1.0 } else { 0.0 }
+ }
+ None => default,
+ }
+}