oxvg_optimiser/jobs/
apply_transforms.rs

1use core::ops::Mul;
2use std::f64;
3
4use lightningcss::properties::{
5    svg::{SVGPaint, StrokeDasharray},
6    transform::{Matrix, TransformList},
7};
8use oxvg_ast::{
9    element::Element,
10    get_attribute, get_attribute_mut, get_computed_style, get_computed_style_css, has_attribute,
11    is_attribute,
12    style::{ComputedStyles, Mode},
13    visitor::{Context, PrepareOutcome, Visitor},
14};
15use oxvg_collections::attribute::{
16    core::SVGTransformList,
17    inheritable::Inheritable,
18    presentation::{LengthPercentage, VectorEffect},
19    Attr, AttrId,
20};
21use oxvg_path::{command::Data, convert, Path};
22#[cfg(feature = "serde")]
23use serde::{Deserialize, Serialize};
24
25#[cfg(feature = "wasm")]
26use tsify::Tsify;
27
28use crate::error::JobsError;
29
30#[cfg_attr(feature = "wasm", derive(Tsify))]
31#[cfg_attr(feature = "napi", napi(object))]
32#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
33#[derive(Default, Clone, Debug)]
34#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
35/// Apply transformations of a `transform` attribute to the path data, removing the `transform`
36/// in the process.
37///
38/// # Differences to SVGO
39///
40/// In SVGO this job cannot be enabled individually; it always runs with `convertPathData`.
41///
42/// # Correctness
43///
44/// By default this job should never visually change the document.
45///
46/// When specifying a precision there may be rounding errors affecting the accuracy of documents.
47///
48/// When specifying to apply to apply transforms to a stroked path the stroke may be visually
49/// warped when compared to the original.
50///
51/// # Errors
52///
53/// Never.
54///
55/// If this job produces an error or panic, please raise an [issue](https://github.com/noahbald/oxvg/issues)
56pub struct ApplyTransforms {
57    /// The level of precising at which to round transforms applied to the path data.
58    #[cfg_attr(feature = "wasm", tsify(optional))]
59    pub transform_precision: Option<f64>,
60    /// Whether or not to apply transforms to paths with a stroke.
61    #[cfg_attr(feature = "serde", serde(default = "bool::default"))]
62    pub apply_transforms_stroked: bool,
63}
64
65impl<'input, 'arena> Visitor<'input, 'arena> for ApplyTransforms {
66    type Error = JobsError<'input>;
67
68    fn prepare(
69        &self,
70        document: &Element<'input, 'arena>,
71        context: &mut Context<'input, 'arena, '_>,
72    ) -> Result<PrepareOutcome, Self::Error> {
73        context.query_has_stylesheet(document);
74        context.query_has_script(document);
75        Ok(PrepareOutcome::none)
76    }
77
78    fn element(
79        &self,
80        element: &Element<'input, 'arena>,
81        context: &mut Context<'input, 'arena, '_>,
82    ) -> Result<(), Self::Error> {
83        if !has_attribute!(element, D) {
84            log::debug!("run: path has no d");
85            return Ok(());
86        }
87        for mut attr in element.attributes().into_iter_mut() {
88            if is_attribute!(attr, Id | Style) {
89                log::debug!("run: element has id");
90                return Ok(());
91            }
92
93            let mut references_props = false;
94            let mut value = attr.value_mut();
95            value.visit_id(|_| references_props = true);
96            if references_props {
97                log::debug!("run: element id reference");
98                return Ok(());
99            }
100
101            let mut references_url = false;
102            value.visit_url(|url| references_url = references_url || url.starts_with('#'));
103            if references_url {
104                log::debug!("run: element url reference");
105                return Ok(());
106            }
107        }
108
109        let computed_styles = ComputedStyles::default()
110            .with_all(element, &context.query_has_stylesheet_result)
111            .map_err(JobsError::ComputedStylesError)?;
112        let Some(transform_attr) = get_attribute!(element, Transform) else {
113            log::debug!("run: element has no transform");
114            return Ok(());
115        };
116        let Some(transform) = transform_attr.option_ref() else {
117            log::debug!("run: cannot handle inherit transform");
118            return Ok(());
119        };
120        if transform.0.is_empty() {
121            log::debug!("run: cannot handle empty transform");
122            return Ok(());
123        }
124        if let Some((css_transform, Mode::Static)) =
125            get_computed_style_css!(computed_styles, Transform(None))
126        {
127            if (&css_transform)
128                .try_into()
129                .ok()
130                .is_none_or(|css_transform: SVGTransformList| {
131                    transform.to_matrix_2d() != css_transform.to_matrix_2d()
132                })
133            {
134                log::debug!("run: another transform is applied to this element");
135                return Ok(());
136            }
137        }
138
139        let stroke = get_computed_style!(computed_styles, Stroke);
140        if matches!(stroke, Some((_, Mode::Dynamic))) {
141            log::debug!("run: cannot handle dynamic stroke");
142            return Ok(());
143        }
144
145        let stroke_width = get_computed_style!(computed_styles, StrokeWidth);
146        if matches!(stroke_width, Some((_, Mode::Dynamic))) {
147            log::debug!("run: cannot handle dynamic stroke_width");
148            return Ok(());
149        }
150        let stroke_width = stroke_width.map(|(stroke_width, _)| stroke_width);
151
152        let css_transform: TransformList = transform.clone().into();
153        let Some(matrix) = css_transform.to_matrix() else {
154            log::debug!("run: cannot get matrix");
155            return Ok(());
156        };
157        let Some(matrix) = matrix.to_matrix2d() else {
158            log::debug!("run: cannot handle matrix");
159            return Ok(());
160        };
161        let matrix = matrix32_to_slice(&matrix);
162
163        drop(transform_attr);
164        if let Some((Inheritable::Defined(stroke), Mode::Static)) = stroke {
165            if self.apply_stroked(&matrix, &stroke, stroke_width, element) {
166                return Ok(());
167            }
168        }
169
170        let Some(mut d) = get_attribute_mut!(element, D) else {
171            unreachable!();
172        };
173        let path = &mut d.0;
174        apply_matrix_to_path_data(path, &matrix);
175        convert::cleanup_unpositioned(path);
176        log::debug!("new d <- {path}");
177        drop(d);
178        element.remove_attribute(&AttrId::Transform);
179        Ok(())
180    }
181}
182
183impl ApplyTransforms {
184    #[allow(clippy::float_cmp, clippy::cast_possible_truncation)]
185    fn apply_stroked(
186        &self,
187        matrix: &[f64; 6],
188        stroke: &SVGPaint,
189        stroke_width: Option<Inheritable<LengthPercentage>>,
190        element: &Element,
191    ) -> bool {
192        if matches!(stroke, SVGPaint::None) {
193            return false;
194        }
195        if self.apply_transforms_stroked {
196            log::debug!("apply_stroked: not applying transformed stroke");
197            return true;
198        }
199        if (matrix[0] != matrix[3] || matrix[1] != -matrix[2])
200            && (matrix[0] != -matrix[3] || matrix[1] != matrix[2])
201        {
202            log::debug!("apply_stroked: stroke cannot be applied with disproportional scale/skew");
203            return true;
204        }
205
206        if let Some(vector_effect) = get_attribute!(element, VectorEffect) {
207            if matches!(&*vector_effect, VectorEffect::NonScalingStroke) {
208                return false;
209            }
210        }
211
212        let mut scale = f64::sqrt((matrix[0] * matrix[0]) + (matrix[1] * matrix[1])); // hypot
213        if let Some(transform_precision) = self.transform_precision {
214            scale = f64::round(scale * transform_precision) / transform_precision;
215        }
216        if scale == 1.0 {
217            return false;
218        }
219        let scale = scale as f32;
220
221        let stroke_width = match stroke_width {
222            Some(Inheritable::Defined(value)) => LengthPercentage(value.0.mul(scale)),
223            // NOTE: Default stroke-width value is 1
224            None | Some(Inheritable::Inherited) => LengthPercentage::px(scale),
225        };
226        element.set_attribute(Attr::StrokeWidth(Inheritable::Defined(stroke_width)));
227
228        if let Some(Inheritable::Defined(LengthPercentage(stroke_dashoffset))) =
229            get_attribute_mut!(element, StrokeDashoffset).as_deref_mut()
230        {
231            *stroke_dashoffset = stroke_dashoffset.clone().mul(scale);
232        }
233        if let Some(Inheritable::Defined(StrokeDasharray::Values(stroke_dasharray))) =
234            get_attribute_mut!(element, StrokeDasharray).as_deref_mut()
235        {
236            stroke_dasharray
237                .iter_mut()
238                .for_each(|dash| *dash = dash.clone().mul(scale));
239        }
240
241        false
242    }
243}
244
245fn matrix32_to_slice(matrix: &Matrix<f32>) -> [f64; 6] {
246    [
247        f64::from(matrix.a),
248        f64::from(matrix.b),
249        f64::from(matrix.c),
250        f64::from(matrix.d),
251        f64::from(matrix.e),
252        f64::from(matrix.f),
253    ]
254}
255
256#[allow(clippy::too_many_lines)]
257fn apply_matrix_to_path_data(path_data: &mut Path, matrix: &[f64; 6]) {
258    log::debug!("applying matrix: {:?}", matrix);
259    let mut start = [0.0; 2];
260    let mut cursor = [0.0; 2];
261    if let Some(data) = path_data.0.get_mut(0) {
262        if let Data::MoveBy(args) = data {
263            *data = Data::MoveTo(*args);
264        }
265    }
266
267    path_data.0.iter_mut().for_each(|data| {
268        if let Data::Implicit(_) = data {
269            *data = data.as_explicit().clone();
270        }
271        match data {
272            Data::HorizontalLineTo(args) => *data = Data::LineTo([args[0], cursor[1]]),
273            Data::HorizontalLineBy(args) => *data = Data::LineBy([args[0], 0.0]),
274            Data::VerticalLineTo(args) => *data = Data::LineTo([cursor[0], args[0]]),
275            Data::VerticalLineBy(args) => *data = Data::LineBy([0.0, args[0]]),
276            _ => {}
277        }
278        match data {
279            Data::MoveTo(args) => {
280                cursor[0] = args[0];
281                cursor[1] = args[1];
282                start[0] = cursor[0];
283                start[1] = cursor[1];
284                *args = transform_absolute_point(matrix, args[0], args[1]);
285            }
286            Data::MoveBy(args) => {
287                cursor[0] += args[0];
288                cursor[1] += args[1];
289                start[0] = cursor[0];
290                start[1] = cursor[1];
291                *args = transform_relative_point(matrix, args[0], args[1]);
292            }
293            Data::LineTo(args) | Data::SmoothQuadraticBezierTo(args) => {
294                cursor[0] = args[0];
295                cursor[1] = args[1];
296                *args = transform_absolute_point(matrix, args[0], args[1]);
297            }
298            Data::LineBy(args) | Data::SmoothQuadraticBezierBy(args) => {
299                cursor[0] += args[0];
300                cursor[1] += args[1];
301                *args = transform_relative_point(matrix, args[0], args[1]);
302            }
303            Data::CubicBezierTo(args) => {
304                cursor[0] = args[4];
305                cursor[1] = args[5];
306                let p1 = transform_absolute_point(matrix, args[0], args[1]);
307                let p2 = transform_absolute_point(matrix, args[2], args[3]);
308                let p = transform_absolute_point(matrix, args[4], args[5]);
309                *args = [p1[0], p1[1], p2[0], p2[1], p[0], p[1]];
310            }
311            Data::CubicBezierBy(args) => {
312                cursor[0] += args[4];
313                cursor[1] += args[5];
314                let p1 = transform_relative_point(matrix, args[0], args[1]);
315                let p2 = transform_relative_point(matrix, args[2], args[3]);
316                let p = transform_relative_point(matrix, args[4], args[5]);
317                *args = [p1[0], p1[1], p2[0], p2[1], p[0], p[1]];
318            }
319            Data::SmoothBezierTo(args) | Data::QuadraticBezierTo(args) => {
320                cursor[0] = args[2];
321                cursor[1] = args[3];
322                let p1 = transform_absolute_point(matrix, args[0], args[1]);
323                let p = transform_absolute_point(matrix, args[2], args[3]);
324                *args = [p1[0], p1[1], p[0], p[1]];
325            }
326            Data::SmoothBezierBy(args) | Data::QuadraticBezierBy(args) => {
327                cursor[0] += args[2];
328                cursor[1] += args[3];
329                let p1 = transform_relative_point(matrix, args[0], args[1]);
330                let p = transform_relative_point(matrix, args[2], args[3]);
331                *args = [p1[0], p1[1], p[0], p[1]];
332            }
333            Data::ArcTo(args) => {
334                transform_arc(cursor, args, matrix);
335                cursor[0] = args[5];
336                cursor[1] = args[6];
337                if f64::abs(args[2]) > 80.0 {
338                    args.swap(0, 1);
339                    args[2] += if args[2] > 0.0 { -90.0 } else { 90.0 };
340                }
341                let p = transform_absolute_point(matrix, args[5], args[6]);
342                args[5] = p[0];
343                args[6] = p[1];
344            }
345            Data::ArcBy(args) => {
346                transform_arc([0.0; 2], args, matrix);
347                cursor[0] += args[5];
348                cursor[1] += args[6];
349                if f64::abs(args[2]) > 80.0 {
350                    args.swap(0, 1);
351                    args[2] += if args[2] > 0.0 { -90.0 } else { 90.0 };
352                }
353                let p = transform_relative_point(matrix, args[5], args[6]);
354                args[5] = p[0];
355                args[6] = p[1];
356            }
357            Data::ClosePath => {
358                cursor[0] = start[0];
359                cursor[1] = start[1];
360            }
361            Data::HorizontalLineBy(_)
362            | Data::HorizontalLineTo(_)
363            | Data::VerticalLineBy(_)
364            | Data::VerticalLineTo(_)
365            | Data::Implicit(_) => {
366                unreachable!("Reached destroyed command type")
367            }
368        }
369    });
370}
371
372fn transform_absolute_point(matrix: &[f64; 6], x: f64, y: f64) -> [f64; 2] {
373    [
374        matrix[0] * x + matrix[2] * y + matrix[4],
375        matrix[1] * x + matrix[3] * y + matrix[5],
376    ]
377}
378
379fn transform_relative_point(matrix: &[f64; 6], x: f64, y: f64) -> [f64; 2] {
380    [matrix[0] * x + matrix[2] * y, matrix[1] * x + matrix[3] * y]
381}
382
383fn transform_arc(cursor: [f64; 2], args: &mut [f64; 7], matrix: &[f64; 6]) {
384    let x = args[5] - cursor[0];
385    let y = args[6] - cursor[1];
386    let [a, b, cos, sin] = rotated_ellipse(args, [x, y]);
387
388    let ellipse = [a * cos, a * sin, -b * sin, b * cos, 0.0, 0.0];
389    let new_matrix = multiply_transform_matrices(matrix, ellipse);
390    let last_col = new_matrix[2] * new_matrix[2] + new_matrix[3] * new_matrix[3];
391    let square_sum = new_matrix[0] * new_matrix[0] + new_matrix[1] * new_matrix[1] + last_col;
392    let root = f64::hypot(new_matrix[0] - new_matrix[3], new_matrix[1] + new_matrix[2])
393        * f64::hypot(new_matrix[0] + new_matrix[3], new_matrix[1] - new_matrix[2]);
394
395    if root == 0.0 {
396        args[0] = f64::sqrt(square_sum / 2.0);
397        args[1] = args[0];
398        args[2] = 0.0;
399    } else {
400        let major_axis_square = f64::midpoint(square_sum, root);
401        let minor_axis_square = (square_sum - root) / 2.0;
402        let major = f64::abs(major_axis_square - last_col) > 1e-6;
403        let sub = if major {
404            major_axis_square
405        } else {
406            minor_axis_square
407        } - last_col;
408        let rows_sum = new_matrix[0] * new_matrix[2] + new_matrix[1] * new_matrix[3];
409        let term_1 = new_matrix[0] * sub + new_matrix[2] * rows_sum;
410        let term_2 = new_matrix[1] * sub + new_matrix[3] * rows_sum;
411        let term = if major { term_1 } else { term_2 };
412        args[0] = major_axis_square.sqrt();
413        args[1] = minor_axis_square.sqrt();
414        let term_sign = if (major && term_2 < 0.0) || (!major && term_1 > 0.0) {
415            -1.0
416        } else {
417            1.0
418        };
419        args[2] = (term_sign * f64::acos(term / f64::hypot(term_1, term_2)) * 180.0)
420            / std::f64::consts::PI;
421    }
422
423    if (matrix[0] < 0.0) != (matrix[3] < 0.0) {
424        args[4] = 1.0 - args[4];
425    }
426}
427
428fn rotated_ellipse(args: &mut [f64; 7], point: [f64; 2]) -> [f64; 4] {
429    let rotation = (args[2] * std::f64::consts::PI) / 180.0;
430    let cos = f64::cos(rotation);
431    let sin = f64::sin(rotation);
432
433    let mut a = args[0];
434    let mut b = args[1];
435    if a > 0.0 && b > 0.0 {
436        let h = (point[0] * cos + point[1] * sin).powi(2) / (4.0 * a * a)
437            + (point[1] * cos - point[0] * sin).powi(2) / (4.0 * b * b);
438        if h > 1.0 {
439            let h = h.sqrt();
440            a *= h;
441            b *= h;
442        }
443    }
444    [a, b, cos, sin]
445}
446
447fn multiply_transform_matrices(matrix: &[f64; 6], ellipse: [f64; 6]) -> [f64; 6] {
448    [
449        matrix[0] * ellipse[0] + matrix[2] * ellipse[1],
450        matrix[1] * ellipse[0] + matrix[3] * ellipse[1],
451        matrix[0] * ellipse[2] + matrix[2] * ellipse[3],
452        matrix[1] * ellipse[2] + matrix[3] * ellipse[3],
453        matrix[0] * ellipse[4] + matrix[2] * ellipse[5] + matrix[4],
454        matrix[1] * ellipse[4] + matrix[3] * ellipse[5] + matrix[5],
455    ]
456}
457
458#[test]
459#[allow(clippy::too_many_lines)]
460fn apply_transforms() -> anyhow::Result<()> {
461    use crate::test_config;
462
463    insta::assert_snapshot!(test_config(
464        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
465        Some(
466            r##"<svg xmlns="http://www.w3.org/2000/svg">
467    <path transform="translate(100,0)" d="M0,0 V100 L 70,50 z M70,50 L140,0 V100 z"/>
468    <path transform="" d="M0,0 V100 L 70,50 z M70,50 L140,0 V100 z"/>
469    <path fill="red" transform="rotate(15) scale(.5) skewX(5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
470    <path fill="red" stroke="red" transform="rotate(15) scale(.5) skewX(5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
471    <path fill="red" stroke="red" transform="rotate(15) scale(.5) skewX(5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 a150,150 0 1,0 150,-150 z"/>
472    <path fill="red" stroke="red" transform="rotate(15) scale(.5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
473    <path fill="red" stroke="red" transform="rotate(15) scale(1.5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
474    <path fill="red" stroke="red" transform="rotate(15) scale(0.33) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
475    <g stroke="red">
476        <path fill="red" transform="rotate(15) scale(.5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
477    </g>
478    <g stroke="red" stroke-width="2">
479        <path fill="red" transform="rotate(15) scale(.5) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
480    </g>
481    <path transform="scale(10)" id="a" d="M0,0 V100 L 70,50 z M70,50 L140,0 V100 z"/>
482    <path transform="scale(10)" id="a" d="M0,0 V100 L 70,50 z M70,50 L140,0 V100 z" stroke="#000"/>
483    <path transform="scale(10)" id="a" d="M0,0 V100 L 70,50 z M70,50 L140,0 V100 z" stroke="#000" stroke-width=".5"/>
484    <g stroke="#000" stroke-width="5">
485        <path transform="scale(10)" id="a" d="M0,0 V100 L 70,50 z M70,50 L140,0 V100 z"/>
486    </g>
487    <path fill="url(#gradient)" transform="rotate(15) scale(0.33) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
488    <path clip-path="url(#a)" transform="rotate(15) scale(0.33) translate(200,100)" d="M100,200 300,400 H100 V300 C100,100 250,100 250,200 S400,300 400,200 Q400,50 600,300 T1000,300 z"/>
489    <path d="M5 0a10 10 0 1 0 20 0" transform="matrix(1 0 0 1 5 0)"/>
490    <path d="M5 0a10 10 0 1 0 20 0" transform="rotate(15) scale(.8,1.2) "/>
491    <path d="M5 0a10 10 0 1 0 20 0" transform="rotate(45)"/>
492    <path d="M5 0a10 10 0 1 0 20 0" transform="skewX(45)"/>
493    <path d="M0 300a1 2 0 1 0 200 0a1 2 0 1 0 -200 0" transform="rotate(15 100 300) scale(.8 1.2)"/>
494    <path d="M0 300a1 2 0 1 0 200 0a1 2 0 1 0 -200 0" transform="rotate(15 100 300)"/>
495    <path d="M700 300a1 2 0 1 0 200 0a1 2 0 1 0 -200 0" transform="rotate(-75 700 300) scale(.8 1.2)"/>
496    <path d="M12.6 8.6l-3.1-3.2-3.1 3.2-.8-.7 3.9-3.9 3.9 3.9zM9 5h1v10h-1z" transform="rotate(-90 9.5 9.5)"/>
497    <path d="M637.43 482.753a43.516 94.083 0 1 1-87.033 0 43.516 94.083 0 1 1 87.032 0z" transform="matrix(1.081 .234 -.187 .993 -37.573 -235.766)"/>
498    <path d="m-1.26-1.4a6.53 1.8-15.2 1 1 12.55-3.44" transform="translate(0, 0)"/>
499    <path d="M0 0c.07 1.33.14 2.66.21 3.99.07 1.33.14 2.66.21 3.99"/>
500</svg>"##
501        )
502    )?);
503
504    insta::assert_snapshot!(test_config(
505        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
506        Some(
507            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
508    <path d="M32 4a4 4 0 0 0-4-4H8a4 4 0 0 1-4 4v28a4 4 0 0 1 4 4h20a4 4 0 0 0 4-4V4z" fill="#888" transform="matrix(1 0 0 -1 0 36)"/>
509</svg>"##
510        )
511    )?);
512
513    insta::assert_snapshot!(test_config(
514        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
515        Some(
516            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
517    <path transform="translate(250, 250) scale(1.5, 1.5) translate(-250, -250)" fill="#7ED321" stroke="#000" stroke-width="15" vector-effect="non-scaling-stroke" d="M125 125h250v250h-250v-250z"/>
518</svg>"##
519        )
520    )?);
521
522    insta::assert_snapshot!(test_config(
523        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
524        Some(
525            r#"<svg width="480" height="360" xmlns="http://www.w3.org/2000/svg">
526  <path transform="scale(1.8)" stroke="black" stroke-width="10" fill="none" stroke-dasharray="none" d="   M  20 20   L  200 20"/>
527  <path transform="scale(1.8)" stroke="black" stroke-width="10" fill="none" stroke-dasharray="0" d="   M  20 40   L  200 40"/>
528  <path transform="scale(1.8)" stroke="black" stroke-width="20" fill="none" stroke-dasharray="5,2,5,5,2,5" d="   M  20 60   L  200 60"/>
529  <path transform="scale(1.8)" stroke="blue" stroke-width="10" fill="none" stroke-dasharray="5,2,5" d="   M  20 60   L  200 60"/>
530  <path transform="scale(1.8)" stroke="black" stroke-width="10" fill="none" stroke-dasharray="2" d="   M  20 80   L  200 80"/>
531  <path transform="scale(1.8)" stroke="blue" stroke-width="10" fill="none" stroke-dasharray="2" stroke-dashoffset="2" d="         M  20 90   L  200 90"/>
532</svg>"#
533        )
534    )?);
535
536    insta::assert_snapshot!(test_config(
537        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
538        Some(
539            r#"<svg width="200" height="100">
540  <path transform="scale(2)" style="stroke:black;stroke-width:10;" d="M 20 20 H 80" />
541  <path transform="scale(2)" stroke="black" stroke-width="10" d="M 20 20 H 80" />
542</svg>"#
543        )
544    )?);
545
546    insta::assert_snapshot!(test_config(
547        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
548        Some(
549            r#"<svg width="1200" height="1200">
550  <path transform="translate(100) scale(2)" d="m200 200 h-100 a100 100 0 1 0 100 -100 z"/>
551  <path transform="translate(100) scale(2)" d="M400 200 H300 A100 100 0 1 0 400 100 z"/>
552</svg>"#
553        )
554    )?);
555
556    insta::assert_snapshot!(test_config(
557        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
558        Some(
559            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" fill="#E7DACB">
560  <path
561    d="
562      M 152 65
563      V 158
564      H 49
565      V 65
566      z
567      m -14 75
568      V 83
569      H 67
570      V 141
571      z
572    "
573    transform="translate(-24, -41)"
574  />
575</svg>"##
576        )
577    )?);
578
579    insta::assert_snapshot!(test_config(
580        r#"{ "applyTransforms": {}, "convertPathData": {} }"#,
581        Some(
582            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.6 31.6">
583  <path d="m5.25,2.2H25.13a0,0,0,0,1-.05-.05V14.18Z" transform="translate(0 0)"/>
584</svg>"#
585        )
586    )?);
587
588    Ok(())
589}