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