oxvg_path/
lib.rs

1//! OXVG Path is a library used for parsing and minifying SVG paths.
2//! It supports parsing of any valid SVG path and provides optimisations close to exactly as SVGO.
3//!
4//! Use the [Path](Path) struct for simple parsing and serializing. By only parsing and serializing,
5//! it will produce optimised formatting out of the box.
6//! It is made up individual command [Data](command::Data).
7//!
8//! For more rigorous minification, try using the [run](convert::run) function. This will use
9//! non-destructive conversions to shorten the path.
10//!
11//! # Differences to SVGO
12//!
13//! - Unlike SVGO, all close paths are serialized as `Z` instead of either `z` or `Z`. This is fine because the two commands function exactly the same.
14//! - An equivalent of the `applyTransforms` option isn't available, but may be in the future.
15//!
16//! # Licensing
17//!
18//! This library is based off the [`convertPathData`](https://svgo.dev/docs/plugins/convertPathData/) plugin from SVGO and is similarly released under MIT.
19#[cfg(feature = "optimise")]
20#[cfg(feature = "parse")]
21#[macro_use]
22extern crate bitflags;
23
24#[cfg(feature = "napi")]
25#[macro_use]
26extern crate napi_derive;
27
28#[cfg(feature = "optimise")]
29pub mod command;
30#[cfg(feature = "optimise")]
31pub mod convert;
32#[cfg(feature = "optimise")]
33pub mod geometry;
34#[cfg(feature = "optimise")]
35pub(crate) mod math;
36#[cfg(feature = "parse")]
37pub mod parser;
38#[cfg(feature = "optimise")]
39pub mod points;
40#[cfg(feature = "optimise")]
41pub mod positioned;
42
43use points::{Point, Points};
44
45#[derive(Debug, Clone, Default, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
48/// A path is a set of commands
49///
50/// # Example
51///
52/// Out of the box, parsing and serializing a path will produce optimal formatting
53///
54/// ```
55/// use oxvg_path::{Path, parser::Parse as _};
56///
57/// let path = Path::parse_string("M 10 0.01 L 0.5 -1").unwrap();
58/// assert_eq!(&path.to_string(), "M10 .01.5-1");
59/// ```
60///
61/// For more extensive minification, look into using the [run](convert::run) function.
62pub struct Path(pub Vec<command::Data>);
63
64impl Path {
65    /// Checks if two paths have an intersection by checking convex hulls collision using
66    /// Gilbert-Johnson-Keerthi distance algorithm.
67    ///
68    /// # Panics
69    /// If internal assertions fail
70    pub fn intersects(&self, other: &Self) -> bool {
71        let points_1 = Points::from_positioned(&convert::relative(self.clone()));
72        let points_2 = Points::from_positioned(&convert::relative(other.clone()));
73
74        // First check whether their bounding box intersects
75        if points_1.max_x <= points_2.min_x
76            || points_2.max_x <= points_1.min_x
77            || points_1.max_y <= points_2.min_y
78            || points_2.max_y <= points_1.min_y
79            || points_1.list.iter().all(|set_1| {
80                points_2.list.iter().all(|set_2| {
81                    set_1.list[set_1.max_x].0[0] <= set_2.list[set_2.min_x].0[0]
82                        || set_2.list[set_2.max_x].0[0] <= set_1.list[set_1.min_x].0[0]
83                        || set_1.list[set_1.max_y].0[1] <= set_2.list[set_2.min_y].0[1]
84                        || set_2.list[set_2.max_y].0[1] <= set_1.list[set_1.min_y].0[1]
85                })
86            })
87        {
88            log::debug!("no intersection, bounds check failed");
89            return false;
90        }
91
92        // i.e. https://en.wikipedia.org/wiki/Gilbert%E2%80%93Johnson%E2%80%93Keerthi_distance_algorithm
93        let mut hull_nest_1 = points_1.list.into_iter().map(Point::convex_hull);
94        let hull_nest_2: Vec<_> = points_2.list.into_iter().map(Point::convex_hull).collect();
95
96        hull_nest_1.any(|hull_1| {
97            if hull_1.list.len() < 3 {
98                return false;
99            }
100
101            hull_nest_2.iter().any(|hull_2| {
102                if hull_2.list.len() < 3 {
103                    return false;
104                }
105
106                let mut simplex = vec![hull_1.get_support(hull_2, geometry::Point([1.0, 0.0]))];
107                let mut direction = simplex[0].minus();
108                let mut iterations = 10_000;
109
110                loop {
111                    iterations -= 1;
112                    if iterations == 0 {
113                        log::error!("Infinite loop while finding path intersections");
114                        return true;
115                    }
116                    simplex.push(hull_1.get_support(hull_2, direction));
117                    if direction.dot(simplex.last().unwrap()) <= 0.0 {
118                        return false;
119                    }
120                    if geometry::Point::process_simplex(&mut simplex, &mut direction) {
121                        return true;
122                    }
123                }
124            })
125        })
126    }
127}
128
129#[cfg(feature = "format")]
130pub(crate) fn format<'a>(
131    mut iter: impl ExactSizeIterator<Item = &'a command::Data>,
132    f: &mut std::fmt::Formatter<'_>,
133) -> std::fmt::Result {
134    use itertools::Itertools;
135    use std::fmt::Display;
136    use std::fmt::Write;
137
138    if iter.len() == 1 {
139        iter.next().unwrap().fmt(f)?;
140        return Ok(());
141    }
142    iter.tuple_windows()
143        .enumerate()
144        .try_for_each(|(i, (prev, current))| -> std::fmt::Result {
145            if i == 0 {
146                prev.fmt(f)?;
147            }
148            let str = current.to_string();
149            if current.is_space_needed(prev) && !str.starts_with('-') {
150                f.write_char(' ')?;
151            }
152            f.write_str(&str)?;
153            Ok(())
154        })
155}
156#[cfg(feature = "format")]
157impl std::fmt::Display for Path {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        format(self.0.iter(), f)
160    }
161}
162#[cfg(feature = "format")]
163impl std::fmt::Display for positioned::Path {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        format(self.0.iter().map(|p| &p.command), f)
166    }
167}
168
169#[cfg(feature = "format")]
170impl From<Path> for String {
171    fn from(value: Path) -> Self {
172        format!("{value}")
173    }
174}
175
176#[cfg(feature = "format")]
177impl From<&Path> for String {
178    fn from(value: &Path) -> Self {
179        format!("{value}")
180    }
181}
182
183#[test]
184#[cfg(feature = "default")]
185fn test_path_parse() {
186    use oxvg_parse::Parse as _;
187    // Should parse single command
188    insta::assert_snapshot!(Path::parse_string("M 10,50").unwrap());
189
190    // Should parse multiple commands
191    insta::assert_snapshot!(
192        Path::parse_string("M 10,50 C 20,30 40,50 60,70 C 10,20 30,40 50,60").unwrap()
193    );
194
195    // Should parse arc
196    insta::assert_snapshot!(Path::parse_string("m-0,1a 25,25 -30 0,1 0,0").unwrap());
197
198    // Should parse implicit
199    insta::assert_snapshot!(Path::parse_string(
200        "M 10,50 C 1,2 3,4 5,6.5 .1 .2 .3 .4 .5 -.05176e-005"
201    )
202    .unwrap());
203
204    // Should parse minified
205    insta::assert_snapshot!(Path::parse_string("M10 50C1 2 3 4 5 6.5.1.2.3.4.5-5.176e-7").unwrap());
206
207    // Should error when command isn't given
208    assert!(Path::parse_string("0,0").is_err());
209
210    // Should error when args are missing
211    assert!(Path::parse_string("m1").is_err());
212
213    // Parse arc with decimals as separators
214    insta::assert_snapshot!(Path::parse_string("m-0,1a20.8 20.8 0 0 0 5.2.6").unwrap());
215
216    // Parse implicit arc
217    insta::assert_snapshot!(Path::parse_string(
218        "m-0,1a29.6 29.6 0 01-2 1.5 151.6 151.6 0 01-2.6 1.8"
219    )
220    .unwrap());
221}