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

            
10
use crate::shape::{CssShape, ShapePoint};
11

            
12
/// Error type for shape parsing failures
13
#[derive(Debug, Clone, PartialEq, Eq)]
14
pub 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

            
27
impl 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
50
13
pub fn parse_shape(input: &str) -> Result<CssShape, ShapeParseError> {
51
13
    let input = input.trim();
52

            
53
13
    if input.is_empty() {
54
1
        return Err(ShapeParseError::EmptyInput);
55
12
    }
56

            
57
    // Extract function name and arguments
58
12
    let (func_name, args) = parse_function(input)?;
59

            
60
12
    match func_name.as_str() {
61
12
        "circle" => parse_circle(&args),
62
8
        "ellipse" => parse_ellipse(&args),
63
7
        "polygon" => parse_polygon(&args),
64
4
        "inset" => parse_inset(&args),
65
2
        "path" => parse_path(&args),
66
1
        _ => Err(ShapeParseError::UnknownFunction(func_name)),
67
    }
68
13
}
69

            
70
/// Extracts function name and arguments from "func(args)"
71
12
fn parse_function(
72
12
    input: &str,
73
12
) -> Result<(alloc::string::String, alloc::string::String), ShapeParseError> {
74
12
    let open_paren = input
75
12
        .find('(')
76
12
        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing opening parenthesis".into()))?;
77

            
78
12
    let close_paren = input
79
12
        .rfind(')')
80
12
        .ok_or_else(|| ShapeParseError::InvalidSyntax("Missing closing parenthesis".into()))?;
81

            
82
12
    if close_paren <= open_paren {
83
        return Err(ShapeParseError::InvalidSyntax("Invalid parentheses".into()));
84
12
    }
85

            
86
12
    let func_name = input[..open_paren].trim().to_string();
87
12
    let args = input[open_paren + 1..close_paren].trim().to_string();
88

            
89
12
    Ok((func_name, args))
90
12
}
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
98
4
fn parse_circle(args: &str) -> Result<CssShape, ShapeParseError> {
99
4
    let parts: Vec<&str> = args.split_whitespace().collect();
100

            
101
4
    if parts.is_empty() {
102
        return Err(ShapeParseError::MissingParameter("radius".into()));
103
4
    }
104

            
105
4
    let radius = parse_length(parts[0])?;
106

            
107
4
    let center = if parts.len() >= 4 && parts[1] == "at" {
108
2
        let x = parse_length(parts[2])?;
109
2
        let y = parse_length(parts[3])?;
110
2
        ShapePoint::new(x, y)
111
    } else {
112
2
        ShapePoint::zero() // Default to origin
113
    };
114

            
115
4
    Ok(CssShape::circle(center, radius))
116
4
}
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)
123
1
fn parse_ellipse(args: &str) -> Result<CssShape, ShapeParseError> {
124
1
    let parts: Vec<&str> = args.split_whitespace().collect();
125

            
126
1
    if parts.len() < 2 {
127
        return Err(ShapeParseError::MissingParameter(
128
            "radius_x and radius_y".into(),
129
        ));
130
1
    }
131

            
132
1
    let radius_x = parse_length(parts[0])?;
133
1
    let radius_y = parse_length(parts[1])?;
134

            
135
1
    let center = if parts.len() >= 5 && parts[2] == "at" {
136
1
        let x = parse_length(parts[3])?;
137
1
        let y = parse_length(parts[4])?;
138
1
        ShapePoint::new(x, y)
139
    } else {
140
        ShapePoint::zero()
141
    };
142

            
143
1
    Ok(CssShape::ellipse(center, radius_x, radius_y))
144
1
}
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
155
3
fn parse_polygon(args: &str) -> Result<CssShape, ShapeParseError> {
156
3
    let args = args.trim();
157

            
158
    // Check for optional fill-rule
159
3
    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
3
        args
165
    };
166

            
167
    // Split by comma to get coordinate pairs
168
18
    let pairs: Vec<&str> = point_str.split(',').map(|s| s.trim()).collect();
169

            
170
3
    if pairs.is_empty() {
171
        return Err(ShapeParseError::MissingParameter(
172
            "at least one point".into(),
173
        ));
174
3
    }
175

            
176
3
    let mut points = alloc::vec::Vec::new();
177

            
178
21
    for pair in pairs {
179
18
        let coords: Vec<&str> = pair.split_whitespace().collect();
180

            
181
18
        if coords.len() < 2 {
182
            return Err(ShapeParseError::InvalidSyntax(format!(
183
                "Expected x y pair, got: {}",
184
                pair
185
            )));
186
18
        }
187

            
188
18
        let x = parse_length(coords[0])?;
189
18
        let y = parse_length(coords[1])?;
190

            
191
18
        points.push(ShapePoint::new(x, y));
192
    }
193

            
194
3
    if points.len() < 3 {
195
        return Err(ShapeParseError::InvalidSyntax(
196
            "Polygon must have at least 3 points".into(),
197
        ));
198
3
    }
199

            
200
3
    Ok(CssShape::polygon(points.into()))
201
3
}
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
211
2
fn parse_inset(args: &str) -> Result<CssShape, ShapeParseError> {
212
2
    let args = args.trim();
213

            
214
    // Check for optional "round" keyword for border radius
215
2
    let (inset_str, border_radius) = if let Some(round_pos) = args.find("round") {
216
1
        let insets = args[..round_pos].trim();
217
1
        let radius_str = args[round_pos + 5..].trim();
218
1
        let radius = parse_length(radius_str)?;
219
1
        (insets, Some(radius))
220
    } else {
221
1
        (args, None)
222
    };
223

            
224
2
    let values: Vec<&str> = inset_str.split_whitespace().collect();
225

            
226
2
    if values.is_empty() {
227
        return Err(ShapeParseError::MissingParameter("inset values".into()));
228
2
    }
229

            
230
    // Parse insets using CSS shorthand rules (same as margin/padding)
231
2
    let (top, right, bottom, left) = match values.len() {
232
        1 => {
233
1
            let all = parse_length(values[0])?;
234
1
            (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
1
            let top = parse_length(values[0])?;
249
1
            let right = parse_length(values[1])?;
250
1
            let bottom = parse_length(values[2])?;
251
1
            let left = parse_length(values[3])?;
252
1
            (top, right, bottom, left)
253
        }
254
        _ => {
255
            return Err(ShapeParseError::InvalidSyntax(
256
                "Too many inset values (max 4)".into(),
257
            ));
258
        }
259
    };
260

            
261
2
    if let Some(radius) = border_radius {
262
1
        Ok(CssShape::inset_rounded(top, right, bottom, left, radius))
263
    } else {
264
1
        Ok(CssShape::inset(top, right, bottom, left))
265
    }
266
2
}
267

            
268
/// Parses a path: `path("svg-path-data")`
269
///
270
/// Example:
271
/// - `path("M 0 0 L 100 0 L 100 100 Z")`
272
1
fn parse_path(args: &str) -> Result<CssShape, ShapeParseError> {
273
    use crate::corety::AzString;
274

            
275
1
    let args = args.trim();
276

            
277
    // Path data should be quoted
278
1
    if !args.starts_with('"') || !args.ends_with('"') {
279
        return Err(ShapeParseError::InvalidSyntax(
280
            "Path data must be quoted".into(),
281
        ));
282
1
    }
283

            
284
1
    let path_data = AzString::from(&args[1..args.len() - 1]);
285

            
286
1
    Ok(CssShape::Path(crate::shape::ShapePath { data: path_data }))
287
1
}
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)
293
54
fn parse_length(s: &str) -> Result<f32, ShapeParseError> {
294
54
    let s = s.trim();
295

            
296
54
    if let Some(num_str) = s.strip_suffix("px") {
297
50
        num_str
298
50
            .parse::<f32>()
299
50
            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
300
4
    } 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
4
        s.parse::<f32>()
310
4
            .map_err(|_| ShapeParseError::InvalidNumber(s.to_string()))
311
    }
312
54
}
313

            
314
#[cfg(test)]
315
mod tests {
316
    use super::*;
317
    use crate::{
318
        corety::OptionF32,
319
        shape::{ShapeCircle, ShapeEllipse, ShapeInset, ShapePath, ShapePolygon},
320
    };
321

            
322
    #[test]
323
1
    fn test_parse_circle() {
324
1
        let shape = parse_shape("circle(50px at 100px 100px)").unwrap();
325
1
        match shape {
326
1
            CssShape::Circle(ShapeCircle { center, radius }) => {
327
1
                assert_eq!(radius, 50.0);
328
1
                assert_eq!(center.x, 100.0);
329
1
                assert_eq!(center.y, 100.0);
330
            }
331
            _ => panic!("Expected Circle"),
332
        }
333
1
    }
334

            
335
    #[test]
336
1
    fn test_parse_circle_no_position() {
337
1
        let shape = parse_shape("circle(50px)").unwrap();
338
1
        match shape {
339
1
            CssShape::Circle(ShapeCircle { center, radius }) => {
340
1
                assert_eq!(radius, 50.0);
341
1
                assert_eq!(center.x, 0.0);
342
1
                assert_eq!(center.y, 0.0);
343
            }
344
            _ => panic!("Expected Circle"),
345
        }
346
1
    }
347

            
348
    #[test]
349
1
    fn test_parse_ellipse() {
350
1
        let shape = parse_shape("ellipse(50px 75px at 100px 100px)").unwrap();
351
1
        match shape {
352
            CssShape::Ellipse(ShapeEllipse {
353
1
                center,
354
1
                radius_x,
355
1
                radius_y,
356
            }) => {
357
1
                assert_eq!(radius_x, 50.0);
358
1
                assert_eq!(radius_y, 75.0);
359
1
                assert_eq!(center.x, 100.0);
360
1
                assert_eq!(center.y, 100.0);
361
            }
362
            _ => panic!("Expected Ellipse"),
363
        }
364
1
    }
365

            
366
    #[test]
367
1
    fn test_parse_polygon_rectangle() {
368
1
        let shape = parse_shape("polygon(0px 0px, 100px 0px, 100px 100px, 0px 100px)").unwrap();
369
1
        match shape {
370
1
            CssShape::Polygon(ShapePolygon { points }) => {
371
1
                assert_eq!(points.as_ref().len(), 4);
372
1
                assert_eq!(points.as_ref()[0].x, 0.0);
373
1
                assert_eq!(points.as_ref()[0].y, 0.0);
374
1
                assert_eq!(points.as_ref()[2].x, 100.0);
375
1
                assert_eq!(points.as_ref()[2].y, 100.0);
376
            }
377
            _ => panic!("Expected Polygon"),
378
        }
379
1
    }
380

            
381
    #[test]
382
1
    fn test_parse_polygon_star() {
383
        // 5-pointed star
384
1
        let shape = parse_shape(
385
1
            "polygon(50px 0px, 61px 35px, 98px 35px, 68px 57px, 79px 91px, 50px 70px, 21px 91px, \
386
1
             32px 57px, 2px 35px, 39px 35px)",
387
        )
388
1
        .unwrap();
389
1
        match shape {
390
1
            CssShape::Polygon(ShapePolygon { points }) => {
391
1
                assert_eq!(points.as_ref().len(), 10); // 5-pointed star has 10 vertices
392
            }
393
            _ => panic!("Expected Polygon"),
394
        }
395
1
    }
396

            
397
    #[test]
398
1
    fn test_parse_inset() {
399
1
        let shape = parse_shape("inset(10px 20px 30px 40px)").unwrap();
400
1
        match shape {
401
            CssShape::Inset(ShapeInset {
402
1
                inset_top,
403
1
                inset_right,
404
1
                inset_bottom,
405
1
                inset_left,
406
1
                border_radius,
407
            }) => {
408
1
                assert_eq!(inset_top, 10.0);
409
1
                assert_eq!(inset_right, 20.0);
410
1
                assert_eq!(inset_bottom, 30.0);
411
1
                assert_eq!(inset_left, 40.0);
412
1
                assert!(matches!(border_radius, OptionF32::None));
413
            }
414
            _ => panic!("Expected Inset"),
415
        }
416
1
    }
417

            
418
    #[test]
419
1
    fn test_parse_inset_rounded() {
420
1
        let shape = parse_shape("inset(10px round 5px)").unwrap();
421
1
        match shape {
422
            CssShape::Inset(ShapeInset {
423
1
                inset_top,
424
1
                inset_right,
425
1
                inset_bottom,
426
1
                inset_left,
427
1
                border_radius,
428
            }) => {
429
1
                assert_eq!(inset_top, 10.0);
430
1
                assert_eq!(inset_right, 10.0);
431
1
                assert_eq!(inset_bottom, 10.0);
432
1
                assert_eq!(inset_left, 10.0);
433
1
                assert!(matches!(border_radius, OptionF32::Some(r) if r == 5.0));
434
            }
435
            _ => panic!("Expected Inset"),
436
        }
437
1
    }
438

            
439
    #[test]
440
1
    fn test_parse_path() {
441
1
        let shape = parse_shape(r#"path("M 0 0 L 100 0 L 100 100 Z")"#).unwrap();
442
1
        match shape {
443
1
            CssShape::Path(ShapePath { data }) => {
444
1
                assert_eq!(data.as_str(), "M 0 0 L 100 0 L 100 100 Z");
445
            }
446
            _ => panic!("Expected Path"),
447
        }
448
1
    }
449

            
450
    #[test]
451
1
    fn test_invalid_function() {
452
1
        let result = parse_shape("unknown(50px)");
453
1
        assert!(result.is_err());
454
1
    }
455

            
456
    #[test]
457
1
    fn test_empty_input() {
458
1
        let result = parse_shape("");
459
1
        assert!(matches!(result, Err(ShapeParseError::EmptyInput)));
460
1
    }
461
}