Skip to main content

agg_gui/vector_icon/
path_data.rs

1//! SVG path-data parser for [`VectorIcon`](super::VectorIcon).
2//!
3//! Deliberately a *subset*: `M`, `L`, `H`, `V`, `C`, `A`, `Z` in both
4//! absolute and relative form. That is everything hand-authored icon
5//! artwork uses, and it lets a host paste an SVG's `d` attribute in
6//! verbatim rather than re-deriving the geometry by hand — the icon in
7//! the app then stays byte-comparable with the artwork it came from.
8//! Full SVG (gradients, transforms, text, `S`/`T`/`Q` shorthands) is the
9//! job of [`crate::svg`], which brings in `usvg`; this module exists so
10//! a 200-byte icon does not need a document parser.
11//!
12//! Output is already-flattened closed contours in icon space, because
13//! everything downstream (painting, hit-free bounds queries, tests)
14//! wants points rather than curves, and the flattening tolerance only
15//! has to satisfy a ~16-px icon.
16//!
17//! ## One thing the subset does NOT accept: compressed arc flags
18//!
19//! SVG lets an arc's two boolean flags run together with the numbers
20//! around them (`a1 1 0 0110 10` means `0 1 10 10`), because a flag is
21//! by definition one character. This scanner reads every argument as a
22//! full number, so it sees `0110` instead. That is a deliberate
23//! omission — the artwork this module exists for is written with
24//! separators, and the alternative is a scanner whose number parsing
25//! depends on which argument position it is in.
26//!
27//! It fails *loudly*, which is why the omission is safe: swallowing
28//! `0110` as one number leaves the arc one argument short, so the path
29//! is rejected with [`IconPathError::MissingArgument`] rather than
30//! drawn wrong (pinned by `compressed_arc_flags_are_rejected_not_misdrawn`
31//! in this module's tests). A path that got past that check would still
32//! land on the stated endpoint — the arc's own bulge is the only thing
33//! flags control.
34//!
35//! Curves are flattened at a fixed angular / parametric step chosen for
36//! that size: arcs are cut at 7.5° (chord error `r·(1−cos 3.75°)`, about
37//! 0.05 units on a 64-unit box — a thousandth of the icon) and cubics
38//! into 16 pieces. Icon artwork is commonly *already* chopped into short
39//! arc segments so that naive flatteners look right; this step is fine
40//! enough that such artwork is unaffected either way.
41
42use std::f64::consts::PI;
43use std::fmt;
44
45/// One closed contour: consecutive points in icon space.
46pub type Contour = Vec<[f64; 2]>;
47
48/// Angular step used to flatten arcs.
49const ARC_STEP: f64 = PI / 24.0;
50/// Number of line segments per cubic Bézier.
51const CUBIC_STEPS: usize = 16;
52
53/// Why a path-data string could not be parsed.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum IconPathError {
56    /// A command letter this subset does not implement.
57    UnsupportedCommand(char),
58    /// A command ran out of numbers (e.g. `L 10`).
59    MissingArgument(char),
60    /// A token where a number was expected.
61    BadNumber(String),
62    /// Coordinates appeared before any `M`.
63    NoCurrentPoint,
64    /// A number appeared where no command could consume it — the only
65    /// way to reach this is a number right after `Z`, which takes no
66    /// arguments, so there is nothing for SVG's implicit-repetition rule
67    /// to repeat.
68    UnexpectedNumber,
69}
70
71impl fmt::Display for IconPathError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            IconPathError::UnsupportedCommand(c) => {
75                write!(f, "unsupported SVG path command '{c}'")
76            }
77            IconPathError::MissingArgument(c) => {
78                write!(f, "SVG path command '{c}' is missing arguments")
79            }
80            IconPathError::BadNumber(s) => write!(f, "'{s}' is not a number"),
81            IconPathError::NoCurrentPoint => {
82                write!(
83                    f,
84                    "SVG path starts with a drawing command before any moveto"
85                )
86            }
87            IconPathError::UnexpectedNumber => {
88                write!(
89                    f,
90                    "SVG path has a number after a closepath, which takes no arguments"
91                )
92            }
93        }
94    }
95}
96
97impl std::error::Error for IconPathError {}
98
99/// Parse SVG path data into closed contours.
100///
101/// A subpath is closed whether or not it ends in `Z`: icon paths are
102/// fills, and an unclosed fill contour is closed implicitly by every
103/// rasteriser anyway.
104pub fn parse_path(d: &str) -> Result<Vec<Contour>, IconPathError> {
105    let mut scan = Scanner::new(d);
106    let mut contours: Vec<Contour> = Vec::new();
107    let mut current: Contour = Vec::new();
108    let mut pos = [0.0f64, 0.0];
109    let mut start = [0.0f64, 0.0];
110    let mut cmd: Option<char> = None;
111
112    loop {
113        scan.skip_separators();
114        let next = match scan.peek() {
115            None => break,
116            Some(c) if c.is_ascii_alphabetic() => {
117                scan.bump();
118                cmd = Some(c);
119                c
120            }
121            // A bare number repeats the previous command (SVG's implicit
122            // repetition; an implicit repeat of `M` is a `L`).
123            Some(_) => match cmd {
124                Some('M') => 'L',
125                Some('m') => 'l',
126                // `Z` takes no arguments, so there is nothing to repeat
127                // — and repeating it would consume no input, spinning
128                // the loop forever. The spec calls this invalid data.
129                Some('Z') | Some('z') => return Err(IconPathError::UnexpectedNumber),
130                Some(c) => c,
131                None => return Err(IconPathError::NoCurrentPoint),
132            },
133        };
134
135        match next {
136            'M' | 'm' => {
137                if current.len() > 1 {
138                    contours.push(std::mem::take(&mut current));
139                } else {
140                    current.clear();
141                }
142                let x = scan.number(next)?;
143                let y = scan.number(next)?;
144                pos = if next == 'M' {
145                    [x, y]
146                } else {
147                    [pos[0] + x, pos[1] + y]
148                };
149                start = pos;
150                current.push(pos);
151            }
152            'L' | 'l' => {
153                let x = scan.number(next)?;
154                let y = scan.number(next)?;
155                pos = if next == 'L' {
156                    [x, y]
157                } else {
158                    [pos[0] + x, pos[1] + y]
159                };
160                push_point(&mut current, pos)?;
161            }
162            'H' | 'h' => {
163                let x = scan.number(next)?;
164                pos = [if next == 'H' { x } else { pos[0] + x }, pos[1]];
165                push_point(&mut current, pos)?;
166            }
167            'V' | 'v' => {
168                let y = scan.number(next)?;
169                pos = [pos[0], if next == 'V' { y } else { pos[1] + y }];
170                push_point(&mut current, pos)?;
171            }
172            'C' | 'c' => {
173                let mut p = [[0.0f64; 2]; 3];
174                for slot in p.iter_mut() {
175                    let x = scan.number(next)?;
176                    let y = scan.number(next)?;
177                    *slot = if next == 'C' {
178                        [x, y]
179                    } else {
180                        [pos[0] + x, pos[1] + y]
181                    };
182                }
183                if current.is_empty() {
184                    return Err(IconPathError::NoCurrentPoint);
185                }
186                flatten_cubic(&mut current, pos, p[0], p[1], p[2]);
187                pos = p[2];
188            }
189            'A' | 'a' => {
190                let rx = scan.number(next)?;
191                let ry = scan.number(next)?;
192                let rot = scan.number(next)?;
193                let large = scan.number(next)? != 0.0;
194                let sweep = scan.number(next)? != 0.0;
195                let x = scan.number(next)?;
196                let y = scan.number(next)?;
197                let end = if next == 'A' {
198                    [x, y]
199                } else {
200                    [pos[0] + x, pos[1] + y]
201                };
202                if current.is_empty() {
203                    return Err(IconPathError::NoCurrentPoint);
204                }
205                flatten_arc(&mut current, pos, end, rx, ry, rot, large, sweep);
206                pos = end;
207            }
208            'Z' | 'z' => {
209                if current.len() > 1 {
210                    contours.push(std::mem::take(&mut current));
211                } else {
212                    current.clear();
213                }
214                pos = start;
215                current.push(pos);
216            }
217            other => return Err(IconPathError::UnsupportedCommand(other)),
218        }
219    }
220
221    if current.len() > 1 {
222        contours.push(current);
223    }
224    Ok(contours)
225}
226
227fn push_point(current: &mut Contour, p: [f64; 2]) -> Result<(), IconPathError> {
228    if current.is_empty() {
229        return Err(IconPathError::NoCurrentPoint);
230    }
231    current.push(p);
232    Ok(())
233}
234
235fn flatten_cubic(out: &mut Contour, p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2]) {
236    for i in 1..=CUBIC_STEPS {
237        let t = i as f64 / CUBIC_STEPS as f64;
238        let u = 1.0 - t;
239        let x = u * u * u * p0[0]
240            + 3.0 * u * u * t * p1[0]
241            + 3.0 * u * t * t * p2[0]
242            + t * t * t * p3[0];
243        let y = u * u * u * p0[1]
244            + 3.0 * u * u * t * p1[1]
245            + 3.0 * u * t * t * p2[1]
246            + t * t * t * p3[1];
247        out.push([x, y]);
248    }
249}
250
251/// Endpoint → centre arc conversion (SVG spec F.6.5), then flattening.
252///
253/// Degenerate cases follow the spec's out-of-range radius handling: a
254/// zero radius draws a straight line, and radii too small to span the
255/// endpoints are scaled up until they just fit.
256#[allow(clippy::too_many_arguments)]
257fn flatten_arc(
258    out: &mut Contour,
259    from: [f64; 2],
260    to: [f64; 2],
261    rx: f64,
262    ry: f64,
263    x_rot_deg: f64,
264    large: bool,
265    sweep: bool,
266) {
267    let (mut rx, mut ry) = (rx.abs(), ry.abs());
268    // Identical endpoints: the spec omits the segment entirely. Pushing
269    // `to` here would duplicate the current point — harmless to fill,
270    // but it corrupts point counts and bounds queries.
271    if (from[0] - to[0]).abs() + (from[1] - to[1]).abs() < 1e-12 {
272        return;
273    }
274    // A zero radius degenerates to a straight line (spec F.6.2).
275    if rx < 1e-12 || ry < 1e-12 {
276        out.push(to);
277        return;
278    }
279    let phi = x_rot_deg.to_radians();
280    let (cos_p, sin_p) = (phi.cos(), phi.sin());
281
282    let dx2 = (from[0] - to[0]) * 0.5;
283    let dy2 = (from[1] - to[1]) * 0.5;
284    let x1p = cos_p * dx2 + sin_p * dy2;
285    let y1p = -sin_p * dx2 + cos_p * dy2;
286
287    // Scale radii up when they are too small to reach (spec F.6.6).
288    let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
289    if lambda > 1.0 {
290        let s = lambda.sqrt();
291        rx *= s;
292        ry *= s;
293    }
294
295    let num = (rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p).max(0.0);
296    let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
297    let coef = if den <= 0.0 {
298        0.0
299    } else {
300        let c = (num / den).sqrt();
301        if large == sweep {
302            -c
303        } else {
304            c
305        }
306    };
307    let cxp = coef * rx * y1p / ry;
308    let cyp = -coef * ry * x1p / rx;
309    let cx = cos_p * cxp - sin_p * cyp + (from[0] + to[0]) * 0.5;
310    let cy = sin_p * cxp + cos_p * cyp + (from[1] + to[1]) * 0.5;
311
312    let theta = angle(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
313    let mut delta = angle(
314        (x1p - cxp) / rx,
315        (y1p - cyp) / ry,
316        (-x1p - cxp) / rx,
317        (-y1p - cyp) / ry,
318    );
319    if !sweep && delta > 0.0 {
320        delta -= 2.0 * PI;
321    } else if sweep && delta < 0.0 {
322        delta += 2.0 * PI;
323    }
324
325    let steps = ((delta.abs() / ARC_STEP).ceil() as usize).max(1);
326    for i in 1..=steps {
327        let t = theta + delta * (i as f64 / steps as f64);
328        let (ct, st) = (t.cos(), t.sin());
329        out.push([
330            cx + cos_p * rx * ct - sin_p * ry * st,
331            cy + sin_p * rx * ct + cos_p * ry * st,
332        ]);
333    }
334    // Land exactly on the stated endpoint: the trigonometry above is
335    // only as accurate as the artwork's rounded coordinates, and a
336    // sub-unit gap here would open a seam between adjacent segments.
337    if let Some(last) = out.last_mut() {
338        *last = to;
339    }
340}
341
342/// Signed angle from vector `u` to vector `v`.
343fn angle(ux: f64, uy: f64, vx: f64, vy: f64) -> f64 {
344    let dot = ux * vx + uy * vy;
345    let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt();
346    if len <= 0.0 {
347        return 0.0;
348    }
349    let mut a = (dot / len).clamp(-1.0, 1.0).acos();
350    if ux * vy - uy * vx < 0.0 {
351        a = -a;
352    }
353    a
354}
355
356/// Character scanner over path data: whitespace and commas separate
357/// tokens, and a `-` or `+` may start a number with no separator before
358/// it (`M0 0H52V22.404`).
359struct Scanner<'a> {
360    bytes: &'a [u8],
361    i: usize,
362}
363
364impl<'a> Scanner<'a> {
365    fn new(s: &'a str) -> Self {
366        Self {
367            bytes: s.as_bytes(),
368            i: 0,
369        }
370    }
371
372    fn peek(&self) -> Option<char> {
373        self.bytes.get(self.i).map(|b| *b as char)
374    }
375
376    fn bump(&mut self) {
377        self.i += 1;
378    }
379
380    fn skip_separators(&mut self) {
381        while let Some(c) = self.peek() {
382            if c.is_ascii_whitespace() || c == ',' {
383                self.bump();
384            } else {
385                break;
386            }
387        }
388    }
389
390    /// Read one number, or report which command ran dry.
391    fn number(&mut self, cmd: char) -> Result<f64, IconPathError> {
392        self.skip_separators();
393        let start = self.i;
394        if matches!(self.peek(), Some('+') | Some('-')) {
395            self.bump();
396        }
397        let mut seen_digit = false;
398        let mut seen_dot = false;
399        while let Some(c) = self.peek() {
400            match c {
401                '0'..='9' => {
402                    seen_digit = true;
403                    self.bump();
404                }
405                '.' if !seen_dot => {
406                    seen_dot = true;
407                    self.bump();
408                }
409                'e' | 'E' if seen_digit => {
410                    self.bump();
411                    if matches!(self.peek(), Some('+') | Some('-')) {
412                        self.bump();
413                    }
414                }
415                _ => break,
416            }
417        }
418        if !seen_digit {
419            self.i = start;
420            return Err(IconPathError::MissingArgument(cmd));
421        }
422        let text = std::str::from_utf8(&self.bytes[start..self.i])
423            .map_err(|_| IconPathError::BadNumber(String::from("<non-utf8>")))?;
424        text.parse::<f64>()
425            .map_err(|_| IconPathError::BadNumber(text.to_string()))
426    }
427}