use std::cell::Cell;
use lightningcss::{properties::svg::SVGPaint, traits::Zero, values::alpha::AlphaValue};
use oxvg_ast::{
element::Element,
get_computed_style, has_computed_style, is_attribute, set_attribute,
style::{ComputedStyles, Mode},
visitor::{Context, ContextFlags, PrepareOutcome, Visitor},
};
use oxvg_collections::{
attribute::{inheritable::Inheritable, AttrId},
element::ElementCategory,
is_prefix,
};
#[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"))]
pub struct RemoveUselessStrokeAndFill {
#[cfg_attr(feature = "serde", serde(default = "default_stroke"))]
pub stroke: bool,
#[cfg_attr(feature = "serde", serde(default = "default_fill"))]
pub fill: bool,
#[cfg_attr(feature = "serde", serde(default = "default_remove_none"))]
pub remove_none: bool,
}
struct State<'o> {
options: &'o RemoveUselessStrokeAndFill,
id_rc_byte: Cell<Option<usize>>,
}
impl<'input, 'arena> Visitor<'input, 'arena> for RemoveUselessStrokeAndFill {
type Error = JobsError<'input>;
fn prepare(
&self,
document: &Element<'input, 'arena>,
context: &mut Context<'input, 'arena, '_>,
) -> Result<PrepareOutcome, Self::Error> {
State {
options: self,
id_rc_byte: Cell::new(None),
}
.start_with_context(document, context)?;
Ok(PrepareOutcome::skip)
}
}
impl<'input, 'arena> Visitor<'input, 'arena> for State<'_> {
type Error = JobsError<'input>;
fn prepare(
&self,
document: &Element<'input, 'arena>,
context: &mut Context<'input, 'arena, '_>,
) -> Result<PrepareOutcome, Self::Error> {
context.query_has_script(document);
context.query_has_stylesheet(document);
Ok(
if context.flags.intersects(
ContextFlags::query_has_stylesheet_result | ContextFlags::query_has_script_result,
) {
PrepareOutcome::skip
} else {
PrepareOutcome::none
},
)
}
fn element(
&self,
element: &Element<'input, 'arena>,
context: &mut Context<'input, 'arena, '_>,
) -> Result<(), Self::Error> {
if self.id_rc_byte.get().is_some() {
return Ok(());
}
if element.has_attribute(&AttrId::Id) {
log::debug!("flagged as id root");
self.id_rc_byte.set(Some(element.id()));
return Ok(());
}
if !element
.qual_name()
.categories()
.intersects(ElementCategory::Shape)
{
return Ok(());
}
let computed_styles = ComputedStyles::default()
.with_all(element, &context.query_has_stylesheet_result)
.map_err(JobsError::ComputedStylesError)?;
self.remove_stroke(element, &computed_styles);
self.remove_fill(element, &computed_styles);
Ok(())
}
fn exit_element(
&self,
element: &Element<'input, 'arena>,
_context: &mut Context<'input, 'arena, '_>,
) -> Result<(), Self::Error> {
if self.id_rc_byte.get().is_some_and(|b| b == element.id()) {
log::debug!("unflagged as id root");
self.id_rc_byte.set(None);
}
Ok(())
}
}
impl State<'_> {
fn remove_stroke<'input>(
&self,
element: &Element<'input, '_>,
computed_styles: &ComputedStyles<'input>,
) {
if !self.options.stroke {
return;
}
let stroke_width = get_computed_style!(computed_styles, StrokeWidth);
let is_stroke_width_zero = stroke_width.is_some_and(|(stroke_width, mode)| {
if matches!(mode, Mode::Dynamic) {
return false;
}
if let Inheritable::Defined(length) = stroke_width {
length.is_zero()
} else {
false
}
});
if !is_stroke_width_zero && has_computed_style!(computed_styles, MarkerEnd) {
log::debug!("skipping stroke removal, has marker");
return;
}
let stroke = get_computed_style!(computed_styles, Stroke);
let mut is_stroke_eq_none = stroke
.as_ref()
.is_some_and(|(stroke, _)| matches!(stroke.option_ref(), Some(SVGPaint::None)));
let is_stroke_none =
stroke.is_none_or(|(_, mode)| matches!(mode, Mode::Static) && is_stroke_eq_none);
let stroke_opacity = get_computed_style!(computed_styles, StrokeOpacity);
let is_stroke_opacity_zero = stroke_opacity.is_some_and(|(stroke_opacity, mode)| {
matches!(mode, Mode::Static)
&& matches!(stroke_opacity, Inheritable::Defined(AlphaValue(0.0)))
});
let stroke_width = get_computed_style!(computed_styles, StrokeWidth);
let is_stroke_width_zero = stroke_width.is_some_and(|(stroke_width, mode)| {
if matches!(mode, Mode::Dynamic) {
return false;
}
if let Inheritable::Defined(length) = stroke_width {
length.is_zero()
} else {
false
}
});
if is_stroke_none || is_stroke_opacity_zero || is_stroke_width_zero {
log::debug!("removing useless stroke");
log::debug!("stroke none: {is_stroke_none}");
log::debug!("stroke opacity zero: {is_stroke_opacity_zero}");
log::debug!("stroke width zero: {is_stroke_width_zero}");
element
.attributes()
.retain(|attr| !is_prefix!(attr, SVG) || !attr.local_name().starts_with("stroke"));
if let Some((parent_stroke, mode)) = computed_styles.get_inherited("stroke") {
if matches!(mode, Mode::Static)
&& !is_attribute!(parent_stroke, Stroke(Inheritable::Defined(SVGPaint::None)))
{
log::debug!("stroke is also inherited, setting to `none`");
set_attribute!(element, Stroke(Inheritable::Defined(SVGPaint::None)));
is_stroke_eq_none = true;
}
}
}
if is_stroke_eq_none && self.options.remove_none {
log::debug!("removing element with no stroke");
element.remove();
}
}
fn remove_fill<'input>(
&self,
element: &Element<'input, '_>,
computed_styles: &ComputedStyles<'input>,
) {
if !self.options.fill {
return;
}
let fill = get_computed_style!(computed_styles, Fill);
let mut is_fill_eq_none = fill.as_ref().is_some_and(|fill| {
matches!(fill, (Inheritable::Defined(SVGPaint::None), Mode::Static))
});
let is_fill_none = fill
.as_ref()
.is_some_and(|(_, mode)| matches!(mode, Mode::Static) && is_fill_eq_none);
let fill_opacity = get_computed_style!(computed_styles, FillOpacity);
let is_fill_opacity_zero = fill_opacity
.is_some_and(|s| matches!(s, (Inheritable::Defined(AlphaValue(0.0)), Mode::Static)));
if is_fill_none || is_fill_opacity_zero {
log::debug!("removing useless fill");
log::debug!("fill none: {is_fill_none}");
log::debug!("fill opacity zero: {is_fill_opacity_zero}");
element
.attributes()
.retain(|attr| !is_prefix!(attr, SVG) || !attr.local_name().starts_with("fill-"));
if fill.is_none() || !is_fill_eq_none {
set_attribute!(element, Fill(Inheritable::Defined(SVGPaint::None)));
is_fill_eq_none = true;
}
}
if is_fill_eq_none && self.options.remove_none {
log::debug!("removing element with no fill");
element.remove();
}
}
}
impl Default for RemoveUselessStrokeAndFill {
fn default() -> Self {
RemoveUselessStrokeAndFill {
stroke: default_stroke(),
fill: default_fill(),
remove_none: default_remove_none(),
}
}
}
const fn default_stroke() -> bool {
true
}
const fn default_fill() -> bool {
true
}
const fn default_remove_none() -> bool {
false
}
#[test]
fn remove_useless_stroke_and_fill() -> anyhow::Result<()> {
use crate::test_config;
insta::assert_snapshot!(test_config(
r#"{ "removeUselessStrokeAndFill": {} }"#,
Some(
r##"<svg xmlns="http://www.w3.org/2000/svg">
<!-- don't affect elements within id'd element -->
<defs>
<g id="test">
<rect stroke-dashoffset="5" width="100" height="100"/>
</g>
</defs>
<!-- remove useless strokes/fills -->
<circle fill="red" stroke-width="6" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
<circle fill="red" stroke="#000" stroke-width="6" stroke-dashoffset="5" stroke-opacity="0" cx="60" cy="60" r="50"/>
<circle fill="red" stroke="#000" stroke-width="0" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
<circle fill="red" stroke="#000" stroke-width="6" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
<!-- replace useless strokes with "none" when inherited stroke will replace it -->
<g stroke="#000" stroke-width="6">
<circle fill="red" stroke="red" stroke-width="0" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
<circle fill="red" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
</g>
<g stroke="#000">
<circle fill="red" stroke-width="0" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
<circle fill="red" stroke="none" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
</g>
</svg>"##
),
)?);
insta::assert_snapshot!(test_config(
r#"{ "removeUselessStrokeAndFill": {} }"#,
Some(
r#"<svg xmlns="http://www.w3.org/2000/svg">
<!-- remove useless fills -->
<defs>
<g id="test">
<rect fill-opacity=".5" width="100" height="100"/>
</g>
</defs>
<circle fill="none" fill-rule="evenodd" cx="60" cy="60" r="50"/>
<circle fill="red" fill-opacity="0" cx="90" cy="90" r="50"/>
<circle fill-opacity="0" fill-rule="evenodd" cx="90" cy="60" r="50"/>
<circle fill="red" fill-opacity=".5" cx="60" cy="60" r="50"/>
<g fill="none">
<circle fill-opacity=".5" cx="60" cy="60" r="50"/>
</g>
</svg>"#
),
)?);
insta::assert_snapshot!(test_config(
r#"{ "removeUselessStrokeAndFill": {} }"#,
Some(
r##"<svg xmlns="http://www.w3.org/2000/svg">
<!-- ignore documents with `style` -->
<style>
* {}
</style>
<circle fill="none" fill-rule="evenodd" cx="60" cy="60" r="50"/>
<circle fill-opacity="0" fill-rule="evenodd" cx="90" cy="60" r="50"/>
<circle fill="red" stroke-width="6" stroke-dashoffset="5" cx="60" cy="60" r="50"/>
<circle fill="red" stroke="#000" stroke-width="6" stroke-dashoffset="5" stroke-opacity="0" cx="60" cy="60" r="50"/>
</svg>"##
),
)?);
insta::assert_snapshot!(test_config(
r#"{ "removeUselessStrokeAndFill": {} }"#,
Some(
r#"<svg width="480" height="360" xmlns="http://www.w3.org/2000/svg">
<!-- don't remove stroke when useful stroke-width and marker-end is on element -->
<defs>
<marker id="testMarker">
<rect width="100" height="100" fill="blue" />
</marker>
</defs>
<line x1="150" y1="150" x2="165" y2="150" stroke="red" stroke-width="25" marker-end="url(#testMarker)" />
<line x1="250" y1="250" x2="265" y2="250" stroke="red" stroke-width="0" marker-end="url(#testMarker)" />
</svg>"#
),
)?);
insta::assert_snapshot!(test_config(
r#"{ "removeUselessStrokeAndFill": { "removeNone": true } }"#,
Some(
r#"<svg xmlns="http://www.w3.org/2000/svg">
<!-- remove element with useless stroke/fill -->
<defs>
<g id="test">
<rect fill-opacity=".5" width="100" height="100"/>
</g>
</defs>
<circle fill="none" fill-rule="evenodd" cx="60" cy="60" r="50"/>
<circle fill="red" fill-opacity="0" cx="90" cy="90" r="50"/>
<circle fill-opacity="0" fill-rule="evenodd" cx="90" cy="60" r="50"/>
<circle fill="red" fill-opacity=".5" cx="60" cy="60" r="50"/>
<g fill="none">
<circle fill-opacity=".5" cx="60" cy="60" r="50"/>
</g>
</svg>"#
),
)?);
Ok(())
}