Skip to main content

azul_css/
shape_parser.rs

1//! CSS Shape parsing for shape-inside, shape-outside, and clip-path
2//!
3//! Supports CSS Shapes Level 1 & 2 syntax:
4//! - `circle(radius at x y)`
5//! - `ellipse(rx ry at x y)`
6//! - `polygon(x1 y1, x2 y2, ...)`
7//! - `inset(top right bottom left [round radius])`
8//! - `path(svg-path-data)`
9
10use crate::shape::{CssShape, ShapePoint};
11
12/// Error type for shape parsing failures
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ShapeParseError {
15    /// Unknown shape function — the string contains the unrecognized function name
16    UnknownFunction(String),
17    /// Missing required parameter — the string names the expected parameter
18    MissingParameter(String),
19    /// Invalid numeric value — the string contains the unparseable token
20    InvalidNumber(String),
21    /// Invalid syntax — the string contains a description of what went wrong
22    InvalidSyntax(String),
23    /// Empty input string was provided
24    EmptyInput,
25}
26
27impl core::fmt::Display for ShapeParseError {
28    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29        match self {
30            Self::UnknownFunction(func) => {
31                write!(f, "Unknown shape function: {func}")
32            }
33            Self::MissingParameter(param) => {
34                write!(f, "Missing required parameter: {param}")
35            }
36            Self::InvalidNumber(num) => {
37                write!(f, "Invalid numeric value: {num}")
38            }
39            Self::InvalidSyntax(msg) => {
40                write!(f, "Invalid syntax: {msg}")
41            }
42            Self::EmptyInput => {
43                write!(f, "Empty input")
44            }
45        }
46    }
47}
48
49/// Parses a CSS shape value
50/// # Errors
51///
52/// Returns an error if `input` is not a valid CSS `shape` value.
53pub fn parse_shape(input: &str) -> Result<CssShape, ShapeParseError> {
54    let input = input.trim();
55
56    if input.is_empty() {
57        return Err(ShapeParseError::EmptyInput);
58    }
59
60    // Extract function name and arguments
61    let (func_name, args) = parse_function(input)?;
62
63    match func_name.as_str() {
64        "circle" => parse_circle(&args),
65        "ellipse" => parse_ellipse(&args),
66        "polygon" => parse_polygon(&args),
67        "inset" => parse_inset(&args),
68        "path" => parse_path(&args),
69        _ => Err(ShapeParseError::UnknownFunction(func_name)),
70    }
71}
72
73/// Extracts function name and arguments from "func(args)"
74fn parse_function(
75    input: &str,
76) -> Result<(String, String), ShapeParseError> {
77    let open_paren = input
78        .find('(')
79        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing opening parenthesis".into()))?;
80
81    let close_paren = input
82        .rfind(')')
83        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing closing parenthesis".into()))?;
84
85    if close_paren <= open_paren {
86        return Err(ShapeParseError::InvalidSyntax("Invalid parentheses".into()));
87    }
88
89    let func_name = input[..open_paren].trim().to_string();
90    let args = input[open_paren + 1..close_paren].trim().to_string();
91
92    Ok((func_name, args))
93}
94
95/// Parses a circle: `circle(radius at x y)` or `circle(radius)`
96///
97/// Examples:
98/// - `circle(50px)` - circle at origin with radius 50px
99/// - `circle(50px at 100px 100px)` - circle at (100, 100) with radius 50px
100/// - `circle(50%)` - circle with radius 50% of container
101fn parse_circle(args: &str) -> Result<CssShape, ShapeParseError> {
102    let parts: Vec<&str> = args.split_whitespace().collect();
103
104    if parts.is_empty() {
105        return Err(ShapeParseError::MissingParameter("radius".into()));
106    }
107
108    let radius = parse_length(parts[0])?;
109
110    let center = if parts.len() >= 4 && parts[1] == "at" {
111        let x = parse_length(parts[2])?;
112        let y = parse_length(parts[3])?;
113        ShapePoint::new(x, y)
114    } else {
115        ShapePoint::zero() // Default to origin
116    };
117
118    Ok(CssShape::circle(center, radius))
119}
120
121/// Parses an ellipse: `ellipse(rx ry at x y)` or `ellipse(rx ry)`
122///
123/// Examples:
124/// - `ellipse(50px 75px)` - ellipse at origin
125/// - `ellipse(50px 75px at 100px 100px)` - ellipse at (100, 100)
126fn parse_ellipse(args: &str) -> Result<CssShape, ShapeParseError> {
127    let parts: Vec<&str> = args.split_whitespace().collect();
128
129    if parts.len() < 2 {
130        return Err(ShapeParseError::MissingParameter(
131            "radius_x and radius_y".into(),
132        ));
133    }
134
135    let radius_x = parse_length(parts[0])?;
136    let radius_y = parse_length(parts[1])?;
137
138    let center = if parts.len() >= 5 && parts[2] == "at" {
139        let x = parse_length(parts[3])?;
140        let y = parse_length(parts[4])?;
141        ShapePoint::new(x, y)
142    } else {
143        ShapePoint::zero()
144    };
145
146    Ok(CssShape::ellipse(center, radius_x, radius_y))
147}
148
149/// Parses a polygon: `polygon([fill-rule,] x1 y1, x2 y2, ...)`
150///
151/// Note: the optional fill-rule (`nonzero` or `evenodd`) is parsed but
152/// currently ignored — the scanline rasterizer always uses even-odd fill.
153///
154/// Examples:
155/// - `polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)` - rectangle
156/// - `polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)` - diamond
157/// - `polygon(nonzero, 0 0, 100 0, 100 100)` - with fill rule
158fn parse_polygon(args: &str) -> Result<CssShape, ShapeParseError> {
159    let args = args.trim();
160
161    // Check for optional fill-rule
162    let point_str = if args.starts_with("nonzero,") || args.starts_with("evenodd,") {
163        // Skip fill-rule for now (not used in line segment computation)
164        let comma = args.find(',').unwrap();
165        &args[comma + 1..]
166    } else {
167        args
168    };
169
170    // Split by comma to get coordinate pairs
171    let pairs: Vec<&str> = point_str.split(',').map(str::trim).collect();
172
173    if pairs.is_empty() {
174        return Err(ShapeParseError::MissingParameter(
175            "at least one point".into(),
176        ));
177    }
178
179    let mut points = Vec::new();
180
181    for pair in pairs {
182        let coords: Vec<&str> = pair.split_whitespace().collect();
183
184        if coords.len() < 2 {
185            return Err(ShapeParseError::InvalidSyntax(format!(
186                "Expected x y pair, got: {pair}"
187            )));
188        }
189
190        let x = parse_length(coords[0])?;
191        let y = parse_length(coords[1])?;
192
193        points.push(ShapePoint::new(x, y));
194    }
195
196    if points.len() < 3 {
197        return Err(ShapeParseError::InvalidSyntax(
198            "Polygon must have at least 3 points".into(),
199        ));
200    }
201
202    Ok(CssShape::polygon(points.into()))
203}
204
205/// Parses an inset: `inset(top right bottom left [round radius])`
206///
207/// Examples:
208/// - `inset(10px)` - all sides 10px
209/// - `inset(10px 20px)` - top/bottom 10px, left/right 20px
210/// - `inset(10px 20px 30px)` - top 10px, left/right 20px, bottom 30px
211/// - `inset(10px 20px 30px 40px)` - individual sides
212/// - `inset(10px round 5px)` - with border radius
213fn parse_inset(args: &str) -> Result<CssShape, ShapeParseError> {
214    let args = args.trim();
215
216    // Check for optional "round" keyword for border radius
217    let (inset_str, border_radius) = if let Some(round_pos) = args.find("round") {
218        let insets = args[..round_pos].trim();
219        let radius_str = args[round_pos + 5..].trim();
220        let radius = parse_length(radius_str)?;
221        (insets, Some(radius))
222    } else {
223        (args, None)
224    };
225
226    let values: Vec<&str> = inset_str.split_whitespace().collect();
227
228    if values.is_empty() {
229        return Err(ShapeParseError::MissingParameter("inset values".into()));
230    }
231
232    // Parse insets using CSS shorthand rules (same as margin/padding)
233    let (top, right, bottom, left) = match values.len() {
234        1 => {
235            let all = parse_length(values[0])?;
236            (all, all, all, all)
237        }
238        2 => {
239            let vertical = parse_length(values[0])?;
240            let horizontal = parse_length(values[1])?;
241            (vertical, horizontal, vertical, horizontal)
242        }
243        3 => {
244            let top = parse_length(values[0])?;
245            let horizontal = parse_length(values[1])?;
246            let bottom = parse_length(values[2])?;
247            (top, horizontal, bottom, horizontal)
248        }
249        4 => {
250            let top = parse_length(values[0])?;
251            let right = parse_length(values[1])?;
252            let bottom = parse_length(values[2])?;
253            let left = parse_length(values[3])?;
254            (top, right, bottom, left)
255        }
256        _ => {
257            return Err(ShapeParseError::InvalidSyntax(
258                "Too many inset values (max 4)".into(),
259            ));
260        }
261    };
262
263    border_radius.map_or_else(
264        || Ok(CssShape::inset(top, right, bottom, left)),
265        |radius| Ok(CssShape::inset_rounded(top, right, bottom, left, radius)),
266    )
267}
268
269/// Parses a path: `path("svg-path-data")`
270///
271/// Example:
272/// - `path("M 0 0 L 100 0 L 100 100 Z")`
273fn parse_path(args: &str) -> Result<CssShape, ShapeParseError> {
274    use crate::corety::AzString;
275
276    let args = args.trim();
277
278    // Path data should be quoted
279    if !args.starts_with('"') || !args.ends_with('"') {
280        return Err(ShapeParseError::InvalidSyntax(
281            "Path data must be quoted".into(),
282        ));
283    }
284
285    let path_data = AzString::from(&args[1..args.len() - 1]);
286
287    Ok(CssShape::Path(crate::shape::ShapePath { data: path_data }))
288}
289
290/// Parses a CSS length value (px, %, em, etc.)
291///
292/// For now, only handles px and % values.
293/// TODO: Handle em, rem, vh, vw, etc. (requires layout context)
294fn parse_length(s: &str) -> Result<f32, ShapeParseError> {
295    let s = s.trim();
296
297    if let Some(num_str) = s.strip_suffix("px") {
298        num_str
299            .parse::<f32>()
300            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
301    } else if let Some(num_str) = s.strip_suffix('%') {
302        let percent = num_str
303            .parse::<f32>()
304            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))?;
305        // TODO: Percentage values need container size to resolve
306        // For now, treat as raw value (will need context later)
307        Ok(percent)
308    } else {
309        // Try to parse as unitless number (treat as px)
310        s.parse::<f32>()
311            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    // Tests assert that parsed values equal the exact source literals.
318    #![allow(clippy::float_cmp)]
319    use super::*;
320    use crate::{
321        corety::OptionF32,
322        shape::{ShapeCircle, ShapeEllipse, ShapeInset, ShapePath, ShapePolygon},
323    };
324
325    #[test]
326    fn test_parse_circle() {
327        let shape = parse_shape("circle(50px at 100px 100px)").unwrap();
328        match shape {
329            CssShape::Circle(ShapeCircle { center, radius }) => {
330                assert_eq!(radius, 50.0);
331                assert_eq!(center.x, 100.0);
332                assert_eq!(center.y, 100.0);
333            }
334            _ => panic!("Expected Circle"),
335        }
336    }
337
338    #[test]
339    fn test_parse_circle_no_position() {
340        let shape = parse_shape("circle(50px)").unwrap();
341        match shape {
342            CssShape::Circle(ShapeCircle { center, radius }) => {
343                assert_eq!(radius, 50.0);
344                assert_eq!(center.x, 0.0);
345                assert_eq!(center.y, 0.0);
346            }
347            _ => panic!("Expected Circle"),
348        }
349    }
350
351    #[test]
352    fn test_parse_ellipse() {
353        let shape = parse_shape("ellipse(50px 75px at 100px 100px)").unwrap();
354        match shape {
355            CssShape::Ellipse(ShapeEllipse {
356                center,
357                radius_x,
358                radius_y,
359            }) => {
360                assert_eq!(radius_x, 50.0);
361                assert_eq!(radius_y, 75.0);
362                assert_eq!(center.x, 100.0);
363                assert_eq!(center.y, 100.0);
364            }
365            _ => panic!("Expected Ellipse"),
366        }
367    }
368
369    #[test]
370    fn test_parse_polygon_rectangle() {
371        let shape = parse_shape("polygon(0px 0px, 100px 0px, 100px 100px, 0px 100px)").unwrap();
372        match shape {
373            CssShape::Polygon(ShapePolygon { points }) => {
374                assert_eq!(points.as_ref().len(), 4);
375                assert_eq!(points.as_ref()[0].x, 0.0);
376                assert_eq!(points.as_ref()[0].y, 0.0);
377                assert_eq!(points.as_ref()[2].x, 100.0);
378                assert_eq!(points.as_ref()[2].y, 100.0);
379            }
380            _ => panic!("Expected Polygon"),
381        }
382    }
383
384    #[test]
385    fn test_parse_polygon_star() {
386        // 5-pointed star
387        let shape = parse_shape(
388            "polygon(50px 0px, 61px 35px, 98px 35px, 68px 57px, 79px 91px, 50px 70px, 21px 91px, \
389             32px 57px, 2px 35px, 39px 35px)",
390        )
391        .unwrap();
392        match shape {
393            CssShape::Polygon(ShapePolygon { points }) => {
394                assert_eq!(points.as_ref().len(), 10); // 5-pointed star has 10 vertices
395            }
396            _ => panic!("Expected Polygon"),
397        }
398    }
399
400    #[test]
401    fn test_parse_inset() {
402        let shape = parse_shape("inset(10px 20px 30px 40px)").unwrap();
403        match shape {
404            CssShape::Inset(ShapeInset {
405                inset_top,
406                inset_right,
407                inset_bottom,
408                inset_left,
409                border_radius,
410            }) => {
411                assert_eq!(inset_top, 10.0);
412                assert_eq!(inset_right, 20.0);
413                assert_eq!(inset_bottom, 30.0);
414                assert_eq!(inset_left, 40.0);
415                assert!(matches!(border_radius, OptionF32::None));
416            }
417            _ => panic!("Expected Inset"),
418        }
419    }
420
421    #[test]
422    fn test_parse_inset_rounded() {
423        let shape = parse_shape("inset(10px round 5px)").unwrap();
424        match shape {
425            CssShape::Inset(ShapeInset {
426                inset_top,
427                inset_right,
428                inset_bottom,
429                inset_left,
430                border_radius,
431            }) => {
432                assert_eq!(inset_top, 10.0);
433                assert_eq!(inset_right, 10.0);
434                assert_eq!(inset_bottom, 10.0);
435                assert_eq!(inset_left, 10.0);
436                assert!(matches!(border_radius, OptionF32::Some(r) if r == 5.0));
437            }
438            _ => panic!("Expected Inset"),
439        }
440    }
441
442    #[test]
443    fn test_parse_path() {
444        let shape = parse_shape(r#"path("M 0 0 L 100 0 L 100 100 Z")"#).unwrap();
445        match shape {
446            CssShape::Path(ShapePath { data }) => {
447                assert_eq!(data.as_str(), "M 0 0 L 100 0 L 100 100 Z");
448            }
449            _ => panic!("Expected Path"),
450        }
451    }
452
453    #[test]
454    fn test_invalid_function() {
455        let result = parse_shape("unknown(50px)");
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn test_empty_input() {
461        let result = parse_shape("");
462        assert!(matches!(result, Err(ShapeParseError::EmptyInput)));
463    }
464}