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