Skip to main content

dotloom_io/
svg.rs

1//! SVG import and export.
2//!
3//! **Export** writes world geometry (model Y up → SVG Y down) in millimetres, one
4//! `<g>` per layer, styles as attributes, and reports what SVG cannot carry
5//! (constraints, plugin parameters, associative dimensions).
6//!
7//! **Import** supports `svg`, `g`, `line`, `rect`, `circle`, `ellipse`, `polyline`,
8//! `polygon`, `path` (all commands; elliptical arcs become Béziers) and `text`, with
9//! nested `transform`s, `viewBox` and absolute size units, and `stroke`/`fill`/
10//! `stroke-width` presentation attributes or inline `style`. DTDs are rejected;
11//! `script`, `style`, `use`, `image`, `foreignObject`, gradients, filters, masks and
12//! clip paths are skipped and reported; `href`s are never followed.
13
14use core::f64::consts::{PI, TAU};
15use std::fmt::Write as _;
16
17use dotloom_document::{Color, LayerId, Style};
18use dotloom_engine::{Engine, NewEntity, document::builtin::is_builtin, scene::Primitive};
19use dotloom_geometry::{
20    Affine, Circle, CubicBez, HAlign, Path, PathEl, Point, Polyline, Rect, Segment, Shape, Text, TransformPolicy,
21    VAlign, Vector, arc_to_cubics,
22};
23use thiserror::Error;
24
25use crate::report::{ConversionReport, LossKind, Recorder};
26
27/// SVG errors.
28#[derive(Debug, Clone, PartialEq, Eq, Error)]
29pub enum SvgError {
30    /// XML is malformed, uses a DTD or exceeds limits.
31    #[error("invalid svg: {0}")]
32    Xml(String),
33    /// Not an SVG document.
34    #[error("root element is not <svg>")]
35    NotSvg,
36    /// Input too large.
37    #[error("svg input exceeds {0} bytes")]
38    TooLarge(usize),
39}
40
41/// Export options.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SvgExportOptions {
44    /// Margin around the drawing (mm).
45    pub margin: f64,
46    /// Color used for theme-default strokes and text.
47    pub foreground: Color,
48    /// Optional background rectangle.
49    pub background: Option<Color>,
50}
51
52impl Default for SvgExportOptions {
53    fn default() -> Self {
54        Self { margin: 10.0, foreground: Color::BLACK, background: None }
55    }
56}
57
58fn num(v: f64) -> String {
59    let s = format!("{v:.6}");
60    let t = s.trim_end_matches('0').trim_end_matches('.');
61    if t == "-0" || t.is_empty() { "0".into() } else { t.to_owned() }
62}
63
64fn esc(s: &str) -> String {
65    let mut o = String::with_capacity(s.len());
66    for c in s.chars() {
67        match c {
68            '&' => o.push_str("&amp;"),
69            '<' => o.push_str("&lt;"),
70            '>' => o.push_str("&gt;"),
71            '"' => o.push_str("&quot;"),
72            '\'' => o.push_str("&apos;"),
73            c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
74            c => o.push(c),
75        }
76    }
77    o
78}
79
80fn color(c: u32, fg: Color) -> (String, Option<f64>) {
81    let c = if c == 0 { fg.0 } else { c };
82    let [r, g, b, a] = c.to_be_bytes();
83    (format!("#{r:02x}{g:02x}{b:02x}"), (a != 0xff).then(|| f64::from(a) / 255.0))
84}
85
86fn p(pt: Point) -> String {
87    format!("{} {}", num(pt.x), num(-pt.y))
88}
89
90fn arc_path(a: &dotloom_geometry::Arc, start_move: bool) -> String {
91    let mut d = String::new();
92    // Split full circles (and sweeps ≥ 2π) into two halves.
93    let pieces = if a.sweep.abs() >= TAU - 1e-12 { 2 } else { 1 };
94    let step = a.sweep / f64::from(pieces);
95    if start_move {
96        let _ = write!(d, "M{}", p(a.start_point()));
97    }
98    for k in 0..pieces {
99        let sub = a.subarc(f64::from(k) / f64::from(pieces), f64::from(k + 1) / f64::from(pieces));
100        let large = u8::from(step.abs() > PI);
101        // Model CCW (sweep > 0) becomes clockwise on the Y-down canvas: sweep-flag 1.
102        let sweep_flag = u8::from(step > 0.0);
103        let _ = write!(d, " A{} {} 0 {} {} {}", num(a.radius), num(a.radius), large, sweep_flag, p(sub.end_point()));
104    }
105    d
106}
107
108fn shape_element(s: &Shape) -> Option<(String, String)> {
109    Some(match s {
110        Shape::Point(pt) => ("circle".into(), format!(r#"cx="{}" cy="{}" r="0.5""#, num(pt.at.x), num(-pt.at.y))),
111        Shape::Line(l) => (
112            "line".into(),
113            format!(r#"x1="{}" y1="{}" x2="{}" y2="{}""#, num(l.a.x), num(-l.a.y), num(l.b.x), num(-l.b.y)),
114        ),
115        Shape::Rect(r) => (
116            "rect".into(),
117            format!(
118                r#"x="{}" y="{}" width="{}" height="{}""#,
119                num(r.origin.x),
120                num(-(r.origin.y + r.height)),
121                num(r.width),
122                num(r.height)
123            ),
124        ),
125        Shape::Circle(c) => {
126            ("circle".into(), format!(r#"cx="{}" cy="{}" r="{}""#, num(c.center.x), num(-c.center.y), num(c.radius)))
127        }
128        Shape::Arc(a) => ("path".into(), format!(r#"d="{}""#, arc_path(a, true))),
129        Shape::Polyline(pl) if !pl.has_arcs() => {
130            let pts: Vec<String> = pl.points.iter().map(|q| format!("{},{}", num(q.x), num(-q.y))).collect();
131            (if pl.closed { "polygon" } else { "polyline" }.into(), format!(r#"points="{}""#, pts.join(" ")))
132        }
133        Shape::Polyline(pl) => {
134            let mut d = String::new();
135            if let Some(f) = pl.points.first() {
136                let _ = write!(d, "M{}", p(*f));
137            }
138            for i in 0..pl.segment_count() {
139                match pl.segment(i)? {
140                    dotloom_geometry::Curve::Arc(a) => d.push_str(&arc_path(&a, false)),
141                    c => {
142                        let _ = write!(d, " L{}", p(c.end()));
143                    }
144                }
145            }
146            if pl.closed {
147                d.push_str(" Z");
148            }
149            ("path".into(), format!(r#"d="{d}""#))
150        }
151        Shape::Polygon(pg) => {
152            let mut d = String::new();
153            for ring in core::iter::once(&pg.outer).chain(pg.holes.iter()) {
154                for (i, q) in ring.iter().enumerate() {
155                    let _ = write!(d, "{}{}", if i == 0 { " M" } else { " L" }, p(*q));
156                }
157                d.push_str(" Z");
158            }
159            ("path".into(), format!(r#"d="{}" fill-rule="evenodd""#, d.trim()))
160        }
161        Shape::Path(path) => {
162            let mut d = String::new();
163            for el in &path.elements {
164                let _ = match *el {
165                    PathEl::MoveTo(a) => write!(d, " M{}", p(a)),
166                    PathEl::LineTo(a) => write!(d, " L{}", p(a)),
167                    PathEl::QuadTo(a, b) => write!(d, " Q{} {}", p(a), p(b)),
168                    PathEl::CubicTo(a, b, c) => write!(d, " C{} {} {}", p(a), p(b), p(c)),
169                    PathEl::Close => write!(d, " Z"),
170                };
171            }
172            ("path".into(), format!(r#"d="{}""#, d.trim()))
173        }
174        Shape::Text(_) => return None,
175    })
176}
177
178fn text_element(t: &Text, fill: &str) -> String {
179    let anchor = match t.halign {
180        HAlign::Left => "start",
181        HAlign::Center => "middle",
182        HAlign::Right => "end",
183    };
184    let baseline = match t.valign {
185        VAlign::Baseline => "alphabetic",
186        VAlign::Middle => "middle",
187        VAlign::Top => "hanging",
188        VAlign::Bottom => "text-after-edge",
189    };
190    let (x, y) = (num(t.position.x), num(-t.position.y));
191    let rot = if t.rotation == 0.0 {
192        String::new()
193    } else {
194        format!(r#" transform="rotate({} {x} {y})""#, num(-t.rotation.to_degrees()))
195    };
196    let lines: Vec<&str> = t.content.split('\n').collect();
197    let body = if lines.len() == 1 {
198        esc(&t.content)
199    } else {
200        lines
201            .iter()
202            .enumerate()
203            .map(|(i, l)| {
204                format!(
205                    r#"<tspan x="{x}" dy="{}">{}</tspan>"#,
206                    if i == 0 { "0".into() } else { num(t.height * Text::LINE_SPACING) },
207                    esc(l)
208                )
209            })
210            .collect()
211    };
212    format!(
213        r#"<text x="{x}" y="{y}" font-size="{}" font-family="Noto Sans, sans-serif" text-anchor="{anchor}" dominant-baseline="{baseline}" fill="{fill}"{rot}>{body}</text>"#,
214        num(t.height)
215    )
216}
217
218/// Export the engine's committed document as SVG.
219pub fn export_svg(engine: &mut Engine, opts: &SvgExportOptions) -> (String, ConversionReport) {
220    let mut rec = Recorder::default();
221    let scene = engine.full_scene();
222    let doc = engine.document();
223    let constraints = doc.constraint_count();
224    if constraints > 0 {
225        for _ in 0..constraints {
226            rec.add(LossKind::Constraints, "constraint");
227        }
228    }
229    let bbox = scene.upserts.iter().fold(dotloom_geometry::Aabb::EMPTY, |b, i| b.union(i.bbox));
230    let bbox = if bbox.is_empty() {
231        dotloom_geometry::Aabb::from_corners(Point::ORIGIN, Point::new(100.0, 100.0))
232    } else {
233        bbox
234    };
235    let m = opts.margin.max(0.0);
236    let (x0, y0) = (bbox.min.x - m, -(bbox.max.y + m));
237    let (w, h) = (bbox.width() + 2.0 * m, bbox.height() + 2.0 * m);
238    let mut out = String::new();
239    let _ = write!(
240        out,
241        r#"<?xml version="1.0" encoding="UTF-8"?>
242<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="{}mm" height="{}mm" viewBox="{} {} {} {}">
243"#,
244        num(w),
245        num(h),
246        num(x0),
247        num(y0),
248        num(w),
249        num(h)
250    );
251    if let Some(bg) = opts.background {
252        let (c, _) = color(bg.0, opts.foreground);
253        let _ = writeln!(
254            out,
255            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{c}"/>"#,
256            num(x0),
257            num(y0),
258            num(w),
259            num(h)
260        );
261    }
262    let layers: Vec<(LayerId, String)> = doc.layers().iter().map(|l| (l.id, l.name.clone())).collect();
263    let order: Vec<u64> = scene.order.clone().unwrap_or_default();
264    for (li, (lid, name)) in layers.iter().enumerate() {
265        let items: Vec<&dotloom_engine::scene::SceneItem> = order
266            .iter()
267            .filter_map(|id| scene.upserts.iter().find(|i| i.id == *id))
268            .filter(|i| i.layer as usize == li)
269            .collect();
270        if items.is_empty() {
271            continue;
272        }
273        let _ = writeln!(out, r#"<g id="layer-{}" inkscape:groupmode="layer" inkscape:label="{}">"#, lid.0, esc(name));
274        for item in items {
275            let Some(e) = doc.entity(dotloom_document::EntityId(item.id)) else { continue };
276            rec.entities += 1;
277            if !is_builtin(&e.type_id) {
278                rec.add(
279                    if item.flags & dotloom_engine::scene::flags::READONLY != 0 {
280                        LossKind::Fallback
281                    } else {
282                        LossKind::PluginGeometry
283                    },
284                    e.type_id.to_string(),
285                );
286            } else if e.type_id.as_str() == dotloom_document::builtin::types::DIMENSION {
287                rec.add(LossKind::Dimensions, "dimension");
288            }
289            let _ =
290                writeln!(out, r#"<g data-dotloom-id="{}" data-dotloom-type="{}">"#, item.id, esc(e.type_id.as_str()));
291            for prim in &item.prims {
292                match prim {
293                    Primitive::Shape { shape, stroke, fill } => {
294                        let Some((tag, geom)) = shape_element(shape) else { continue };
295                        let mut attrs = geom;
296                        match fill {
297                            Some(f) => {
298                                let (c, a) = color(*f, opts.foreground);
299                                let _ = write!(attrs, r#" fill="{c}""#);
300                                if let Some(a) = a {
301                                    let _ = write!(attrs, r#" fill-opacity="{}""#, num(a));
302                                }
303                            }
304                            None => attrs.push_str(r#" fill="none""#),
305                        }
306                        match stroke {
307                            Some(s) => {
308                                let (c, a) = color(s.color, opts.foreground);
309                                let _ = write!(
310                                    attrs,
311                                    r#" stroke="{c}" stroke-width="{}" vector-effect="non-scaling-stroke""#,
312                                    num(f64::from(s.width))
313                                );
314                                if let Some(a) = a {
315                                    let _ = write!(attrs, r#" stroke-opacity="{}""#, num(a));
316                                }
317                                if !s.dash.is_empty() {
318                                    let d: Vec<String> = s.dash.iter().map(|x| num(f64::from(*x))).collect();
319                                    let _ = write!(attrs, r#" stroke-dasharray="{}""#, d.join(" "));
320                                }
321                            }
322                            None => attrs.push_str(r#" stroke="none""#),
323                        }
324                        let _ = writeln!(out, "<{tag} {attrs}/>");
325                    }
326                    Primitive::Text { text, color: c } => {
327                        let (c, _) = color(*c, opts.foreground);
328                        let _ = writeln!(out, "{}", text_element(text, &c));
329                    }
330                    Primitive::Arrow { tip, direction, size, color: c } => {
331                        let (c, _) = color(*c, opts.foreground);
332                        let back = *tip - *direction * *size;
333                        let n = direction.perp() * (*size * 0.3);
334                        let _ = writeln!(
335                            out,
336                            r#"<polygon points="{},{} {},{} {},{}" fill="{c}"/>"#,
337                            num(tip.x),
338                            num(-tip.y),
339                            num((back + n).x),
340                            num(-(back + n).y),
341                            num((back - n).x),
342                            num(-(back - n).y)
343                        );
344                    }
345                }
346            }
347            out.push_str("</g>\n");
348        }
349        out.push_str("</g>\n");
350    }
351    out.push_str("</svg>\n");
352    rec.notes.push("units: millimetres; model Y axis flipped to SVG Y-down".into());
353    (out, rec.finish())
354}
355
356// ---------------------------------------------------------------------------
357// Import
358
359/// Result of an SVG import: layers and entities ready for `Command::CreateEntity`.
360#[derive(Debug, Clone, PartialEq)]
361pub struct SvgImport {
362    /// Layer names (top-level groups), first entry is the default layer.
363    pub layers: Vec<String>,
364    /// `(layer index, entity)`.
365    pub entities: Vec<(usize, NewEntity)>,
366    /// Report.
367    pub report: ConversionReport,
368}
369
370/// Maximum SVG input size.
371pub const MAX_SVG_BYTES: usize = 64 << 20;
372
373#[derive(Debug, Clone, Default)]
374struct Paint {
375    stroke: Option<Option<Color>>,
376    fill: Option<Option<Color>>,
377    width: Option<f64>,
378    hidden: bool,
379}
380
381fn parse_color(s: &str, rec: &mut Recorder) -> Option<Option<Color>> {
382    let s = s.trim();
383    if s.is_empty() || s == "inherit" {
384        return None;
385    }
386    if s == "none" || s == "transparent" {
387        return Some(None);
388    }
389    if s.starts_with('#') {
390        return match Color::parse(s) {
391            Ok(c) => Some(Some(c)),
392            Err(_) => {
393                rec.add(LossKind::Style, "color");
394                None
395            }
396        };
397    }
398    if let Some(inner) = s.strip_prefix("rgb(").and_then(|x| x.strip_suffix(')')) {
399        let v: Vec<u8> = inner
400            .split(',')
401            .filter_map(|c| c.trim().trim_end_matches('%').parse::<f64>().ok())
402            .map(|c| c.clamp(0.0, 255.0) as u8)
403            .collect();
404        if let [r, g, b] = v.as_slice() {
405            return Some(Some(Color::rgba(*r, *g, *b, 255)));
406        }
407    }
408    let named = match s {
409        "black" => Some(Color::rgba(0, 0, 0, 255)),
410        "white" => Some(Color::rgba(255, 255, 255, 255)),
411        "red" => Some(Color::rgba(255, 0, 0, 255)),
412        "green" => Some(Color::rgba(0, 128, 0, 255)),
413        "blue" => Some(Color::rgba(0, 0, 255, 255)),
414        "gray" | "grey" => Some(Color::rgba(128, 128, 128, 255)),
415        "yellow" => Some(Color::rgba(255, 255, 0, 255)),
416        "orange" => Some(Color::rgba(255, 165, 0, 255)),
417        _ => None,
418    };
419    if named.is_none() {
420        rec.add(LossKind::Style, if s.starts_with("url(") { "paint server (gradient/pattern)" } else { "color" });
421    }
422    named.map(Some)
423}
424
425fn length_mm(s: &str) -> Option<f64> {
426    let s = s.trim();
427    let split = s.find(|c: char| c.is_ascii_alphabetic() || c == '%').unwrap_or(s.len());
428    let (n, u) = s.split_at(split);
429    let v: f64 = n.trim().parse().ok()?;
430    let mm = match u.trim() {
431        "" | "px" => v * 25.4 / 96.0,
432        "mm" => v,
433        "cm" => v * 10.0,
434        "in" => v * 25.4,
435        "pt" => v * 25.4 / 72.0,
436        "pc" => v * 25.4 / 6.0,
437        _ => return None,
438    };
439    mm.is_finite().then_some(mm)
440}
441
442fn numbers(s: &str) -> Vec<f64> {
443    let mut out = Vec::new();
444    let b = s.as_bytes();
445    let mut i = 0;
446    while i < b.len() {
447        let c = b[i];
448        if c == b'-' || c == b'+' || c == b'.' || c.is_ascii_digit() {
449            let start = i;
450            i += 1;
451            let mut seen_dot = c == b'.';
452            let mut seen_e = false;
453            while i < b.len() {
454                let d = b[i];
455                if d.is_ascii_digit() {
456                    i += 1;
457                } else if d == b'.' && !seen_dot && !seen_e {
458                    seen_dot = true;
459                    i += 1;
460                } else if (d == b'e' || d == b'E') && !seen_e {
461                    seen_e = true;
462                    i += 1;
463                    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
464                        i += 1;
465                    }
466                } else {
467                    break;
468                }
469            }
470            if let Ok(v) = s[start..i].parse::<f64>()
471                && v.is_finite()
472            {
473                out.push(v);
474            }
475        } else {
476            i += 1;
477        }
478    }
479    out
480}
481
482fn parse_transform(s: &str) -> Affine {
483    let mut t = Affine::IDENTITY;
484    let mut rest = s;
485    // SVG lists apply right-to-left: "A B" means A(B(x)); compose in reading order.
486    let mut list = Vec::new();
487    while let Some(open) = rest.find('(') {
488        let name = rest[..open].trim().trim_start_matches(',').trim();
489        let Some(close) = rest[open..].find(')') else { break };
490        let args = numbers(&rest[open + 1..open + close]);
491        let m = match (name, args.as_slice()) {
492            ("matrix", [a, b, c, d, e, f]) => Affine { m: [*a, *b, *c, *d, *e, *f] },
493            ("translate", [x]) => Affine::translate(Vector::new(*x, 0.0)),
494            ("translate", [x, y]) => Affine::translate(Vector::new(*x, *y)),
495            ("scale", [s]) => Affine::scale(*s, *s),
496            ("scale", [x, y]) => Affine::scale(*x, *y),
497            ("rotate", [a]) => Affine::rotate(a.to_radians()),
498            ("rotate", [a, cx, cy]) => Affine::rotate_about(a.to_radians(), Point::new(*cx, *cy)),
499            ("skewX", [a]) => Affine { m: [1.0, 0.0, dotloom_geometry::math::tan(a.to_radians()), 1.0, 0.0, 0.0] },
500            ("skewY", [a]) => Affine { m: [1.0, dotloom_geometry::math::tan(a.to_radians()), 0.0, 1.0, 0.0, 0.0] },
501            _ => Affine::IDENTITY,
502        };
503        list.push(m);
504        rest = &rest[open + close + 1..];
505    }
506    for m in list.into_iter().rev() {
507        t = t.then(m);
508    }
509    t
510}
511
512fn style_map(node: &roxmltree::Node<'_, '_>) -> Vec<(String, String)> {
513    let mut v: Vec<(String, String)> = Vec::new();
514    for key in ["stroke", "fill", "stroke-width", "display", "visibility"] {
515        if let Some(val) = node.attribute(key) {
516            v.push((key.into(), val.into()));
517        }
518    }
519    if let Some(st) = node.attribute("style") {
520        for decl in st.split(';') {
521            if let Some((k, val)) = decl.split_once(':') {
522                v.push((k.trim().to_owned(), val.trim().to_owned()));
523            }
524        }
525    }
526    v
527}
528
529struct Importer<'r> {
530    rec: &'r mut Recorder,
531    out: Vec<(usize, NewEntity)>,
532    layers: Vec<String>,
533    unit_scale: f64,
534    elements: usize,
535}
536
537fn ellipse_path(c: Point, rx: f64, ry: f64) -> Option<Path> {
538    let arc = dotloom_geometry::Arc::new(Point::ORIGIN, 1.0, 0.0, TAU).ok()?;
539    let mut elements = Vec::new();
540    let s = Affine::scale(rx, ry).then(Affine::translate(c.to_vector()));
541    for (i, cv) in arc_to_cubics(arc).into_iter().enumerate() {
542        if let dotloom_geometry::Curve::Cubic(b) = cv {
543            let b = b.transform(s);
544            if i == 0 {
545                elements.push(PathEl::MoveTo(b.p0));
546            }
547            elements.push(PathEl::CubicTo(b.p1, b.p2, b.p3));
548        }
549    }
550    elements.push(PathEl::Close);
551    Some(Path { elements })
552}
553
554/// SVG elliptical arc (endpoint form) to cubic Béziers.
555#[allow(clippy::too_many_arguments)]
556fn svg_arc(p0: Point, rx: f64, ry: f64, phi_deg: f64, large: bool, sweep: bool, p1: Point, out: &mut Vec<PathEl>) {
557    if p0 == p1 {
558        return;
559    }
560    let (mut rx, mut ry) = (rx.abs(), ry.abs());
561    if rx == 0.0 || ry == 0.0 {
562        out.push(PathEl::LineTo(p1));
563        return;
564    }
565    let phi = phi_deg.to_radians();
566    let (s, c) = dotloom_geometry::math::sin_cos(phi);
567    let dx = (p0.x - p1.x) / 2.0;
568    let dy = (p0.y - p1.y) / 2.0;
569    let x1 = c * dx + s * dy;
570    let y1 = -s * dx + c * dy;
571    let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
572    if lambda > 1.0 {
573        let k = lambda.sqrt();
574        rx *= k;
575        ry *= k;
576    }
577    let num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
578    let den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
579    let mut coef = if den > 0.0 { (num / den).max(0.0).sqrt() } else { 0.0 };
580    if large == sweep {
581        coef = -coef;
582    }
583    let cx1 = coef * (rx * y1 / ry);
584    let cy1 = coef * (-ry * x1 / rx);
585    let cx = c * cx1 - s * cy1 + (p0.x + p1.x) / 2.0;
586    let cy = s * cx1 + c * cy1 + (p0.y + p1.y) / 2.0;
587    let ang = |ux: f64, uy: f64, vx: f64, vy: f64| dotloom_geometry::math::atan2(ux * vy - uy * vx, ux * vx + uy * vy);
588    let th1 = ang(1.0, 0.0, (x1 - cx1) / rx, (y1 - cy1) / ry);
589    let mut dth = ang((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry);
590    if !sweep && dth > 0.0 {
591        dth -= TAU;
592    } else if sweep && dth < 0.0 {
593        dth += TAU;
594    }
595    let Ok(unit) = dotloom_geometry::Arc::new(Point::ORIGIN, 1.0, th1, dth) else {
596        out.push(PathEl::LineTo(p1));
597        return;
598    };
599    let t = Affine::scale(rx, ry).then(Affine::rotate(phi)).then(Affine::translate(Vector::new(cx, cy)));
600    for cv in arc_to_cubics(unit) {
601        if let dotloom_geometry::Curve::Cubic(b) = cv {
602            let b: CubicBez = b.transform(t);
603            out.push(PathEl::CubicTo(b.p1, b.p2, b.p3));
604        }
605    }
606    if let Some(PathEl::CubicTo(_, _, last)) = out.last_mut() {
607        *last = p1;
608    }
609}
610
611fn parse_path(d: &str, rec: &mut Recorder) -> Option<Path> {
612    let mut els: Vec<PathEl> = Vec::new();
613    let b = d.as_bytes();
614    let mut i = 0;
615    let mut cmd = b'M';
616    let mut cur = Point::ORIGIN;
617    let mut start = Point::ORIGIN;
618    let mut last_ctrl: Option<Point> = None;
619    let mut last_q: Option<Point> = None;
620    let mut arcs = false;
621    let mut steps = 0usize;
622    loop {
623        while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b',') {
624            i += 1;
625        }
626        if i >= b.len() {
627            break;
628        }
629        steps += 1;
630        if steps > 2_000_000 {
631            return None;
632        }
633        if b[i].is_ascii_alphabetic() {
634            cmd = b[i];
635            i += 1;
636            if cmd == b'Z' || cmd == b'z' {
637                els.push(PathEl::Close);
638                cur = start;
639                last_ctrl = None;
640                last_q = None;
641                continue;
642            }
643        }
644        let rel = cmd.is_ascii_lowercase();
645        let arity = match cmd.to_ascii_uppercase() {
646            b'M' | b'L' | b'T' => 2,
647            b'H' | b'V' => 1,
648            b'C' => 6,
649            b'S' | b'Q' => 4,
650            b'A' => 7,
651            _ => return None,
652        };
653        // Read `arity` numbers (arc flags may be written without separators).
654        let mut vals = Vec::with_capacity(arity);
655        while vals.len() < arity {
656            while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b',') {
657                i += 1;
658            }
659            if i >= b.len() {
660                break;
661            }
662            if cmd.eq_ignore_ascii_case(&b'A') && (vals.len() == 3 || vals.len() == 4) && (b[i] == b'0' || b[i] == b'1')
663            {
664                vals.push(f64::from(b[i] - b'0'));
665                i += 1;
666                continue;
667            }
668            let s = i;
669            if b[i] == b'-' || b[i] == b'+' {
670                i += 1;
671            }
672            let mut dot = false;
673            let mut exp = false;
674            while i < b.len() {
675                let c = b[i];
676                if c.is_ascii_digit() {
677                    i += 1;
678                } else if c == b'.' && !dot && !exp {
679                    dot = true;
680                    i += 1;
681                } else if (c == b'e' || c == b'E') && !exp {
682                    exp = true;
683                    i += 1;
684                    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
685                        i += 1;
686                    }
687                } else {
688                    break;
689                }
690            }
691            let v: f64 = d.get(s..i)?.parse().ok()?;
692            if !v.is_finite() {
693                return None;
694            }
695            vals.push(v);
696        }
697        if vals.len() < arity {
698            break;
699        }
700        let pt = |x: f64, y: f64| if rel { Point::new(cur.x + x, cur.y + y) } else { Point::new(x, y) };
701        match cmd.to_ascii_uppercase() {
702            b'M' => {
703                cur = pt(vals[0], vals[1]);
704                start = cur;
705                els.push(PathEl::MoveTo(cur));
706                cmd = if rel { b'l' } else { b'L' };
707                last_ctrl = None;
708                last_q = None;
709            }
710            b'L' => {
711                cur = pt(vals[0], vals[1]);
712                els.push(PathEl::LineTo(cur));
713                last_ctrl = None;
714                last_q = None;
715            }
716            b'H' => {
717                cur = Point::new(if rel { cur.x + vals[0] } else { vals[0] }, cur.y);
718                els.push(PathEl::LineTo(cur));
719                last_ctrl = None;
720                last_q = None;
721            }
722            b'V' => {
723                cur = Point::new(cur.x, if rel { cur.y + vals[0] } else { vals[0] });
724                els.push(PathEl::LineTo(cur));
725                last_ctrl = None;
726                last_q = None;
727            }
728            b'C' => {
729                let (c1, c2, e) = (pt(vals[0], vals[1]), pt(vals[2], vals[3]), pt(vals[4], vals[5]));
730                els.push(PathEl::CubicTo(c1, c2, e));
731                last_ctrl = Some(c2);
732                last_q = None;
733                cur = e;
734            }
735            b'S' => {
736                let c1 = last_ctrl.map_or(cur, |c| cur + (cur - c));
737                let (c2, e) = (pt(vals[0], vals[1]), pt(vals[2], vals[3]));
738                els.push(PathEl::CubicTo(c1, c2, e));
739                last_ctrl = Some(c2);
740                last_q = None;
741                cur = e;
742            }
743            b'Q' => {
744                let (c, e) = (pt(vals[0], vals[1]), pt(vals[2], vals[3]));
745                els.push(PathEl::QuadTo(c, e));
746                last_q = Some(c);
747                last_ctrl = None;
748                cur = e;
749            }
750            b'T' => {
751                let c = last_q.map_or(cur, |q| cur + (cur - q));
752                let e = pt(vals[0], vals[1]);
753                els.push(PathEl::QuadTo(c, e));
754                last_q = Some(c);
755                last_ctrl = None;
756                cur = e;
757            }
758            b'A' => {
759                let e = pt(vals[5], vals[6]);
760                svg_arc(cur, vals[0], vals[1], vals[2], vals[3] != 0.0, vals[4] != 0.0, e, &mut els);
761                arcs = true;
762                cur = e;
763                last_ctrl = None;
764                last_q = None;
765            }
766            _ => return None,
767        }
768    }
769    if arcs {
770        rec.add(LossKind::Approximated, "elliptical arc → Bézier");
771    }
772    if !matches!(els.first(), Some(PathEl::MoveTo(_))) {
773        return None;
774    }
775    Some(Path { elements: els })
776}
777
778impl Importer<'_> {
779    fn emit(&mut self, layer: usize, shape: Shape, paint: &Paint) {
780        if shape.validate().is_err() {
781            self.rec.add(LossKind::Unsupported, "degenerate shape");
782            return;
783        }
784        let stroke_w = paint.width.map(|w| (w * self.unit_scale * 96.0 / 25.4).clamp(0.0, 1000.0));
785        let style = Style {
786            stroke: paint.stroke.unwrap_or(Some(Color::BLACK)).filter(|_| paint.stroke != Some(None)),
787            fill: paint.fill.flatten().filter(|_| shape.is_region()),
788            stroke_width: stroke_w,
789            dash: None,
790        };
791        self.rec.entities += 1;
792        self.out.push((layer, NewEntity { geometry: Some(shape), style: Some(style), ..NewEntity::default() }));
793    }
794
795    fn walk(&mut self, node: roxmltree::Node<'_, '_>, t: Affine, paint: &Paint, layer: usize, depth: usize) {
796        if depth > 256 {
797            self.rec.add(LossKind::Unsupported, "nesting deeper than 256");
798            return;
799        }
800        for child in node.children().filter(roxmltree::Node::is_element) {
801            self.elements += 1;
802            if self.elements > 1_000_000 {
803                return;
804            }
805            let tag = child.tag_name().name();
806            if child.attribute("href").is_some() || child.attribute(("http://www.w3.org/1999/xlink", "href")).is_some()
807            {
808                self.rec.add(LossKind::ExternalReference, format!("href on <{tag}>"));
809            }
810            let mut p = paint.clone();
811            for (k, v) in style_map(&child) {
812                match k.as_str() {
813                    "stroke" => p.stroke = parse_color(&v, self.rec).or(p.stroke),
814                    "fill" => p.fill = parse_color(&v, self.rec).or(p.fill),
815                    "stroke-width" => p.width = numbers(&v).first().copied().or(p.width),
816                    "display" if v == "none" => p.hidden = true,
817                    "visibility" if v == "hidden" => p.hidden = true,
818                    _ => {}
819                }
820            }
821            if p.hidden {
822                continue;
823            }
824            let ct = child.attribute("transform").map_or(Affine::IDENTITY, parse_transform).then(t);
825            let f = |name: &str| child.attribute(name).and_then(|v| numbers(v).first().copied()).unwrap_or(0.0);
826            let tr = |s: Shape| s.transform(ct, TransformPolicy::Convert);
827            match tag {
828                "g" | "a" | "switch" => {
829                    let l = if depth == 0 && tag == "g" {
830                        let name = child
831                            .attribute(("http://www.inkscape.org/namespaces/inkscape", "label"))
832                            .or_else(|| child.attribute("id"))
833                            .unwrap_or("Layer");
834                        self.layers.push(name.chars().take(256).collect());
835                        self.layers.len() - 1
836                    } else {
837                        layer
838                    };
839                    self.walk(child, ct, &p, l, depth + 1);
840                }
841                "line" => {
842                    if let Ok(s) =
843                        tr(Shape::Line(Segment::new(Point::new(f("x1"), f("y1")), Point::new(f("x2"), f("y2")))))
844                    {
845                        self.emit(layer, s, &p);
846                    }
847                }
848                "rect" => {
849                    if child.attribute("rx").is_some() || child.attribute("ry").is_some() {
850                        self.rec.add(LossKind::Approximated, "rounded rect corners");
851                    }
852                    let r = Rect { origin: Point::new(f("x"), f("y")), width: f("width"), height: f("height") };
853                    if let Ok(s) = tr(Shape::Rect(r)) {
854                        self.emit(layer, s, &p);
855                    }
856                }
857                "circle" => {
858                    let c = Circle { center: Point::new(f("cx"), f("cy")), radius: f("r") };
859                    match tr(Shape::Circle(c)) {
860                        Ok(s) => {
861                            if s.kind() != dotloom_geometry::ShapeKind::Circle {
862                                self.rec.add(LossKind::Approximated, "transformed circle → Bézier");
863                            }
864                            self.emit(layer, s, &p);
865                        }
866                        Err(_) => self.rec.add(LossKind::Unsupported, "circle"),
867                    }
868                }
869                "ellipse" => {
870                    let (rx, ry) = (f("rx"), f("ry"));
871                    let c = Point::new(f("cx"), f("cy"));
872                    let shape = if (rx - ry).abs() <= 1e-12 * rx.abs().max(1.0) {
873                        Some(Shape::Circle(Circle { center: c, radius: rx }))
874                    } else {
875                        self.rec.add(LossKind::Approximated, "ellipse → Bézier");
876                        ellipse_path(c, rx, ry).map(Shape::Path)
877                    };
878                    if let Some(s) = shape.and_then(|s| tr(s).ok()) {
879                        self.emit(layer, s, &p);
880                    }
881                }
882                "polyline" | "polygon" => {
883                    let v = numbers(child.attribute("points").unwrap_or(""));
884                    let pts: Vec<Point> = v.as_chunks::<2>().0.iter().map(|[x, y]| Point::new(*x, *y)).collect();
885                    let pl = Polyline { points: pts, bulges: Vec::new(), closed: tag == "polygon" };
886                    if let Ok(s) = tr(Shape::Polyline(pl)) {
887                        self.emit(layer, s, &p);
888                    }
889                }
890                "path" => match parse_path(child.attribute("d").unwrap_or(""), self.rec) {
891                    Some(path) => {
892                        if let Ok(s) = tr(Shape::Path(path)) {
893                            self.emit(layer, s, &p);
894                        }
895                    }
896                    None => self.rec.add(LossKind::Unsupported, "malformed path data"),
897                },
898                "text" => {
899                    let content: String = child
900                        .descendants()
901                        .filter(roxmltree::Node::is_text)
902                        .filter_map(|n| n.text())
903                        .collect::<Vec<_>>()
904                        .join("");
905                    if child.descendants().any(|n| n.tag_name().name() == "tspan") {
906                        self.rec.add(LossKind::Text, "tspan flattened");
907                    }
908                    let size = child
909                        .attribute("font-size")
910                        .and_then(|v| numbers(v).first().copied())
911                        .filter(|v| *v > 0.0)
912                        .unwrap_or(16.0);
913                    let halign = match child.attribute("text-anchor") {
914                        Some("middle") => HAlign::Center,
915                        Some("end") => HAlign::Right,
916                        _ => HAlign::Left,
917                    };
918                    let t0 = Text {
919                        position: Point::new(f("x"), f("y")),
920                        content: content.chars().take(10_000).collect(),
921                        height: size,
922                        rotation: 0.0,
923                        halign,
924                        valign: VAlign::Baseline,
925                    };
926                    // Text is upright in SVG (Y down); after the Y flip it must stay readable.
927                    let pos = ct.apply(t0.position);
928                    let dir = ct.apply_vector(Vector::new(1.0, 0.0));
929                    let scale = ct.apply_vector(Vector::new(0.0, 1.0)).length();
930                    let text = Text { position: pos, height: size * scale, rotation: dir.angle(), ..t0 };
931                    if !text.content.trim().is_empty() {
932                        self.emit(layer, Shape::Text(text), &p);
933                    }
934                }
935                "script" => self.rec.add(LossKind::Unsupported, "script (never executed)"),
936                "style" => self.rec.add(LossKind::Style, "CSS <style> sheet"),
937                "use" | "image" | "foreignObject" | "iframe" | "video" | "audio" => {
938                    self.rec.add(LossKind::Unsupported, tag)
939                }
940                "linearGradient" | "radialGradient" | "pattern" | "filter" | "mask" | "clipPath" | "marker" => {
941                    self.rec.add(LossKind::Style, tag)
942                }
943                "defs" | "symbol" => self.rec.add(LossKind::Unsupported, format!("{tag} content")),
944                "title" | "desc" | "metadata" => {}
945                other => self.rec.add(LossKind::Unsupported, other.to_owned()),
946            }
947        }
948    }
949}
950
951/// Import SVG text into built-in Dotloom geometry (mm, Y up).
952pub fn import_svg(text: &str) -> Result<SvgImport, SvgError> {
953    if text.len() > MAX_SVG_BYTES {
954        return Err(SvgError::TooLarge(MAX_SVG_BYTES));
955    }
956    let opts =
957        roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 2_000_000, ..roxmltree::ParsingOptions::default() };
958    let doc = roxmltree::Document::parse_with_options(text, opts).map_err(|e| SvgError::Xml(e.to_string()))?;
959    let root = doc.root_element();
960    if root.tag_name().name() != "svg" {
961        return Err(SvgError::NotSvg);
962    }
963    let mut rec = Recorder::default();
964    // User units → mm, with the Y axis flipped.
965    let vb = root.attribute("viewBox").map(numbers).filter(|v| v.len() == 4 && v[2] > 0.0 && v[3] > 0.0);
966    // The viewBox only selects the visible area; user coordinates are kept absolute.
967    let (sx, sy) =
968        match (&vb, root.attribute("width").and_then(length_mm), root.attribute("height").and_then(length_mm)) {
969            (Some(v), Some(w), Some(h)) => (w / v[2], h / v[3]),
970            (Some(_), _, _) => {
971                rec.notes.push("no absolute size: 1 user unit = 1 px at 96 dpi".into());
972                (25.4 / 96.0, 25.4 / 96.0)
973            }
974            _ => {
975                rec.notes.push("no viewBox: 1 user unit = 1 px at 96 dpi".into());
976                (25.4 / 96.0, 25.4 / 96.0)
977            }
978        };
979    if (sx - sy).abs() > 1e-9 * sx.abs().max(sy.abs()) {
980        rec.add(LossKind::Units, "non-uniform viewBox scaling (preserveAspectRatio ignored)");
981    }
982    let to_model = Affine::scale(sx, -sy);
983    let mut imp =
984        Importer { rec: &mut rec, out: Vec::new(), layers: vec!["Imported".into()], unit_scale: sx, elements: 0 };
985    let paint = Paint::default();
986    imp.walk(root, to_model, &paint, 0, 0);
987    let (layers, entities) = (imp.layers, imp.out);
988    rec.notes.push(format!("scale: {} mm per user unit", num(sx)));
989    Ok(SvgImport { layers, entities, report: rec.finish() })
990}