1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
29pub enum SvgError {
30 #[error("invalid svg: {0}")]
32 Xml(String),
33 #[error("root element is not <svg>")]
35 NotSvg,
36 #[error("svg input exceeds {0} bytes")]
38 TooLarge(usize),
39}
40
41#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct SvgExportOptions {
44 pub margin: f64,
46 pub foreground: Color,
48 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("&"),
69 '<' => o.push_str("<"),
70 '>' => o.push_str(">"),
71 '"' => o.push_str("""),
72 '\'' => o.push_str("'"),
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 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 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
218pub 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#[derive(Debug, Clone, PartialEq)]
361pub struct SvgImport {
362 pub layers: Vec<String>,
364 pub entities: Vec<(usize, NewEntity)>,
366 pub report: ConversionReport,
368}
369
370pub 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 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#[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 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 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
951pub 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 let vb = root.attribute("viewBox").map(numbers).filter(|v| v.len() == 4 && v[2] > 0.0 && v[3] > 0.0);
966 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}