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