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" => {
480                if i + 2 < tokens.len() {
481                    cur_x = tokens[i + 1].parse().unwrap_or(0.0);
482                    cur_y = tokens[i + 2].parse().unwrap_or(0.0);
483                    start_x = cur_x;
484                    start_y = cur_y;
485                    commands.push(SvgCommand::MoveTo(cur_x, cur_y));
486                    i += 3;
487                    // Implicit LineTo for subsequent coordinate pairs
488                    while i + 1 < tokens.len() && is_number(&tokens[i]) {
489                        cur_x = tokens[i].parse().unwrap_or(0.0);
490                        cur_y = tokens[i + 1].parse().unwrap_or(0.0);
491                        commands.push(SvgCommand::LineTo(cur_x, cur_y));
492                        i += 2;
493                    }
494                } else {
495                    i += 1;
496                }
497            }
498            "m" => {
499                if i + 2 < tokens.len() {
500                    cur_x += tokens[i + 1].parse::<f64>().unwrap_or(0.0);
501                    cur_y += tokens[i + 2].parse::<f64>().unwrap_or(0.0);
502                    start_x = cur_x;
503                    start_y = cur_y;
504                    commands.push(SvgCommand::MoveTo(cur_x, cur_y));
505                    i += 3;
506                    while i + 1 < tokens.len() && is_number(&tokens[i]) {
507                        cur_x += tokens[i].parse::<f64>().unwrap_or(0.0);
508                        cur_y += tokens[i + 1].parse::<f64>().unwrap_or(0.0);
509                        commands.push(SvgCommand::LineTo(cur_x, cur_y));
510                        i += 2;
511                    }
512                } else {
513                    i += 1;
514                }
515            }
516            "L" => {
517                i += 1;
518                while i + 1 < tokens.len() && is_number(&tokens[i]) {
519                    cur_x = tokens[i].parse().unwrap_or(0.0);
520                    cur_y = tokens[i + 1].parse().unwrap_or(0.0);
521                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
522                    i += 2;
523                }
524            }
525            "l" => {
526                i += 1;
527                while i + 1 < tokens.len() && is_number(&tokens[i]) {
528                    cur_x += tokens[i].parse::<f64>().unwrap_or(0.0);
529                    cur_y += tokens[i + 1].parse::<f64>().unwrap_or(0.0);
530                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
531                    i += 2;
532                }
533            }
534            "H" => {
535                i += 1;
536                while i < tokens.len() && is_number(&tokens[i]) {
537                    cur_x = tokens[i].parse().unwrap_or(0.0);
538                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
539                    i += 1;
540                }
541            }
542            "h" => {
543                i += 1;
544                while i < tokens.len() && is_number(&tokens[i]) {
545                    cur_x += tokens[i].parse::<f64>().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().unwrap_or(0.0);
554                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
555                    i += 1;
556                }
557            }
558            "v" => {
559                i += 1;
560                while i < tokens.len() && is_number(&tokens[i]) {
561                    cur_y += tokens[i].parse::<f64>().unwrap_or(0.0);
562                    commands.push(SvgCommand::LineTo(cur_x, cur_y));
563                    i += 1;
564                }
565            }
566            "C" => {
567                i += 1;
568                while i + 5 < tokens.len() && is_number(&tokens[i]) {
569                    let x1 = tokens[i].parse().unwrap_or(0.0);
570                    let y1 = tokens[i + 1].parse().unwrap_or(0.0);
571                    let x2 = tokens[i + 2].parse().unwrap_or(0.0);
572                    let y2 = tokens[i + 3].parse().unwrap_or(0.0);
573                    cur_x = tokens[i + 4].parse().unwrap_or(0.0);
574                    cur_y = tokens[i + 5].parse().unwrap_or(0.0);
575                    commands.push(SvgCommand::CurveTo(x1, y1, x2, y2, cur_x, cur_y));
576                    i += 6;
577                }
578            }
579            "c" => {
580                i += 1;
581                while i + 5 < tokens.len() && is_number(&tokens[i]) {
582                    let x1 = cur_x + tokens[i].parse::<f64>().unwrap_or(0.0);
583                    let y1 = cur_y + tokens[i + 1].parse::<f64>().unwrap_or(0.0);
584                    let x2 = cur_x + tokens[i + 2].parse::<f64>().unwrap_or(0.0);
585                    let y2 = cur_y + tokens[i + 3].parse::<f64>().unwrap_or(0.0);
586                    cur_x += tokens[i + 4].parse::<f64>().unwrap_or(0.0);
587                    cur_y += tokens[i + 5].parse::<f64>().unwrap_or(0.0);
588                    commands.push(SvgCommand::CurveTo(x1, y1, x2, y2, cur_x, cur_y));
589                    i += 6;
590                }
591            }
592            "Q" => {
593                i += 1;
594                while i + 3 < tokens.len() && is_number(&tokens[i]) {
595                    let qx = tokens[i].parse::<f64>().unwrap_or(0.0);
596                    let qy = tokens[i + 1].parse::<f64>().unwrap_or(0.0);
597                    let end_x = tokens[i + 2].parse::<f64>().unwrap_or(0.0);
598                    let end_y = tokens[i + 3].parse::<f64>().unwrap_or(0.0);
599                    // Convert quadratic to cubic
600                    let c1x = cur_x + (2.0 / 3.0) * (qx - cur_x);
601                    let c1y = cur_y + (2.0 / 3.0) * (qy - cur_y);
602                    let c2x = end_x + (2.0 / 3.0) * (qx - end_x);
603                    let c2y = end_y + (2.0 / 3.0) * (qy - end_y);
604                    cur_x = end_x;
605                    cur_y = end_y;
606                    commands.push(SvgCommand::CurveTo(c1x, c1y, c2x, c2y, cur_x, cur_y));
607                    i += 4;
608                }
609            }
610            "q" => {
611                i += 1;
612                while i + 3 < tokens.len() && is_number(&tokens[i]) {
613                    let qx = cur_x + tokens[i].parse::<f64>().unwrap_or(0.0);
614                    let qy = cur_y + tokens[i + 1].parse::<f64>().unwrap_or(0.0);
615                    let end_x = cur_x + tokens[i + 2].parse::<f64>().unwrap_or(0.0);
616                    let end_y = cur_y + tokens[i + 3].parse::<f64>().unwrap_or(0.0);
617                    let c1x = cur_x + (2.0 / 3.0) * (qx - cur_x);
618                    let c1y = cur_y + (2.0 / 3.0) * (qy - cur_y);
619                    let c2x = end_x + (2.0 / 3.0) * (qx - end_x);
620                    let c2y = end_y + (2.0 / 3.0) * (qy - end_y);
621                    cur_x = end_x;
622                    cur_y = end_y;
623                    commands.push(SvgCommand::CurveTo(c1x, c1y, c2x, c2y, cur_x, cur_y));
624                    i += 4;
625                }
626            }
627            "A" => {
628                i += 1;
629                while i + 6 < tokens.len() && is_number(&tokens[i]) {
630                    let rx = tokens[i].parse::<f64>().unwrap_or(0.0);
631                    let ry = tokens[i + 1].parse::<f64>().unwrap_or(0.0);
632                    let x_rotation = tokens[i + 2].parse::<f64>().unwrap_or(0.0);
633                    let large_arc = tokens[i + 3].parse::<f64>().unwrap_or(0.0) != 0.0;
634                    let sweep = tokens[i + 4].parse::<f64>().unwrap_or(0.0) != 0.0;
635                    let end_x = tokens[i + 5].parse::<f64>().unwrap_or(0.0);
636                    let end_y = tokens[i + 6].parse::<f64>().unwrap_or(0.0);
637                    commands.extend(svg_arc_to_curves(
638                        cur_x, cur_y, rx, ry, x_rotation, large_arc, sweep, end_x, end_y,
639                    ));
640                    cur_x = end_x;
641                    cur_y = end_y;
642                    i += 7;
643                }
644            }
645            "a" => {
646                i += 1;
647                while i + 6 < tokens.len() && is_number(&tokens[i]) {
648                    let rx = tokens[i].parse::<f64>().unwrap_or(0.0);
649                    let ry = tokens[i + 1].parse::<f64>().unwrap_or(0.0);
650                    let x_rotation = tokens[i + 2].parse::<f64>().unwrap_or(0.0);
651                    let large_arc = tokens[i + 3].parse::<f64>().unwrap_or(0.0) != 0.0;
652                    let sweep = tokens[i + 4].parse::<f64>().unwrap_or(0.0) != 0.0;
653                    let end_x = cur_x + tokens[i + 5].parse::<f64>().unwrap_or(0.0);
654                    let end_y = cur_y + tokens[i + 6].parse::<f64>().unwrap_or(0.0);
655                    commands.extend(svg_arc_to_curves(
656                        cur_x, cur_y, rx, ry, x_rotation, large_arc, sweep, end_x, end_y,
657                    ));
658                    cur_x = end_x;
659                    cur_y = end_y;
660                    i += 7;
661                }
662            }
663            "Z" | "z" => {
664                commands.push(SvgCommand::ClosePath);
665                cur_x = start_x;
666                cur_y = start_y;
667                i += 1;
668            }
669            _ => {
670                i += 1;
671            }
672        }
673    }
674
675    commands
676}
677
678/// Tokenize a path `d` string into commands and numbers.
679fn tokenize_path(d: &str) -> Vec<String> {
680    let mut tokens = Vec::new();
681    let mut current = String::new();
682
683    let chars: Vec<char> = d.chars().collect();
684    let mut i = 0;
685
686    while i < chars.len() {
687        let ch = chars[i];
688
689        if ch.is_alphabetic() {
690            if !current.is_empty() {
691                tokens.push(current.clone());
692                current.clear();
693            }
694            tokens.push(ch.to_string());
695            i += 1;
696        } else if ch == '-'
697            && !current.is_empty()
698            && !current.ends_with('e')
699            && !current.ends_with('E')
700        {
701            // Negative sign starts a new number (unless after exponent)
702            tokens.push(current.clone());
703            current.clear();
704            current.push(ch);
705            i += 1;
706        } else if ch.is_ascii_digit() || ch == '.' || ch == '-' || ch == '+' {
707            current.push(ch);
708            i += 1;
709        } else if ch == ',' || ch.is_whitespace() {
710            if !current.is_empty() {
711                tokens.push(current.clone());
712                current.clear();
713            }
714            i += 1;
715        } else {
716            i += 1;
717        }
718    }
719
720    if !current.is_empty() {
721        tokens.push(current);
722    }
723
724    tokens
725}
726
727fn is_number(s: &str) -> bool {
728    s.parse::<f64>().is_ok()
729}
730
731/// Parse an SVG color string (hex, named colors).
732fn parse_svg_color(s: &str) -> Option<(f64, f64, f64)> {
733    let s = s.trim();
734    if let Some(hex) = s.strip_prefix('#') {
735        match hex.len() {
736            3 => {
737                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()? as f64 / 255.0;
738                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()? as f64 / 255.0;
739                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()? as f64 / 255.0;
740                Some((r, g, b))
741            }
742            6 => {
743                let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f64 / 255.0;
744                let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f64 / 255.0;
745                let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f64 / 255.0;
746                Some((r, g, b))
747            }
748            _ => None,
749        }
750    } else if s.starts_with("rgb(") {
751        let inner = s.trim_start_matches("rgb(").trim_end_matches(')');
752        let parts: Vec<&str> = inner.split(',').collect();
753        if parts.len() == 3 {
754            let r = parts[0].trim().parse::<f64>().ok()? / 255.0;
755            let g = parts[1].trim().parse::<f64>().ok()? / 255.0;
756            let b = parts[2].trim().parse::<f64>().ok()? / 255.0;
757            Some((r, g, b))
758        } else {
759            None
760        }
761    } else {
762        // Named colors
763        match s.to_lowercase().as_str() {
764            "black" => Some((0.0, 0.0, 0.0)),
765            "white" => Some((1.0, 1.0, 1.0)),
766            "red" => Some((1.0, 0.0, 0.0)),
767            "green" => Some((0.0, 0.502, 0.0)),
768            "blue" => Some((0.0, 0.0, 1.0)),
769            "yellow" => Some((1.0, 1.0, 0.0)),
770            "gray" | "grey" => Some((0.502, 0.502, 0.502)),
771            "orange" => Some((1.0, 0.647, 0.0)),
772            "purple" => Some((0.502, 0.0, 0.502)),
773            "cyan" => Some((0.0, 1.0, 1.0)),
774            "magenta" => Some((1.0, 0.0, 1.0)),
775            _ => None,
776        }
777    }
778}
779
780/// Parse SVG points attribute (e.g., "10,20 30,40").
781fn parse_points(s: &str) -> Vec<(f64, f64)> {
782    let nums: Vec<f64> = s
783        .split(|c: char| c == ',' || c.is_whitespace())
784        .filter(|s| !s.is_empty())
785        .filter_map(|s| s.parse::<f64>().ok())
786        .collect();
787
788    nums.chunks(2)
789        .filter(|c| c.len() == 2)
790        .map(|c| (c[0], c[1]))
791        .collect()
792}
793
794/// Helper to get an attribute value from a quick-xml BytesStart.
795fn get_attr(e: &quick_xml::events::BytesStart, name: &str) -> Option<String> {
796    for attr in e.attributes().flatten() {
797        if attr.key.as_ref() == name.as_bytes() {
798            return String::from_utf8(attr.value.to_vec()).ok();
799        }
800    }
801    None
802}
803
804fn get_attr_f64(e: &quick_xml::events::BytesStart, name: &str) -> Option<f64> {
805    get_attr(e, name).and_then(|s| s.parse::<f64>().ok())
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn test_parse_view_box() {
814        let vb = parse_view_box("0 0 100 200").unwrap();
815        assert!((vb.min_x - 0.0).abs() < 0.001);
816        assert!((vb.width - 100.0).abs() < 0.001);
817        assert!((vb.height - 200.0).abs() < 0.001);
818    }
819
820    #[test]
821    fn test_parse_view_box_invalid() {
822        assert!(parse_view_box("bad").is_none());
823    }
824
825    #[test]
826    fn test_parse_rect() {
827        let cmds = parse_svg(
828            r##"<rect x="10" y="20" width="100" height="50" fill="#ff0000"/>"##,
829            ViewBox {
830                min_x: 0.0,
831                min_y: 0.0,
832                width: 200.0,
833                height: 200.0,
834            },
835            200.0,
836            200.0,
837        );
838        assert!(!cmds.is_empty());
839        // Should have SaveState, SetFill, MoveTo, LineTo..., Fill, RestoreState
840        assert!(cmds
841            .iter()
842            .any(|c| matches!(c, SvgCommand::SetFill(r, _, _) if (*r - 1.0).abs() < 0.01)));
843    }
844
845    #[test]
846    fn test_parse_circle() {
847        let cmds = parse_svg(
848            r#"<circle cx="50" cy="50" r="25" fill="blue"/>"#,
849            ViewBox {
850                min_x: 0.0,
851                min_y: 0.0,
852                width: 100.0,
853                height: 100.0,
854            },
855            100.0,
856            100.0,
857        );
858        assert!(!cmds.is_empty());
859        assert!(cmds.iter().any(|c| matches!(c, SvgCommand::CurveTo(..))));
860    }
861
862    #[test]
863    fn test_parse_path_m_l_z() {
864        let cmds = parse_path_d("M 10 20 L 30 40 Z");
865        assert!(
866            matches!(cmds[0], SvgCommand::MoveTo(x, y) if (x - 10.0).abs() < 0.001 && (y - 20.0).abs() < 0.001)
867        );
868        assert!(
869            matches!(cmds[1], SvgCommand::LineTo(x, y) if (x - 30.0).abs() < 0.001 && (y - 40.0).abs() < 0.001)
870        );
871        assert!(matches!(cmds[2], SvgCommand::ClosePath));
872    }
873
874    #[test]
875    fn test_parse_path_relative() {
876        let cmds = parse_path_d("m 10 20 l 5 5 z");
877        assert!(
878            matches!(cmds[0], SvgCommand::MoveTo(x, y) if (x - 10.0).abs() < 0.001 && (y - 20.0).abs() < 0.001)
879        );
880        assert!(
881            matches!(cmds[1], SvgCommand::LineTo(x, y) if (x - 15.0).abs() < 0.001 && (y - 25.0).abs() < 0.001)
882        );
883    }
884
885    #[test]
886    fn test_parse_hex_color() {
887        let (r, g, b) = parse_svg_color("#ff0000").unwrap();
888        assert!((r - 1.0).abs() < 0.01);
889        assert!((g - 0.0).abs() < 0.01);
890        assert!((b - 0.0).abs() < 0.01);
891    }
892
893    #[test]
894    fn test_parse_line() {
895        let cmds = parse_svg(
896            r#"<line x1="0" y1="0" x2="100" y2="100" stroke="black"/>"#,
897            ViewBox {
898                min_x: 0.0,
899                min_y: 0.0,
900                width: 100.0,
901                height: 100.0,
902            },
903            100.0,
904            100.0,
905        );
906        assert!(!cmds.is_empty());
907        assert!(cmds.iter().any(|c| matches!(c, SvgCommand::Stroke)));
908    }
909
910    #[test]
911    fn test_empty_svg() {
912        let cmds = parse_svg(
913            "",
914            ViewBox {
915                min_x: 0.0,
916                min_y: 0.0,
917                width: 100.0,
918                height: 100.0,
919            },
920            100.0,
921            100.0,
922        );
923        assert!(cmds.is_empty());
924    }
925}