oxvg_optimiser 0.0.5

The OXVG optimiser is library for optimising SVG documents.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
use std::cell::{self, RefMut};

use itertools::Itertools as _;
use lightningcss::properties::{
    effects::{Filter, FilterList},
    svg::SVGPaint,
};
use oxvg_ast::{
    element::Element,
    get_attribute, get_attribute_mut, get_computed_style, has_attribute, has_computed_style,
    has_computed_style_css, is_attribute, is_element, set_attribute,
    style::{ComputedStyles, Mode},
    visitor::{Context, PrepareOutcome, Visitor},
};
use oxvg_collections::attribute::{inheritable::Inheritable, path};
use oxvg_path::command;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

#[cfg(feature = "wasm")]
use tsify::Tsify;

use crate::error::JobsError;

#[cfg_attr(feature = "wasm", derive(Tsify))]
#[cfg_attr(feature = "napi", napi(object))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
/// Merge multiple paths into one
///
/// # Differences to SVGO
///
/// There's no need to specify precision or spacing for path serialization.
///
/// # Correctness
///
/// By default this job should never visually change the document.
///
/// Running with `force` may cause intersecting paths to be incorrectly merged.
///
/// # Errors
///
/// Never.
///
/// If this job produces an error or panic, please raise an [issue](https://github.com/noahbald/oxvg/issues)
pub struct MergePaths {
    #[cfg_attr(feature = "serde", serde(default = "default_force"))]
    /// Whether to merge paths despite intersections
    pub force: bool,
}

impl Default for MergePaths {
    fn default() -> Self {
        MergePaths {
            force: default_force(),
        }
    }
}

impl<'input, 'arena> Visitor<'input, 'arena> for MergePaths {
    type Error = JobsError<'input>;

    fn prepare(
        &self,
        document: &Element<'input, 'arena>,
        context: &mut Context<'input, 'arena, '_>,
    ) -> Result<PrepareOutcome, Self::Error> {
        context.query_has_stylesheet(document);
        Ok(PrepareOutcome::none)
    }

    #[allow(clippy::too_many_lines)]
    fn element(
        &self,
        element: &Element<'input, 'arena>,
        context: &mut Context<'input, 'arena, '_>,
    ) -> Result<(), Self::Error> {
        let mut children = itertools::peek_nth(element.children_iter());
        if children.peek_nth(1).is_none() {
            return Ok(());
        }

        let mut prev_path_data: Option<path::Path> = None;

        for (prev_child, child) in children.tuple_windows() {
            log::debug!("trying to merge {child:?}");
            macro_rules! update_previous_path {
                ($prev_child:ident) => {
                    if let Some(data) = prev_path_data.take() {
                        set_attribute!(prev_child, D(data));
                    }
                    prev_path_data = None;
                };
            }

            if !is_element!(prev_child, Path)
                || !prev_child.is_empty()
                || has_attribute!(prev_child, Id)
            {
                log::debug!("ending merge, prev not a plain path");
                update_previous_path!(prev_child);
                continue;
            }

            if !is_element!(child, Path) || !child.is_empty() {
                log::debug!("ending merge, current not a plain path");
                update_previous_path!(prev_child);
                continue;
            }
            let computed_styles = ComputedStyles::default()
                .with_all(&child, &context.query_has_stylesheet_result)
                .map_err(JobsError::ComputedStylesError)?;
            let Some(mut current_path_data) =
                get_attribute_mut!(child, D).map(|d| RefMut::map(d, |path::Path(d, _)| d))
            else {
                log::debug!("ending merge, current has no `d`");
                update_previous_path!(prev_child);
                continue;
            };
            if let Some(first) = current_path_data.0.first_mut() {
                if let command::Data::MoveBy(data) = first {
                    *first = command::Data::MoveTo(*data);

                    if let Some(second) = current_path_data.0.get_mut(1) {
                        if second.is_implicit() && second.as_explicit().id() != command::ID::LineTo
                        {
                            *second = second.as_explicit().clone();
                        }
                    }
                }
            }
            drop(current_path_data);

            if
                has_computed_style!(
                    computed_styles,
                    MarkerStart | MarkerMid | MarkerEnd | ClipPath | Mask
                )
                || has_computed_style_css!(computed_styles, MaskImage(None))
                || get_computed_style!(computed_styles, Fill).is_some_and(|(fill, mode)| {
                    matches!(mode, Mode::Static)
                        && matches!(fill.option(), Some(SVGPaint::Url { url,.. }) if url.url.starts_with('#'))
                })
                || get_computed_style!(computed_styles, Filter).is_some_and(|(filter, mode)| {
                    matches!(mode, Mode::Static)
                        && matches!(filter, Inheritable::Defined(FilterList::Filters(filters)) if filters.iter().any(|filter| matches!(filter, Filter::Url(url) if url.url.starts_with('#'))))
                })
                || get_computed_style!(computed_styles, Stroke).is_some_and(|(stroke, mode)| {
                    matches!(mode, Mode::Static) && matches!(stroke.option(), Some(SVGPaint::Url { url,.. }) if url.url.starts_with('#'))
                })
            {
                log::debug!("ending merge, has forbidden style or reference");
                update_previous_path!(prev_child);
                continue;
            }

            let prev_attrs = prev_child.attributes();
            let attrs = child.attributes();
            if prev_attrs.len() != attrs.len() {
                log::debug!("ending merge, current attrs length different to prev");
                update_previous_path!(prev_child);
                continue;
            }

            let are_any_attr_diff = attrs.into_iter().any(|a| {
                !is_attribute!(a, D) && prev_attrs.get_named_item(a.name()).is_none_or(|p| *p != *a)
            });
            if are_any_attr_diff {
                log::debug!("ending merge, current attrs equal to prev");
                update_previous_path!(prev_child);
                continue;
            }

            let has_prev_path = prev_path_data.is_some();
            if prev_path_data.is_none() {
                prev_path_data = get_attribute!(prev_child, D).as_deref().cloned();
            }

            let current_path_data = get_attribute!(child, D)
                .map(|d| cell::Ref::map(d, |path::Path(d, _)| d))
                .expect("D previously used");
            if let Some(path::Path(prev_path_data, _)) = &mut prev_path_data {
                if prev_path_data.0.last().is_some_and(|d| {
                    matches!(
                        d.id().as_explicit(),
                        command::ID::MoveTo | command::ID::MoveBy
                    )
                }) {
                    prev_path_data.0.pop();
                }
                if self.force || !prev_path_data.intersects(&current_path_data) {
                    log::debug!("merging, current doesn't intersect prev");
                    prev_path_data.0.extend(current_path_data.0.clone());
                    prev_child.remove();
                    continue;
                }
            }

            log::debug!("ending merge, current doesn't intersect prev");
            if has_prev_path {
                update_previous_path!(prev_child);
            } else {
                prev_path_data = None;
            }
        }
        if let Some(prev_path_data) = prev_path_data {
            set_attribute!(element.last_element_child().unwrap(), D(prev_path_data));
        }

        Ok(())
    }
}

const fn default_force() -> bool {
    false
}

#[test]
#[allow(clippy::too_many_lines)]
fn merge_paths() -> anyhow::Result<()> {
    use crate::test_config;

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r##"<svg xmlns="http://www.w3.org/2000/svg">
    <!-- merge paths without attributes -->
    <path d="M 0,0 z"/>
    <path d="M 10,10 z"/>
    <path d="M 20,20 l 10,10 M 30,0 c 10,0 20,10 20,20"/>
    <path d="M 30,30 z"/>
    <path d="M 30,30 z" fill="#f00"/>
    <path d="M 40,40 z"/>
    <path d="m 50,50 0,10 20,30 40,0"/>
</svg>"##
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r##"<svg xmlns="http://www.w3.org/2000/svg">
    <!-- merge sequence of paths with same attributes -->
    <path d="M 0,0 z" fill="#fff" stroke="#333"/>
    <path d="M 10,10 z" fill="#fff" stroke="#333"/>
    <path d="M 20,20" fill="#fff" stroke="#333"/>
    <path d="M 30,30 z" fill="#fff" stroke="#333"/>
    <path d="M 30,30 z" fill="#f00"/>
    <path d="M 40,40 z"/>
    <path d="m 50,50 z"/>
    <path d="M 40,40"/>
    <path d="m 50,50"/>
    <path d="M 40,40 z" fill="#fff" stroke="#333"/>
    <path d="m 50,50 z" fill="#fff" stroke="#333"/>
    <path d="M 40,40" fill="#fff" stroke="#333"/>
    <path d="m 50,50" fill="#fff" stroke="#333"/>
    <path d="m 50,50 z" fill="#fff" stroke="#333"/>
    <path d="M0 0v100h100V0z" fill="red"/>
    <path d="M200 0v100h100V0z" fill="red"/>
    <path d="M0 0v100h100V0z" fill="blue"/>
    <path d="M200 0v100h100V0zM0 200h100v100H0z" fill="blue"/>
</svg>"##
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg">
    <!-- merge only intersected paths -->
    <path d="M30 0L0 40H60z"/>
    <path d="M0 10H60L30 50z"/>
    <path d="M0 0V50L50 0"/>
    <path d="M0 60L50 10V60"/>
    <g>
        <path d="M100 0a50 50 0 0 1 0 100"/>
        <path d="M25 25H75V75H25z"/>
        <path d="M135 85H185V135H135z"/>
    </g>
    <g>
        <path d="M10 14H7v1h3v-1z"/>
        <path d="M9 21H8v1h1v-1z"/>
    </g>
    <g>
        <path d="M30 32.705V40h10.42L30 32.705z"/>
        <path d="M46.25 34.928V30h-7.04l7.04 4.928z"/>
    </g>
    <g>
        <path d="M20 20H60L100 30"/>
        <path d="M20 20L50 30H100"/>
    </g>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg">
    <path d="M320 60c17.466-8.733 33.76-12.78 46.593-12.484 12.856.297 22.254 4.936 26.612 12.484 4.358 7.548 3.676 18.007-2.494 29.29-6.16 11.26-17.812 23.348-34.107 34.107-16.26 10.735-37.164 20.14-60.72 26.613C272.356 156.473 246.178 160 220 160c-26.18 0-52.357-3.527-75.882-9.99-23.557-6.472-44.462-15.878-60.72-26.613-16.296-10.76-27.95-22.846-34.11-34.108-6.17-11.283-6.85-21.742-2.493-29.29 4.358-7.548 13.756-12.187 26.612-12.484C86.24 47.22 102.535 51.266 120 60c17.426 8.713 36.024 22.114 53.407 39.28C190.767 116.42 206.91 137.33 220 160c13.09 22.67 23.124 47.106 29.29 70.71 6.173 23.638 8.48 46.445 7.313 65.893-1.17 19.49-5.812 35.627-12.485 46.592C237.432 354.18 228.716 360 220 360s-17.432-5.82-24.118-16.805c-6.673-10.965-11.315-27.1-12.485-46.592-1.167-19.448 1.14-42.255 7.314-65.892 6.166-23.604 16.2-48.04 29.29-70.71 13.09-22.67 29.233-43.58 46.593-60.72C283.976 82.113 302.573 68.712 320 60z"/>
    <path d="M280 320l100-173.2h200l100 173.2-100 173.2h-200"/>
    <g>
        <path d="M706.69 299.29c-.764-11.43-6.036-56.734-16.338-71.32 0 0 9.997 14.14 11.095 76.806l5.243-5.486z"/>
        <path d="M705.16 292.54c-5.615-35.752-25.082-67.015-25.082-67.015 7.35 15.128 20.257 53.835 23.64 77.45l2.33-2.24-.888-8.195z"/>
    </g>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r##"<svg xmlns="http://www.w3.org/2000/svg" width="499.25" height="732.44">
    <!-- don't merge paths inheriting forbidden styles -->
    <g fill="#ffe900" fill-rule="evenodd" stroke="#1b1918">
        <g stroke-width="2.52">
            <path d="M373.27 534.98c-8.092-54.74-4.391-98.636 56.127-90.287 77.894 55.595-9.147 98.206-5.311 151.74 21.027 45.08 17.096 66.495-7.512 68.302-17.258 10.998-32.537 13.238-46.236 8.48-.246-1.867-.69-3.845-1.368-5.94l-19.752-40.751c44.709 19.982 82.483-.171 51.564-24.28zm32.16-40.207c-5.449-9.977 3.342-14.397 8.048-3.55 12.4 31.857 6.043 40.206-16.136 72.254l-1.911-2.463c11.558-13.292 20.249-27.75 21.334-39.194.899-9.481-5.973-16.736-11.335-27.048z"/>
            <path d="M407.72 580.04c40.745 49.516-3.991 92.385-40.977 82.64"/>
        </g>
    </g>
</svg>"##
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" width="1221.3" height="1297.3" viewBox="0 0 1145 1216.2">
    <!-- allow merge on paths with equal attributes -->
    <g stroke="gray" stroke-width="1.46">
        <path d="M2236.1 787.25c6.625.191 11.52.01 11.828-2.044-8.189-9.2 8.854-46.86-11.828-48.722-17.83 3.99-6.438 26.66-11.828 48.722-.133 2.352 7.537 2.028 11.828 2.044z" transform="matrix(-.02646 -1.4538 -1.2888 .02985 1465.1 3284.4)"/>
        <path d="M2243.9 787.13c-7.561-19.76 6.33-43.05-7.817-50.642" transform="matrix(-.02646 -1.4538 -1.2888 .02985 1465.1 3284.4)"/>
        <path d="M2238.8 787.31c-4.873-19.48 2.772-37.1-2.667-50.82" transform="matrix(-.02646 -1.4538 -1.2888 .02985 1465.1 3284.4)"/>
        <path d="M2228.3 787.13c4.104-21.9-3.13-44.68 7.817-50.642" transform="matrix(-.02646 -1.4538 -1.2888 .02985 1465.1 3284.4)"/>
        <path d="M2233.4 787.31c-.692-5.383-1.098-39.17 2.667-50.82" transform="matrix(-.02646 -1.4538 -1.2888 .02985 1465.1 3284.4)"/>
    </g>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg width="100" height="100">
    <!-- don't merge where paths lose their ends and markers are rendered incorrectly -->
    <defs>
        <style>
            .a {marker-end: url(#arrowhead_end);}
        </style>
        <marker id="arrowhead_end" markerWidth="10" markerHeight="10" refX="6" refY="3">
            <path d="M 0,0 l 6,3 l -6,3" stroke="black" />
        </marker>
    </defs>
    <path d="M 10,10 h50" stroke="black" marker-end="url(#arrowhead_end)" />
    <path d="M 10,50 h50" stroke="black" marker-end="url(#arrowhead_end)" />
    <path d="M 10,60 h60" stroke="black" class="a" />
    <path d="M 10,70 h60" stroke="black" class="a"/>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
    <!-- don't merge paths with a linearGradient fill -->
    <style>
        path.lg{fill:url(#gradient);}
    </style>
    <linearGradient id="gradient">
        <stop offset="0" stop-color="#ff0000"/>
        <stop offset="1" stop-color="#0000ff"/>
    </linearGradient>
    <path fill="url(#gradient)" d="M 0 0 H 100 V 80 H 0 z"/>
    <path fill="url(#gradient)" d="M 200 0 H 300 V 80 H 200 z"/>
    <path style="fill:url(#gradient)" d="M 0 100 h 100 v 80 H 0 z"/>
    <path style="fill:url(#gradient)" d="M 200 100 H 300 v 80 H 200 z"/>
    <path class="lg" d="M 0 200 h 100 v 80 H 0 z"/>
    <path class="lg" d="M 200 200 H 300 v 80 H 200 z"/>
</svg>"##
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 300 300">
    <!-- don't merge paths with a filter url -->
    <style>
        path.lg{filter:url(#blurMe);}
    </style>
    <filter id="blurMe" x=".1">
        <feGaussianBlur stdDeviation="5"/>
    </filter>
    <path filter="url(#blurMe)" fill="red" d="M 0 0 H 100 V 80 H 0 z"/>
    <path filter="url(#blurMe)" fill="red" d="M 200 0 H 300 V 80 H 200 z"/>
    <path style="filter:url(#blurMe)" fill="red" d="M 0 100 h 100 v 80 H 0 z"/>
    <path style="filter:url(#blurMe)" fill="red" d="M 200 100 H 300 v 80 H 200 z"/>
    <path class="lg" fill="red" d="M 0 200 h 100 v 80 H 0 z"/>
    <path class="lg" fill="red" d="M 200 200 H 300 v 80 H 200 z"/>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 400 400">
    <!-- don't merge paths with a clip-path -->
    <style>
        path.lg{clip-path:url(#myClip);}
    </style>
    <clipPath id="myClip" clipPathUnits="objectBoundingBox">
        <circle cx=".5" cy=".5" r=".5"/>
    </clipPath>
    <path clip-path="url(#myClip)" fill="red" d="M 0 0 H 100 V 80 H 0 z"/>
    <path clip-path="url(#myClip)" fill="red" d="M 200 0 H 300 V 80 H 200 z"/>
    <path style="clip-path:url(#myClip)" fill="red" d="M 0 100 h 100 v 80 H 0 z"/>
    <path style="clip-path:url(#myClip)" fill="red" d="M 200 100 H 300 v 80 H 200 z"/>
    <path class="lg" fill="red" d="M 0 200 h 100 v 80 H 0 z"/>
    <path class="lg" fill="red" d="M 200 200 H 300 v 80 H 200 z"/>
    <path style="clip-path:circle(25%)" fill="red" d="M 0 300 h 100 v 80 H 0 z"/>
    <path style="clip-path:circle(25%)" fill="red" d="M 200 300 H 300 v 80 H 200 z"/>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 400 400">
    <!-- don't merge paths with a mask -->
    <style>
        path.lg{mask:url(#mask);}
    </style>
    <mask id="mask" maskContentUnits="objectBoundingBox">
        <rect fill="white" x="0" y="0" width="100%" height="100%"/>
        <circle fill="black" cx=".5" cy=".5" r=".5"/>
    </mask>
    <path mask="url(#mask)" fill="red" d="M 0 0 H 100 V 80 H 0 z"/>
    <path mask="url(#mask)" fill="red" d="M 200 0 H 300 V 80 H 200 z"/>
    <path style="mask:url(#mask)" fill="red" d="M 0 100 h 100 v 80 H 0 z"/>
    <path style="mask:url(#mask)" fill="red" d="M 200 100 H 300 v 80 H 200 z"/>
    <path class="lg" fill="red" d="M 0 200 h 100 v 80 H 0 z"/>
    <path class="lg" fill="red" d="M 200 200 H 300 v 80 H 200 z"/>
    <path style="mask-image: linear-gradient(to left top,black, transparent)" fill="red" d="M 0 300 h 100 v 80 H 0 z"/>
    <path style="mask-image: linear-gradient(to left top,black, transparent)" fill="red" d="M 200 300 H 300 v 80 H 200 z"/>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "mergePaths": {} }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.764 105.935">
    <path d="M43.119 39.565Zm-.797 3.961c.077.167.257.083.309.177Z"/>
    <path d="m42.38 43.684-.06.019Z"/>
</svg>"#
        ),
    )?);

    Ok(())
}