Skip to main content

pfx_geom/
svg.rs

1use std::collections::BTreeMap;
2use std::f64::consts::{FRAC_PI_2, TAU};
3use std::str::FromStr;
4
5use svgtypes::{SimplePathSegment, SimplifyingPathParser, Transform};
6
7const SVG_NS: &str = "http://www.w3.org/2000/svg";
8const SHAPES: [&str; 7] = [
9    "path", "rect", "circle", "ellipse", "polygon", "polyline", "line",
10];
11const SKIPPED: [&str; 11] = [
12    "defs",
13    "mask",
14    "clipPath",
15    "linearGradient",
16    "radialGradient",
17    "title",
18    "desc",
19    "symbol",
20    "pattern",
21    "marker",
22    "metadata",
23];
24const STYLE_KEYS: [&str; 8] = [
25    "fill",
26    "stroke",
27    "stroke-width",
28    "stroke-linecap",
29    "stroke-linejoin",
30    "mask",
31    "fill-rule",
32    "opacity",
33];
34
35#[derive(Clone, Debug, PartialEq)]
36pub enum Paint {
37    Color([u8; 3]),
38    Gradient(Gradient),
39}
40
41#[derive(Clone, Debug, PartialEq)]
42pub struct Gradient {
43    pub id: String,
44    pub stops: Vec<(f32, [u8; 3])>,
45    pub x1: f32,
46    pub x2: f32,
47    pub y1: f32,
48    pub y2: f32,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum FillRule {
53    NonZero,
54    EvenOdd,
55}
56
57#[derive(Clone, Debug, PartialEq)]
58pub struct Path {
59    pub points: Vec<[f64; 2]>,
60    pub closed: bool,
61}
62
63#[derive(Clone, Debug, PartialEq)]
64pub struct Element {
65    pub tag: String,
66    pub paths: Vec<Path>,
67    pub fill: Option<Paint>,
68    pub stroke: Option<Paint>,
69    pub stroke_width: f64,
70    pub joined: bool,
71    pub fill_rule: FillRule,
72    pub holes: Vec<Vec<[f64; 2]>>,
73    pub ground: bool,
74}
75
76#[derive(Clone, Debug, PartialEq)]
77pub struct Drawing {
78    pub view: [f64; 4],
79    pub elements: Vec<Element>,
80    pub tolerance: f64,
81}
82
83impl Drawing {
84    pub fn parse(text: &str, tolerance: f64) -> Result<Drawing, String> {
85        if text.contains("<!DOCTYPE") || text.contains("<!ENTITY") {
86            return Err("refusing an SVG with a DOCTYPE or ENTITY".to_string());
87        }
88        let document = roxmltree::Document::parse(text).map_err(|e| format!("svg: {e}"))?;
89        let root = document.root_element();
90        if root.tag_name().name() != "svg" {
91            return Err("svg: the root element is not <svg>".to_string());
92        }
93        let view = match root.attribute("viewBox") {
94            Some(value) => view_box(value)?,
95            None => [0.0, 0.0, 100.0, 100.0],
96        };
97        let size = view[2].max(view[3]).max(1e-9);
98        let tolerance = (tolerance * size).max(1e-9);
99        let mut gradients = BTreeMap::new();
100        let mut masks = BTreeMap::new();
101        for node in root.descendants().filter(|n| is_svg(n)) {
102            let id = node.attribute("id").unwrap_or("").to_string();
103            match node.tag_name().name() {
104                "linearGradient" | "radialGradient" => {
105                    gradients.insert(id.clone(), gradient(&node, id)?);
106                }
107                "mask" => {
108                    masks.insert(id, mask_holes(&node, tolerance)?);
109                }
110                _ => {}
111            }
112        }
113        let mut walker = Walker {
114            view,
115            tolerance,
116            gradients: &gradients,
117            masks: &masks,
118            elements: Vec::new(),
119        };
120        let mut start = BTreeMap::new();
121        start.insert("fill".to_string(), "black".to_string());
122        walker.walk(root, &start, Transform::default())?;
123        Ok(Drawing {
124            view,
125            elements: walker.elements,
126            tolerance,
127        })
128    }
129}
130
131fn is_svg(node: &roxmltree::Node) -> bool {
132    node.is_element() && matches!(node.tag_name().namespace(), None | Some(SVG_NS))
133}
134
135fn view_box(value: &str) -> Result<[f64; 4], String> {
136    let numbers = numbers(value)?;
137    if numbers.len() != 4 || numbers[2] <= 0.0 || numbers[3] <= 0.0 {
138        return Err(format!("svg: bad viewBox \"{value}\""));
139    }
140    Ok([numbers[0], numbers[1], numbers[2], numbers[3]])
141}
142
143fn numbers(value: &str) -> Result<Vec<f64>, String> {
144    value
145        .split(|c: char| c == ',' || c.is_whitespace())
146        .filter(|part| !part.is_empty())
147        .map(|part| {
148            part.parse::<f64>()
149                .map_err(|_| format!("svg: \"{part}\" is not a number"))
150        })
151        .collect()
152}
153
154fn length(value: Option<&str>, default: f64) -> Result<f64, String> {
155    let Some(value) = value else {
156        return Ok(default);
157    };
158    let trimmed = value.trim().trim_end_matches('%');
159    if trimmed.is_empty() {
160        return Ok(default);
161    }
162    svgtypes::Length::from_str(trimmed)
163        .map(|length| length.number)
164        .map_err(|_| format!("svg: \"{value}\" is not a length"))
165}
166
167fn style_of(
168    node: &roxmltree::Node,
169    inherited: &BTreeMap<String, String>,
170) -> BTreeMap<String, String> {
171    let mut out = inherited.clone();
172    for key in STYLE_KEYS {
173        if let Some(value) = node.attribute(key) {
174            out.insert(key.to_string(), value.to_string());
175        }
176    }
177    for part in node.attribute("style").unwrap_or("").split(';') {
178        if let Some((key, value)) = part.split_once(':') {
179            out.insert(key.trim().to_string(), value.trim().to_string());
180        }
181    }
182    out
183}
184
185fn color(value: &str) -> Result<[u8; 3], String> {
186    let parsed = svgtypes::Color::from_str(value.trim())
187        .map_err(|_| format!("svg: bad colour \"{value}\""))?;
188    Ok([parsed.red, parsed.green, parsed.blue])
189}
190
191fn gradient(node: &roxmltree::Node, id: String) -> Result<Gradient, String> {
192    let mut stops = Vec::new();
193    for stop in node
194        .children()
195        .filter(|n| is_svg(n) && n.tag_name().name() == "stop")
196    {
197        let style = style_of(&stop, &BTreeMap::new());
198        let value = stop
199            .attribute("stop-color")
200            .or(style.get("stop-color").map(String::as_str))
201            .unwrap_or("#000000");
202        let offset = stop.attribute("offset").unwrap_or("0").trim();
203        let offset = match offset.strip_suffix('%') {
204            Some(percent) => percent.parse::<f64>().map(|v| v / 100.0),
205            None => offset.parse::<f64>(),
206        }
207        .map_err(|_| format!("svg: gradient {id} has a bad stop offset \"{offset}\""))?;
208        stops.push((offset as f32, color(value)?));
209    }
210    let coordinate = |key: &str, default: &str| -> Result<f32, String> {
211        let raw = node.attribute(key).unwrap_or(default);
212        raw.trim()
213            .trim_end_matches('%')
214            .parse::<f32>()
215            .map_err(|_| format!("svg: gradient {id} has a bad {key} \"{raw}\""))
216    };
217    Ok(Gradient {
218        x1: coordinate("x1", "0")?,
219        x2: coordinate("x2", "1")?,
220        y1: coordinate("y1", "0")?,
221        y2: coordinate("y2", "0")?,
222        id,
223        stops,
224    })
225}
226
227fn mask_holes(node: &roxmltree::Node, tolerance: f64) -> Result<Vec<Vec<[f64; 2]>>, String> {
228    let mut holes = Vec::new();
229    for shape in node.descendants().filter(|n| is_svg(n)) {
230        if shape.tag_name().name() != "rect" {
231            continue;
232        }
233        let fill = shape.attribute("fill").unwrap_or("").to_ascii_lowercase();
234        if !matches!(fill.as_str(), "black" | "#000" | "#000000") {
235            continue;
236        }
237        let x = length(shape.attribute("x"), 0.0)?;
238        let y = length(shape.attribute("y"), 0.0)?;
239        let w = length(shape.attribute("width"), 0.0)?;
240        let h = length(shape.attribute("height"), 0.0)?;
241        let rx = length(shape.attribute("rx"), 0.0)?;
242        if w > 0.0 && h > 0.0 {
243            holes.push(rounded_rect(x, y, w, h, rx, rx, tolerance));
244        }
245    }
246    Ok(holes)
247}
248
249struct Walker<'a> {
250    view: [f64; 4],
251    tolerance: f64,
252    gradients: &'a BTreeMap<String, Gradient>,
253    masks: &'a BTreeMap<String, Vec<Vec<[f64; 2]>>>,
254    elements: Vec<Element>,
255}
256
257impl Walker<'_> {
258    fn walk(
259        &mut self,
260        node: roxmltree::Node,
261        inherited: &BTreeMap<String, String>,
262        parent: Transform,
263    ) -> Result<(), String> {
264        if !is_svg(&node) {
265            return Ok(());
266        }
267        let tag = node.tag_name().name();
268        if SKIPPED.contains(&tag) {
269            return Ok(());
270        }
271        let own = match node.attribute("transform") {
272            Some(text) => {
273                Transform::from_str(text).map_err(|_| format!("svg: bad transform \"{text}\""))?
274            }
275            None => Transform::default(),
276        };
277        let transform = multiply(&parent, &own);
278        let style = style_of(&node, inherited);
279        if SHAPES.contains(&tag) {
280            self.shape(&node, tag, &style, &transform)?;
281        }
282        for child in node.children() {
283            self.walk(child, &style, transform)?;
284        }
285        Ok(())
286    }
287
288    fn paint(&self, value: Option<&String>) -> Result<Option<Paint>, String> {
289        let Some(value) = value.map(|v| v.trim()) else {
290            return Ok(None);
291        };
292        if value.is_empty() || value == "none" || value == "transparent" {
293            return Ok(None);
294        }
295        if let Some(rest) = value.strip_prefix("url(#") {
296            let id = rest.trim_end_matches(')').trim();
297            return self
298                .gradients
299                .get(id)
300                .map(|g| Some(Paint::Gradient(g.clone())))
301                .ok_or_else(|| format!("svg: no gradient #{id}"));
302        }
303        Ok(Some(Paint::Color(color(value)?)))
304    }
305
306    fn shape(
307        &mut self,
308        node: &roxmltree::Node,
309        tag: &str,
310        style: &BTreeMap<String, String>,
311        transform: &Transform,
312    ) -> Result<(), String> {
313        let det = transform.a * transform.d - transform.b * transform.c;
314        let spread = det.abs().sqrt();
315        let local_tolerance = self.tolerance / spread.max(1e-9);
316        let attr = |key: &str| length(node.attribute(key), 0.0);
317        let mut ground = false;
318        let local: Vec<Path> = match tag {
319            "path" => path_data(node.attribute("d").unwrap_or(""), transform, self.tolerance)?,
320            "rect" => {
321                let (x, y, w, h) = (attr("x")?, attr("y")?, attr("width")?, attr("height")?);
322                let square = (self.view[2] - self.view[3]).abs() < 1e-6;
323                ground = square && w >= self.view[2] * 0.98 && h >= self.view[3] * 0.98;
324                let rx = node.attribute("rx").map(|_| attr("rx")).transpose()?;
325                let ry = node.attribute("ry").map(|_| attr("ry")).transpose()?;
326                let (rx, ry) = match (rx, ry) {
327                    (Some(rx), Some(ry)) => (rx, ry),
328                    (Some(r), None) | (None, Some(r)) => (r, r),
329                    (None, None) => (0.0, 0.0),
330                };
331                closed(rounded_rect(x, y, w, h, rx, ry, local_tolerance), transform)
332            }
333            "circle" => {
334                let r = attr("r")?;
335                closed(
336                    ellipse(attr("cx")?, attr("cy")?, r, r, local_tolerance),
337                    transform,
338                )
339            }
340            "ellipse" => closed(
341                ellipse(
342                    attr("cx")?,
343                    attr("cy")?,
344                    attr("rx")?,
345                    attr("ry")?,
346                    local_tolerance,
347                ),
348                transform,
349            ),
350            "polygon" | "polyline" => {
351                let values = numbers(node.attribute("points").unwrap_or(""))?;
352                let points = values
353                    .chunks_exact(2)
354                    .map(|p| apply(transform, [p[0], p[1]]))
355                    .collect::<Vec<_>>();
356                vec![Path {
357                    points,
358                    closed: tag == "polygon",
359                }]
360            }
361            _ => vec![Path {
362                points: vec![
363                    apply(transform, [attr("x1")?, attr("y1")?]),
364                    apply(transform, [attr("x2")?, attr("y2")?]),
365                ],
366                closed: false,
367            }],
368        };
369        let paths = local
370            .into_iter()
371            .map(|mut path| {
372                path.points.dedup_by(|a, b| distance(*a, *b) <= 1e-12);
373                if path.closed
374                    && path.points.len() > 1
375                    && distance(path.points[0], *path.points.last().unwrap()) <= 1e-12
376                {
377                    path.points.pop();
378                }
379                path
380            })
381            .filter(|path| path.points.len() > 1)
382            .collect::<Vec<_>>();
383        let fill = self.paint(style.get("fill"))?;
384        let stroke = self.paint(style.get("stroke"))?;
385        let width = length(style.get("stroke-width").map(String::as_str), 0.0)?;
386        let joined = fill.is_some()
387            && stroke.is_some()
388            && width > 0.0
389            && style.get("stroke") == style.get("fill");
390        let fill_rule = match style.get("fill-rule").map(String::as_str) {
391            Some("evenodd") => FillRule::EvenOdd,
392            _ => FillRule::NonZero,
393        };
394        let holes = match style.get("mask").map(|m| m.trim()) {
395            Some(mask) => match mask.strip_prefix("url(#") {
396                Some(rest) => self
397                    .masks
398                    .get(rest.trim_end_matches(')').trim())
399                    .cloned()
400                    .unwrap_or_default(),
401                None => Vec::new(),
402            },
403            None => Vec::new(),
404        };
405        self.elements.push(Element {
406            tag: tag.to_string(),
407            paths,
408            fill,
409            stroke,
410            stroke_width: width * spread,
411            joined,
412            fill_rule,
413            holes,
414            ground,
415        });
416        Ok(())
417    }
418}
419
420fn multiply(outer: &Transform, inner: &Transform) -> Transform {
421    Transform::new(
422        outer.a * inner.a + outer.c * inner.b,
423        outer.b * inner.a + outer.d * inner.b,
424        outer.a * inner.c + outer.c * inner.d,
425        outer.b * inner.c + outer.d * inner.d,
426        outer.a * inner.e + outer.c * inner.f + outer.e,
427        outer.b * inner.e + outer.d * inner.f + outer.f,
428    )
429}
430
431fn apply(t: &Transform, p: [f64; 2]) -> [f64; 2] {
432    [t.a * p[0] + t.c * p[1] + t.e, t.b * p[0] + t.d * p[1] + t.f]
433}
434
435fn distance(a: [f64; 2], b: [f64; 2]) -> f64 {
436    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
437}
438
439fn closed(points: Vec<[f64; 2]>, transform: &Transform) -> Vec<Path> {
440    if points.len() < 3 {
441        return Vec::new();
442    }
443    vec![Path {
444        points: points.into_iter().map(|p| apply(transform, p)).collect(),
445        closed: true,
446    }]
447}
448
449fn arc_steps(radius: f64, tolerance: f64, span: f64) -> usize {
450    if radius <= tolerance {
451        return 1;
452    }
453    let half = (1.0 - tolerance / radius).clamp(-1.0, 1.0).acos();
454    ((span / (2.0 * half)).ceil() as usize).clamp(1, 4096)
455}
456
457fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64, tolerance: f64) -> Vec<[f64; 2]> {
458    if rx <= 0.0 || ry <= 0.0 {
459        return Vec::new();
460    }
461    let steps = arc_steps(rx.max(ry), tolerance, TAU).max(8);
462    (0..steps)
463        .map(|i| {
464            let angle = TAU * i as f64 / steps as f64;
465            [cx + rx * angle.cos(), cy + ry * angle.sin()]
466        })
467        .collect()
468}
469
470fn rounded_rect(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64, tolerance: f64) -> Vec<[f64; 2]> {
471    if w <= 0.0 || h <= 0.0 {
472        return Vec::new();
473    }
474    let rx = rx.clamp(0.0, w * 0.5);
475    let ry = ry.clamp(0.0, h * 0.5);
476    if rx <= 0.0 || ry <= 0.0 {
477        return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
478    }
479    let steps = arc_steps(rx.max(ry), tolerance, FRAC_PI_2).max(2);
480    let corners = [
481        ([x + w - rx, y + ry], -FRAC_PI_2),
482        ([x + w - rx, y + h - ry], 0.0),
483        ([x + rx, y + h - ry], FRAC_PI_2),
484        ([x + rx, y + ry], std::f64::consts::PI),
485    ];
486    let mut out = Vec::with_capacity(4 * (steps + 1));
487    for (center, from) in corners {
488        for i in 0..=steps {
489            let angle = from + FRAC_PI_2 * i as f64 / steps as f64;
490            out.push([center[0] + rx * angle.cos(), center[1] + ry * angle.sin()]);
491        }
492    }
493    out
494}
495
496fn path_data(d: &str, transform: &Transform, tolerance: f64) -> Result<Vec<Path>, String> {
497    let mut paths = Vec::new();
498    let mut current: Vec<[f64; 2]> = Vec::new();
499    let mut start = [0.0, 0.0];
500    let mut last = [0.0, 0.0];
501    let flush = |current: &mut Vec<[f64; 2]>, paths: &mut Vec<Path>, closed: bool| {
502        if current.len() > 1 {
503            paths.push(Path {
504                points: std::mem::take(current),
505                closed,
506            });
507        } else {
508            current.clear();
509        }
510    };
511    for segment in SimplifyingPathParser::from(d) {
512        let segment = segment.map_err(|e| format!("svg: path data: {e}"))?;
513        match segment {
514            SimplePathSegment::MoveTo { x, y } => {
515                flush(&mut current, &mut paths, false);
516                start = apply(transform, [x, y]);
517                last = start;
518                current.push(start);
519            }
520            SimplePathSegment::LineTo { x, y } => {
521                if current.is_empty() {
522                    current.push(last);
523                }
524                last = apply(transform, [x, y]);
525                current.push(last);
526            }
527            SimplePathSegment::Quadratic { x1, y1, x, y } => {
528                if current.is_empty() {
529                    current.push(last);
530                }
531                let c = apply(transform, [x1, y1]);
532                let end = apply(transform, [x, y]);
533                let bend = [last[0] - 2.0 * c[0] + end[0], last[1] - 2.0 * c[1] + end[1]];
534                let steps = wang(bend, 0.25, tolerance);
535                for i in 1..=steps {
536                    let t = i as f64 / steps as f64;
537                    let u = 1.0 - t;
538                    current.push([
539                        u * u * last[0] + 2.0 * u * t * c[0] + t * t * end[0],
540                        u * u * last[1] + 2.0 * u * t * c[1] + t * t * end[1],
541                    ]);
542                }
543                last = end;
544            }
545            SimplePathSegment::CurveTo {
546                x1,
547                y1,
548                x2,
549                y2,
550                x,
551                y,
552            } => {
553                if current.is_empty() {
554                    current.push(last);
555                }
556                let c1 = apply(transform, [x1, y1]);
557                let c2 = apply(transform, [x2, y2]);
558                let end = apply(transform, [x, y]);
559                let b1 = [last[0] - 2.0 * c1[0] + c2[0], last[1] - 2.0 * c1[1] + c2[1]];
560                let b2 = [c1[0] - 2.0 * c2[0] + end[0], c1[1] - 2.0 * c2[1] + end[1]];
561                let bend = if b1[0].hypot(b1[1]) > b2[0].hypot(b2[1]) {
562                    b1
563                } else {
564                    b2
565                };
566                let steps = wang(bend, 0.75, tolerance);
567                for i in 1..=steps {
568                    let t = i as f64 / steps as f64;
569                    let u = 1.0 - t;
570                    let (w0, w1, w2, w3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
571                    current.push([
572                        w0 * last[0] + w1 * c1[0] + w2 * c2[0] + w3 * end[0],
573                        w0 * last[1] + w1 * c1[1] + w2 * c2[1] + w3 * end[1],
574                    ]);
575                }
576                last = end;
577            }
578            SimplePathSegment::ClosePath => {
579                flush(&mut current, &mut paths, true);
580                last = start;
581            }
582        }
583    }
584    flush(&mut current, &mut paths, false);
585    Ok(paths)
586}
587
588fn wang(bend: [f64; 2], factor: f64, tolerance: f64) -> usize {
589    let size = bend[0].hypot(bend[1]);
590    ((factor * size / tolerance).sqrt().ceil() as usize).clamp(1, 1024)
591}