summaryrefslogtreecommitdiff
path: root/rust/crates/graphics/src/gradient
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/graphics/src/gradient')
-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
5 files changed, 1434 insertions, 0 deletions
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()
+}