oxvg_optimiser 0.0.5

The OXVG optimiser is library for optimising SVG documents.
Documentation
use oxvg_ast::{
    element::Element,
    has_attribute, has_computed_style, is_element,
    style::ComputedStyles,
    visitor::{Context, PrepareOutcome, Visitor},
};
use oxvg_collections::element::ElementCategory;
#[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(transparent))]
/// Removes container elements with no functional children or meaningful attributes.
///
/// # Correctness
///
/// This job shouldn't visually change the document. Removing whitespace may have
/// an effect on `inline` or `inline-block` elements.
///
/// # Errors
///
/// Never.
///
/// If this job produces an error or panic, please raise an [issue](https://github.com/noahbald/oxvg/issues)
pub struct RemoveEmptyContainers(pub bool);

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

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

    fn exit_element(
        &self,
        element: &Element<'input, 'arena>,
        context: &mut Context<'input, 'arena, '_>,
    ) -> Result<(), Self::Error> {
        let name = element.qual_name();

        if !name.categories().contains(ElementCategory::Container) || !element.is_empty() {
            return Ok(());
        }
        if is_element!(element, Svg) {
            return Ok(());
        } else if is_element!(element, Pattern) {
            if !element.attributes().is_empty() {
                return Ok(());
            }
        } else if is_element!(element, Mask) {
            if has_attribute!(element, Id) {
                return Ok(());
            }
        } else if element
            .parent_element()
            .is_some_and(|e| is_element!(e, Switch))
        {
            return Ok(());
        }
        if is_element!(element, G) {
            let computed_styles = ComputedStyles::default()
                .with_all(element, &context.query_has_stylesheet_result)
                .map_err(JobsError::ComputedStylesError)?;
            if has_computed_style!(computed_styles, Filter) {
                return Ok(());
            }
        }

        element.remove();
        Ok(())
    }
}

impl Default for RemoveEmptyContainers {
    fn default() -> Self {
        Self(true)
    }
}

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

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg">
    <!-- remove empty containers -->
    <pattern/>
    <g>
        <marker>
            <a/>
        </marker>
    </g>
    <path d="..."/>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <!-- preserve non-empty containers -->
    <defs>
        <pattern id="a">
            <rect/>
        </pattern>
        <pattern xlink:href="url(#a)" id="b"/>
    </defs>
    <g>
        <marker>
            <a/>
        </marker>
        <path d="..."/>
    </g>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:x="http://www.w3.org/1999/xlink">
    <!-- preserve non-empty containers -->
    <defs>
        <pattern id="a">
            <rect/>
        </pattern>
        <pattern x:href="url(#a)" id="b"/>
    </defs>
    <g>
        <marker>
            <a/>
        </marker>
        <path d="..."/>
    </g>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r#"<svg>
    <!-- preserve non-empty containers -->
    <defs>
        <filter id="feTileFilter" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="115" y="40" width="250" height="250">
            <feFlood x="115" y="40" width="54" height="19" flood-color="lime"/>
            <feOffset x="115" y="40" width="50" height="25" dx="6" dy="6" result="offset"/>
            <feTile/>
        </filter>
    </defs>
    <g filter="url(#feTileFilter)"/>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r#"<svg width="480" height="360" xmlns="http://www.w3.org/2000/svg">
    <!-- preserve id'd mask -->
    <mask id="testMask" />
    <rect x="100" y="100" width="250" height="150" fill="green" />
    <rect x="100" y="100" width="250" height="150" fill="red" mask="url(#testMask)" />
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 462 352">
    <!-- preserve children of `switch` -->
    <switch>
        <g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
        <a transform="translate(0,-5)" href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
            <text text-anchor="middle" font-size="10px" x="50%" y="100%">Viewer does not support full SVG 1.1</text>
        </a>
    </switch>
</svg>"#
        ),
    )?);

    insta::assert_snapshot!(test_config(
        r#"{ "removeEmptyContainers": true }"#,
        Some(
            r##"<svg viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
    <!-- preserve filtered `g`s -->
    <filter id="a" x="0" y="0" width="50" height="50" filterUnits="userSpaceOnUse">
        <feFlood flood-color="#aaa"/>
    </filter>
    <mask id="b" x="0" y="0" width="50" height="50">
        <g style="filter: url(#a)"/>
    </mask>
    <text x="16" y="16" style="mask: url(#b)">•ᴗ•</text>
</svg>"##
        ),
    )?);

    Ok(())
}