Skip to main content

forme/svg/
mod.rs

1//! # SVG Parser
2//!
3//! Parses a subset of SVG into drawing commands that can be rendered to PDF.
4//! Supports: rect, circle, ellipse, line, polyline, polygon, path, g (group).
5//! Path commands: M, L, H, V, C, Q, A, Z (absolute + relative).
6
7use quick_xml::events::Event;
8use quick_xml::Reader;
9
10/// A parsed SVG viewBox.
11#[derive(Debug, Clone, Copy)]
12pub struct ViewBox {
13    pub min_x: f64,
14    pub min_y: f64,
15    pub width: f64,
16    pub height: f64,
17}
18
19/// Drawing commands produced by the SVG parser.
20#[derive(Debug, Clone)]
21pub enum SvgCommand {
22    MoveTo(f64, f64),
23    LineTo(f64, f64),
24    CurveTo(f64, f64, f64, f64, f64, f64),
25    ClosePath,
26    SetFill(f64, f64, f64),
27    SetFillNone,
28    SetStroke(f64, f64, f64),
29    SetStrokeNone,
30    SetStrokeWidth(f64),
31    Fill,
32    Stroke,
33    FillAndStroke,
34    SetLineCap(u32),
35    SetLineJoin(u32),
36    SaveState,
37    RestoreState,
38}
39
40/// Parse a viewBox string like "0 0 100 100".
41pub fn parse_view_box(s: &str) -> Option<ViewBox> {
42    let parts: Vec<f64> = s
43        .split_whitespace()
44        .filter_map(|p| p.parse::<f64>().ok())
45        .collect();
46    if parts.len() == 4 {
47        Some(ViewBox {
48            min_x: parts[0],
49            min_y: parts[1],
50            width: parts[2],
51            height: parts[3],
52        })
53    } else {
54        None
55    }
56}
57
58/// Parse SVG XML content into drawing commands.
59pub fn parse_svg(
60    content: &str,
61    _view_box: ViewBox,
62    _target_width: f64,
63    _target_height: f64,
64) -> Vec<SvgCommand> {
65    let mut commands = Vec::new();
66    let mut reader = Reader::from_str(content);
67
68    let mut fill_stack: Vec<Option<(f64, f64, f64)>> = vec![Some((0.0, 0.0, 0.0))];
69    let mut stroke_stack: Vec<Option<(f64, f64, f64)>> = vec![None];
70    let mut stroke_width_stack: Vec<f64> = vec![1.0];
71
72    let mut buf = Vec::new();
73
74    loop {
75        let event = reader.read_event_into(&mut buf);
76        let (e_ref, is_start) = match &event {
77            Ok(Event::Start(e)) => (Some(e), true),
78            Ok(Event::Empty(e)) => (Some(e), false),
79            Ok(Event::End(e)) => {
80                let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
81                if tag_name == "g" {
82                    fill_stack.pop();
83                    stroke_stack.pop();
84                    stroke_width_stack.pop();
85                    commands.push(SvgCommand::RestoreState);
86                }
87                buf.clear();
88                continue;
89            }
90            Ok(Event::Eof) => break,
91            Err(_) => break,
92            _ => {
93                buf.clear();
94                continue;
95            }
96        };
97        if let Some(e) = e_ref {
98            let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
99
100            // Parse style attributes
101            let fill = get_attr(e, "fill");
102            let stroke = get_attr(e, "stroke");
103            let sw = get_attr(e, "stroke-width");
104
105            let current_fill = if let Some(ref f) = fill {
106                if f == "none" {
107                    None
108                } else {
109                    parse_svg_color(f).or(*fill_stack.last().unwrap_or(&Some((0.0, 0.0, 0.0))))
110                }
111            } else {
112                *fill_stack.last().unwrap_or(&Some((0.0, 0.0, 0.0)))
113            };
114
115            let current_stroke = if let Some(ref s) = stroke {
116                if s == "none" {
117                    None
118                } else {
119                    parse_svg_color(s).or(*stroke_stack.last().unwrap_or(&None))
120                }
121            } else {
122                *stroke_stack.last().unwrap_or(&None)
123            };
124
125            let current_sw = sw
126                .as_deref()
127                .and_then(|s| s.parse::<f64>().ok())
128                .unwrap_or(*stroke_width_stack.last().unwrap_or(&1.0));
129
130            match tag_name.as_str() {
131                "g" if is_start => {
132                    commands.push(SvgCommand::SaveState);
133                    fill_stack.push(current_fill);
134                    stroke_stack.push(current_stroke);
135                    stroke_width_stack.push(current_sw);
136                }
137                "rect" => {
138                    let x = get_attr_f64(e, "x").unwrap_or(0.0);
139                    let y = get_attr_f64(e, "y").unwrap_or(0.0);
140                    let w = get_attr_f64(e, "width").unwrap_or(0.0);
141                    let h = get_attr_f64(e, "height").unwrap_or(0.0);
142
143                    emit_shape(
144                        &mut commands,
145                        current_fill,
146                        current_stroke,
147                        current_sw,
148                        || {
149                            vec![
150                                SvgCommand::MoveTo(x, y),
151                                SvgCommand::LineTo(x + w, y),
152                                SvgCommand::LineTo(x + w, y + h),
153                                SvgCommand::LineTo(x, y + h),
154                                SvgCommand::ClosePath,
155                            ]
156                        },
157                    );
158                }
159                "circle" => {
160                    let cx = get_attr_f64(e, "cx").unwrap_or(0.0);
161                    let cy = get_attr_f64(e, "cy").unwrap_or(0.0);
162                    let r = get_attr_f64(e, "r").unwrap_or(0.0);
163
164                    emit_shape(
165                        &mut commands,
166                        current_fill,
167                        current_stroke,
168                        current_sw,
169                        || ellipse_commands(cx, cy, r, r),
170                    );
171                }
172                "ellipse" => {
173                    let cx = get_attr_f64(e, "cx").unwrap_or(0.0);
174                    let cy = get_attr_f64(e, "cy").unwrap_or(0.0);
175                    let rx = get_attr_f64(e, "rx").unwrap_or(0.0);
176                    let ry = get_attr_f64(e, "ry").unwrap_or(0.0);
177
178                    emit_shape(
179                        &mut commands,
180                        current_fill,
181                        current_stroke,
182                        current_sw,
183                        || ellipse_commands(cx, cy, rx, ry),
184                    );
185                }
186                "line" => {
187                    let x1 = get_attr_f64(e, "x1").unwrap_or(0.0);
188                    let y1 = get_attr_f64(e, "y1").unwrap_or(0.0);
189                    let x2 = get_attr_f64(e, "x2").unwrap_or(0.0);
190                    let y2 = get_attr_f64(e, "y2").unwrap_or(0.0);
191
192                    // Lines only have stroke, no fill
193                    emit_shape(&mut commands, None, current_stroke, current_sw, || {
194                        vec![SvgCommand::MoveTo(x1, y1), SvgCommand::LineTo(x2, y2)]
195                    });
196                }
197                "polyline" | "polygon" => {
198                    let points_str = get_attr(e, "points").unwrap_or_default();
199                    let points = parse_points(&points_str);
200                    if !points.is_empty() {
201                        let close = tag_name == "polygon";
202                        emit_shape(
203                            &mut commands,
204                            current_fill,
205                            current_stroke,
206                            current_sw,
207                            || {
208                                let mut cmds = Vec::new();
209                                cmds.push(SvgCommand::MoveTo(points[0].0, points[0].1));
210                                for &(px, py) in &points[1..] {
211                                    cmds.push(SvgCommand::LineTo(px, py));
212                                }
213                                if close {
214                                    cmds.push(SvgCommand::ClosePath);
215                                }
216                                cmds
217                            },
218                        );
219                    }
220                }
221                "path" => {
222                    let d = get_attr(e, "d").unwrap_or_default();
223                    let path_cmds = parse_path_d(&d);
224                    if !path_cmds.is_empty() {
225                        emit_shape(
226                            &mut commands,
227                            current_fill,
228                            current_stroke,
229                            current_sw,
230                            || path_cmds.clone(),
231                        );
232                    }
233                }
234                _ => {}
235            }
236        }
237        buf.clear();
238    }
239
240    commands
241}
242
243fn emit_shape(
244    commands: &mut Vec<SvgCommand>,
245    fill: Option<(f64, f64, f64)>,
246    stroke: Option<(f64, f64, f64)>,
247    stroke_width: f64,
248    path_fn: impl FnOnce() -> Vec<SvgCommand>,
249) {
250    let has_fill = fill.is_some();
251    let has_stroke = stroke.is_some();
252
253    if !has_fill && !has_stroke {
254        return;
255    }
256
257    commands.push(SvgCommand::SaveState);
258
259    if let Some((r, g, b)) = fill {
260        commands.push(SvgCommand::SetFill(r, g, b));
261    }
262    if let Some((r, g, b)) = stroke {
263        commands.push(SvgCommand::SetStroke(r, g, b));
264        commands.push(SvgCommand::SetStrokeWidth(stroke_width));
265    }
266
267    commands.extend(path_fn());
268
269    match (has_fill, has_stroke) {
270        (true, true) => commands.push(SvgCommand::FillAndStroke),
271        (true, false) => commands.push(SvgCommand::Fill),
272        (false, true) => commands.push(SvgCommand::Stroke),
273        _ => {}
274    }
275
276    commands.push(SvgCommand::RestoreState);
277}
278
279/// Generate cubic bezier commands to approximate an ellipse.
280pub fn ellipse_commands(cx: f64, cy: f64, rx: f64, ry: f64) -> Vec<SvgCommand> {
281    let k: f64 = 0.5522847498;
282    let kx = rx * k;
283    let ky = ry * k;
284
285    vec![
286        SvgCommand::MoveTo(cx + rx, cy),
287        SvgCommand::CurveTo(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry),
288        SvgCommand::CurveTo(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy),
289        SvgCommand::CurveTo(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry),
290        SvgCommand::CurveTo(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy),
291        SvgCommand::ClosePath,
292    ]
293}
294
295/// Convert an SVG arc command to cubic bezier curves.
296/// Implements W3C SVG spec F.6.5/F.6.6 (endpoint-to-center parameterization).
297#[allow(clippy::too_many_arguments)]
298fn svg_arc_to_curves(
299    x1: f64,
300    y1: f64,
301    mut rx: f64,
302    mut ry: f64,
303    x_rotation_deg: f64,
304    large_arc: bool,
305    sweep: bool,
306    x2: f64,
307    y2: f64,
308) -> Vec<SvgCommand> {
309    // F.6.2: If endpoints are identical, skip
310    if (x1 - x2).abs() < 1e-10 && (y1 - y2).abs() < 1e-10 {
311        return vec![];
312    }
313    // F.6.2: If either radius is zero, treat as line
314    if rx.abs() < 1e-10 || ry.abs() < 1e-10 {
315        return vec![SvgCommand::LineTo(x2, y2)];
316    }
317
318    rx = rx.abs();
319    ry = ry.abs();
320
321    let phi = x_rotation_deg.to_radians();
322    let cos_phi = phi.cos();
323    let sin_phi = phi.sin();
324
325    // F.6.5.1: Compute (x1', y1')
326    let dx = (x1 - x2) / 2.0;
327    let dy = (y1 - y2) / 2.0;
328    let x1p = cos_phi * dx + sin_phi * dy;
329    let y1p = -sin_phi * dx + cos_phi * dy;
330
331    // F.6.6.2: Ensure radii are large enough
332    let x1p2 = x1p * x1p;
333    let y1p2 = y1p * y1p;
334    let rx2 = rx * rx;
335    let ry2 = ry * ry;
336    let lambda = x1p2 / rx2 + y1p2 / ry2;
337    if lambda > 1.0 {
338        let lambda_sqrt = lambda.sqrt();
339        rx *= lambda_sqrt;
340        ry *= lambda_sqrt;
341    }
342
343    let rx2 = rx * rx;
344    let ry2 = ry * ry;
345
346    // F.6.5.2: Compute center point (cx', cy')
347    let num = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2).max(0.0);
348    let den = rx2 * y1p2 + ry2 * x1p2;
349    let sq = if den.abs() < 1e-10 {
350        0.0
351    } else {
352        (num / den).sqrt()
353    };
354    let sign = if large_arc == sweep { -1.0 } else { 1.0 };
355    let cxp = sign * sq * (rx * y1p / ry);
356    let cyp = sign * sq * -(ry * x1p / rx);
357
358    // F.6.5.3: Compute center point (cx, cy)
359    let cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0;
360    let cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0;
361
362    // F.6.5.5/F.6.5.6: Compute theta1 and dtheta
363    let theta1 = angle_between(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
364    let mut dtheta = angle_between(
365        (x1p - cxp) / rx,
366        (y1p - cyp) / ry,
367        (-x1p - cxp) / rx,
368        (-y1p - cyp) / ry,
369    );
370
371    if !sweep && dtheta > 0.0 {
372        dtheta -= std::f64::consts::TAU;
373    } else if sweep && dtheta < 0.0 {
374        dtheta += std::f64::consts::TAU;
375    }
376
377    // Split arc into segments of at most PI/2
378    let n_segs = (dtheta.abs() / (std::f64::consts::FRAC_PI_2)).ceil() as usize;
379    let n_segs = n_segs.max(1);
380    let d_per_seg = dtheta / n_segs as f64;
381
382    let mut commands = Vec::new();
383    let mut theta = theta1;
384
385    for _ in 0..n_segs {
386        let t1 = theta;
387        let t2 = theta + d_per_seg;
388
389        // Cubic bezier approximation of arc segment
390        let alpha = (d_per_seg / 4.0).tan() * 4.0 / 3.0;
391
392        let cos_t1 = t1.cos();
393        let sin_t1 = t1.sin();
394        let cos_t2 = t2.cos();
395        let sin_t2 = t2.sin();
396
397        // Points on the unit circle
398        let ep1x = cos_t1 - alpha * sin_t1;
399        let ep1y = sin_t1 + alpha * cos_t1;
400        let ep2x = cos_t2 + alpha * sin_t2;
401        let ep2y = sin_t2 - alpha * cos_t2;
402
403        // Scale by radii, rotate, translate
404        let cp1x = cos_phi * rx * ep1x - sin_phi * ry * ep1y + cx;
405        let cp1y = sin_phi * rx * ep1x + cos_phi * ry * ep1y + cy;
406        let cp2x = cos_phi * rx * ep2x - sin_phi * ry * ep2y + cx;
407        let cp2y = sin_phi * rx * ep2x + cos_phi * ry * ep2y + cy;
408        let ex = cos_phi * rx * cos_t2 - sin_phi * ry * sin_t2 + cx;
409        let ey = sin_phi * rx * cos_t2 + cos_phi * ry * sin_t2 + cy;
410
411        commands.push(SvgCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, ex, ey));
412
413        theta = t2;
414    }
415
416    commands
417}
418
419/// Compute the angle between two vectors.
420fn angle_between(ux: f64, uy: f64, vx: f64, vy: f64) -> f64 {
421    let dot = ux * vx + uy * vy;
422    let len = (ux * ux + uy * uy).sqrt() * (vx * vx + vy * vy).sqrt();
423    if len.abs() < 1e-10 {
424        return 0.0;
425    }
426    let cos_val = (dot / len).clamp(-1.0, 1.0);
427    let angle = cos_val.acos();
428    if ux * vy - uy * vx < 0.0 {
429        -angle
430    } else {
431        angle
432    }
433}
434
435/// Parse an SVG path `d` attribute into drawing commands.
436fn parse_path_d(d: &str) -> Vec<SvgCommand> {
437    let mut commands = Vec::new();
438    let mut cur_x = 0.0f64;
439    let mut cur_y = 0.0f64;
440    let mut start_x = 0.0f64;
441    let mut start_y = 0.0f64;
442
443    let tokens = tokenize_path(d);
444    let mut i = 0;
445
446    while i < tokens.len() {
447        match tokens[i].as_str() {
448            "M" => {
449                if i + 2 < tokens.len() {
450                    cur_x = tokens[i + 1].parse().unwrap_or(0.0);
451                    cur_y = tokens[i + 2].parse().unwrap_or(0.0);
452                    start_x = cur_x;
453                    start_y = cur_y;
454                    commands.push(SvgCommand::MoveTo(cur_x, cur_y));
455                    i += 3;
456                    // Implicit LineTo for subsequent coordinate pairs
457                    while i + 1 < tokens.len() && is_number(&tokens[i]) {
458                        cur_x = tokens[i].parse().unwrap_or(0.0);
459                        cur_y = tokens[i + 1].parse().unwrap_or(0.0);
460                        commands.push(SvgCommand::LineTo(cur_x, cur_y));
461                        i += 2;
462                    }
463                } else {
464                    i += 1;
465                }
466            }
467            "m" => {
468                if i + 2 < tokens.len() {
469                    cur_x += tokens[i + 1].parse::<f64>().unwrap_or(0.0);
470                    cur_y += tokens[i + 2].parse::<f64>().unwrap_or(0.0);
471                    start_x = cur_x;
472                    start_y = cur_y;
473                    commands.push(SvgCommand::MoveTo(cur_x, cur_y));
474                    i += 3;
475                    while i + 1 < tokens.len() && is_number(&tokens[i]) {
476                        cur_x += tokens[i].parse::<f64>().unwrap_or(0.0);
477                        cur_y += tokens[i + 1].parse::<f64>().unwrap_or(0.0);
478                        commands.push(SvgCommand::LineTo(cur_x, cur_y));
479                        i += 2;
480                    }
481                } else {
482                    i += 1;
483                }
484            }
485            "L" => {
486                i += 1;
487                while i + 1 < tokens.len() && is_number(&tokens[i]) {
488                    cur_x = tokens[i].parse().unwrap_or(0.0);
489                    cur_y = tokens[i + 1].parse().unwrap_or(0.0);
490                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
491                    i += 2;
492                }
493            }
494            "l" => {
495                i += 1;
496                while i + 1 < tokens.len() && is_number(&tokens[i]) {
497                    cur_x += tokens[i].parse::<f64>().unwrap_or(0.0);
498                    cur_y += tokens[i + 1].parse::<f64>().unwrap_or(0.0);
499                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
500                    i += 2;
501                }
502            }
503            "H" => {
504                i += 1;
505                while i < tokens.len() && is_number(&tokens[i]) {
506                    cur_x = tokens[i].parse().unwrap_or(0.0);
507                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
508                    i += 1;
509                }
510            }
511            "h" => {
512                i += 1;
513                while i < tokens.len() && is_number(&tokens[i]) {
514                    cur_x += tokens[i].parse::<f64>().unwrap_or(0.0);
515                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
516                    i += 1;
517                }
518            }
519            "V" => {
520                i += 1;
521                while i < tokens.len() && is_number(&tokens[i]) {
522                    cur_y = tokens[i].parse().unwrap_or(0.0);
523                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
524                    i += 1;
525                }
526            }
527            "v" => {
528                i += 1;
529                while i < tokens.len() && is_number(&tokens[i]) {
530                    cur_y += tokens[i].parse::<f64>().unwrap_or(0.0);
531                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
532                    i += 1;
533                }
534            }
535            "C" => {
536                i += 1;
537                while i + 5 < tokens.len() && is_number(&tokens[i]) {
538                    let x1 = tokens[i].parse().unwrap_or(0.0);
539                    let y1 = tokens[i + 1].parse().unwrap_or(0.0);
540                    let x2 = tokens[i + 2].parse().unwrap_or(0.0);
541                    let y2 = tokens[i + 3].parse().unwrap_or(0.0);
542                    cur_x = tokens[i + 4].parse().unwrap_or(0.0);
543                    cur_y = tokens[i + 5].parse().unwrap_or(0.0);
544                    commands.push(SvgCommand::CurveTo(x1, y1, x2, y2, cur_x, cur_y));
545                    i += 6;
546                }
547            }
548            "c" => {
549                i += 1;
550                while i + 5 < tokens.len() && is_number(&tokens[i]) {
551                    let x1 = cur_x + tokens[i].parse::<f64>().unwrap_or(0.0);
552                    let y1 = cur_y + tokens[i + 1].parse::<f64>().unwrap_or(0.0);
553                    let x2 = cur_x + tokens[i + 2].parse::<f64>().unwrap_or(0.0);
554                    let y2 = cur_y + tokens[i + 3].parse::<f64>().unwrap_or(0.0);
555                    cur_x += tokens[i + 4].parse::<f64>().unwrap_or(0.0);
556                    cur_y += tokens[i + 5].parse::<f64>().unwrap_or(0.0);
557                    commands.push(SvgCommand::CurveTo(x1, y1, x2, y2, cur_x, cur_y));
558                    i += 6;
559                }
560            }
561            "Q" => {
562                i += 1;
563                while i + 3 < tokens.len() && is_number(&tokens[i]) {
564                    let qx = tokens[i].parse::<f64>().unwrap_or(0.0);
565                    let qy = tokens[i + 1].parse::<f64>().unwrap_or(0.0);
566                    let end_x = tokens[i + 2].parse::<f64>().unwrap_or(0.0);
567                    let end_y = tokens[i + 3].parse::<f64>().unwrap_or(0.0);
568                    // Convert quadratic to cubic
569                    let c1x = cur_x + (2.0 / 3.0) * (qx - cur_x);
570                    let c1y = cur_y + (2.0 / 3.0) * (qy - cur_y);
571                    let c2x = end_x + (2.0 / 3.0) * (qx - end_x);
572                    let c2y = end_y + (2.0 / 3.0) * (qy - end_y);
573                    cur_x = end_x;
574                    cur_y = end_y;
575                    commands.push(SvgCommand::CurveTo(c1x, c1y, c2x, c2y, cur_x, cur_y));
576                    i += 4;
577                }
578            }
579            "q" => {
580                i += 1;
581                while i + 3 < tokens.len() && is_number(&tokens[i]) {
582                    let qx = cur_x + tokens[i].parse::<f64>().unwrap_or(0.0);
583                    let qy = cur_y + tokens[i + 1].parse::<f64>().unwrap_or(0.0);
584                    let end_x = cur_x + tokens[i + 2].parse::<f64>().unwrap_or(0.0);
585                    let end_y = cur_y + tokens[i + 3].parse::<f64>().unwrap_or(0.0);
586                    let c1x = cur_x + (2.0 / 3.0) * (qx - cur_x);
587                    let c1y = cur_y + (2.0 / 3.0) * (qy - cur_y);
588                    let c2x = end_x + (2.0 / 3.0) * (qx - end_x);
589                    let c2y = end_y + (2.0 / 3.0) * (qy - end_y);
590                    cur_x = end_x;
591                    cur_y = end_y;
592                    commands.push(SvgCommand::CurveTo(c1x, c1y, c2x, c2y, cur_x, cur_y));
593                    i += 4;
594                }
595            }
596            "A" => {
597                i += 1;
598                while i + 6 < tokens.len() && is_number(&tokens[i]) {
599                    let rx = tokens[i].parse::<f64>().unwrap_or(0.0);
600                    let ry = tokens[i + 1].parse::<f64>().unwrap_or(0.0);
601                    let x_rotation = tokens[i + 2].parse::<f64>().unwrap_or(0.0);
602                    let large_arc = tokens[i + 3].parse::<f64>().unwrap_or(0.0) != 0.0;
603                    let sweep = tokens[i + 4].parse::<f64>().unwrap_or(0.0) != 0.0;
604                    let end_x = tokens[i + 5].parse::<f64>().unwrap_or(0.0);
605                    let end_y = tokens[i + 6].parse::<f64>().unwrap_or(0.0);
606                    commands.extend(svg_arc_to_curves(
607                        cur_x, cur_y, rx, ry, x_rotation, large_arc, sweep, end_x, end_y,
608                    ));
609                    cur_x = end_x;
610                    cur_y = end_y;
611                    i += 7;
612                }
613            }
614            "a" => {
615                i += 1;
616                while i + 6 < tokens.len() && is_number(&tokens[i]) {
617                    let rx = tokens[i].parse::<f64>().unwrap_or(0.0);
618                    let ry = tokens[i + 1].parse::<f64>().unwrap_or(0.0);
619                    let x_rotation = tokens[i + 2].parse::<f64>().unwrap_or(0.0);
620                    let large_arc = tokens[i + 3].parse::<f64>().unwrap_or(0.0) != 0.0;
621                    let sweep = tokens[i + 4].parse::<f64>().unwrap_or(0.0) != 0.0;
622                    let end_x = cur_x + tokens[i + 5].parse::<f64>().unwrap_or(0.0);
623                    let end_y = cur_y + tokens[i + 6].parse::<f64>().unwrap_or(0.0);
624                    commands.extend(svg_arc_to_curves(
625                        cur_x, cur_y, rx, ry, x_rotation, large_arc, sweep, end_x, end_y,
626                    ));
627                    cur_x = end_x;
628                    cur_y = end_y;
629                    i += 7;
630                }
631            }
632            "Z" | "z" => {
633                commands.push(SvgCommand::ClosePath);
634                cur_x = start_x;
635                cur_y = start_y;
636                i += 1;
637            }
638            _ => {
639                i += 1;
640            }
641        }
642    }
643
644    commands
645}
646
647/// Tokenize a path `d` string into commands and numbers.
648fn tokenize_path(d: &str) -> Vec<String> {
649    let mut tokens = Vec::new();
650    let mut current = String::new();
651
652    let chars: Vec<char> = d.chars().collect();
653    let mut i = 0;
654
655    while i < chars.len() {
656        let ch = chars[i];
657
658        if ch.is_alphabetic() {
659            if !current.is_empty() {
660                tokens.push(current.clone());
661                current.clear();
662            }
663            tokens.push(ch.to_string());
664            i += 1;
665        } else if ch == '-'
666            && !current.is_empty()
667            && !current.ends_with('e')
668            && !current.ends_with('E')
669        {
670            // Negative sign starts a new number (unless after exponent)
671            tokens.push(current.clone());
672            current.clear();
673            current.push(ch);
674            i += 1;
675        } else if ch.is_ascii_digit() || ch == '.' || ch == '-' || ch == '+' {
676            current.push(ch);
677            i += 1;
678        } else if ch == ',' || ch.is_whitespace() {
679            if !current.is_empty() {
680                tokens.push(current.clone());
681                current.clear();
682            }
683            i += 1;
684        } else {
685            i += 1;
686        }
687    }
688
689    if !current.is_empty() {
690        tokens.push(current);
691    }
692
693    tokens
694}
695
696fn is_number(s: &str) -> bool {
697    s.parse::<f64>().is_ok()
698}
699
700/// Parse an SVG color string (hex, named colors).
701fn parse_svg_color(s: &str) -> Option<(f64, f64, f64)> {
702    let s = s.trim();
703    if let Some(hex) = s.strip_prefix('#') {
704        match hex.len() {
705            3 => {
706                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()? as f64 / 255.0;
707                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()? as f64 / 255.0;
708                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()? as f64 / 255.0;
709                Some((r, g, b))
710            }
711            6 => {
712                let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f64 / 255.0;
713                let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f64 / 255.0;
714                let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f64 / 255.0;
715                Some((r, g, b))
716            }
717            _ => None,
718        }
719    } else if s.starts_with("rgb(") {
720        let inner = s.trim_start_matches("rgb(").trim_end_matches(')');
721        let parts: Vec<&str> = inner.split(',').collect();
722        if parts.len() == 3 {
723            let r = parts[0].trim().parse::<f64>().ok()? / 255.0;
724            let g = parts[1].trim().parse::<f64>().ok()? / 255.0;
725            let b = parts[2].trim().parse::<f64>().ok()? / 255.0;
726            Some((r, g, b))
727        } else {
728            None
729        }
730    } else {
731        // Named colors
732        match s.to_lowercase().as_str() {
733            "black" => Some((0.0, 0.0, 0.0)),
734            "white" => Some((1.0, 1.0, 1.0)),
735            "red" => Some((1.0, 0.0, 0.0)),
736            "green" => Some((0.0, 0.502, 0.0)),
737            "blue" => Some((0.0, 0.0, 1.0)),
738            "yellow" => Some((1.0, 1.0, 0.0)),
739            "gray" | "grey" => Some((0.502, 0.502, 0.502)),
740            "orange" => Some((1.0, 0.647, 0.0)),
741            "purple" => Some((0.502, 0.0, 0.502)),
742            "cyan" => Some((0.0, 1.0, 1.0)),
743            "magenta" => Some((1.0, 0.0, 1.0)),
744            _ => None,
745        }
746    }
747}
748
749/// Parse SVG points attribute (e.g., "10,20 30,40").
750fn parse_points(s: &str) -> Vec<(f64, f64)> {
751    let nums: Vec<f64> = s
752        .split(|c: char| c == ',' || c.is_whitespace())
753        .filter(|s| !s.is_empty())
754        .filter_map(|s| s.parse::<f64>().ok())
755        .collect();
756
757    nums.chunks(2)
758        .filter(|c| c.len() == 2)
759        .map(|c| (c[0], c[1]))
760        .collect()
761}
762
763/// Helper to get an attribute value from a quick-xml BytesStart.
764fn get_attr(e: &quick_xml::events::BytesStart, name: &str) -> Option<String> {
765    for attr in e.attributes().flatten() {
766        if attr.key.as_ref() == name.as_bytes() {
767            return String::from_utf8(attr.value.to_vec()).ok();
768        }
769    }
770    None
771}
772
773fn get_attr_f64(e: &quick_xml::events::BytesStart, name: &str) -> Option<f64> {
774    get_attr(e, name).and_then(|s| s.parse::<f64>().ok())
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn test_parse_view_box() {
783        let vb = parse_view_box("0 0 100 200").unwrap();
784        assert!((vb.min_x - 0.0).abs() < 0.001);
785        assert!((vb.width - 100.0).abs() < 0.001);
786        assert!((vb.height - 200.0).abs() < 0.001);
787    }
788
789    #[test]
790    fn test_parse_view_box_invalid() {
791        assert!(parse_view_box("bad").is_none());
792    }
793
794    #[test]
795    fn test_parse_rect() {
796        let cmds = parse_svg(
797            r##"<rect x="10" y="20" width="100" height="50" fill="#ff0000"/>"##,
798            ViewBox {
799                min_x: 0.0,
800                min_y: 0.0,
801                width: 200.0,
802                height: 200.0,
803            },
804            200.0,
805            200.0,
806        );
807        assert!(!cmds.is_empty());
808        // Should have SaveState, SetFill, MoveTo, LineTo..., Fill, RestoreState
809        assert!(cmds
810            .iter()
811            .any(|c| matches!(c, SvgCommand::SetFill(r, _, _) if (*r - 1.0).abs() < 0.01)));
812    }
813
814    #[test]
815    fn test_parse_circle() {
816        let cmds = parse_svg(
817            r#"<circle cx="50" cy="50" r="25" fill="blue"/>"#,
818            ViewBox {
819                min_x: 0.0,
820                min_y: 0.0,
821                width: 100.0,
822                height: 100.0,
823            },
824            100.0,
825            100.0,
826        );
827        assert!(!cmds.is_empty());
828        assert!(cmds.iter().any(|c| matches!(c, SvgCommand::CurveTo(..))));
829    }
830
831    #[test]
832    fn test_parse_path_m_l_z() {
833        let cmds = parse_path_d("M 10 20 L 30 40 Z");
834        assert!(
835            matches!(cmds[0], SvgCommand::MoveTo(x, y) if (x - 10.0).abs() < 0.001 && (y - 20.0).abs() < 0.001)
836        );
837        assert!(
838            matches!(cmds[1], SvgCommand::LineTo(x, y) if (x - 30.0).abs() < 0.001 && (y - 40.0).abs() < 0.001)
839        );
840        assert!(matches!(cmds[2], SvgCommand::ClosePath));
841    }
842
843    #[test]
844    fn test_parse_path_relative() {
845        let cmds = parse_path_d("m 10 20 l 5 5 z");
846        assert!(
847            matches!(cmds[0], SvgCommand::MoveTo(x, y) if (x - 10.0).abs() < 0.001 && (y - 20.0).abs() < 0.001)
848        );
849        assert!(
850            matches!(cmds[1], SvgCommand::LineTo(x, y) if (x - 15.0).abs() < 0.001 && (y - 25.0).abs() < 0.001)
851        );
852    }
853
854    #[test]
855    fn test_parse_hex_color() {
856        let (r, g, b) = parse_svg_color("#ff0000").unwrap();
857        assert!((r - 1.0).abs() < 0.01);
858        assert!((g - 0.0).abs() < 0.01);
859        assert!((b - 0.0).abs() < 0.01);
860    }
861
862    #[test]
863    fn test_parse_line() {
864        let cmds = parse_svg(
865            r#"<line x1="0" y1="0" x2="100" y2="100" stroke="black"/>"#,
866            ViewBox {
867                min_x: 0.0,
868                min_y: 0.0,
869                width: 100.0,
870                height: 100.0,
871            },
872            100.0,
873            100.0,
874        );
875        assert!(!cmds.is_empty());
876        assert!(cmds.iter().any(|c| matches!(c, SvgCommand::Stroke)));
877    }
878
879    #[test]
880    fn test_empty_svg() {
881        let cmds = parse_svg(
882            "",
883            ViewBox {
884                min_x: 0.0,
885                min_y: 0.0,
886                width: 100.0,
887                height: 100.0,
888            },
889            100.0,
890            100.0,
891        );
892        assert!(cmds.is_empty());
893    }
894}