use crate::Plugin;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vexy_vsvg::ast::{Document, Element, Node};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[derive(Default)]
pub struct RemoveOffCanvasPathsConfig {}
#[cfg(test)]
vexy_vsvg_test_utils::plugin_fixture_tests!(RemoveOffCanvasPathsPlugin, "removeOffCanvasPaths");
#[derive(Debug, Clone, Copy)]
struct ViewBox {
x: f64,
y: f64,
width: f64,
height: f64,
}
pub struct RemoveOffCanvasPathsPlugin {
#[allow(dead_code)]
config: RemoveOffCanvasPathsConfig,
}
impl RemoveOffCanvasPathsPlugin {
pub fn new() -> Self {
Self {
#[allow(dead_code)]
config: RemoveOffCanvasPathsConfig::default(),
}
}
pub fn with_config(config: RemoveOffCanvasPathsConfig) -> Self {
Self { config }
}
fn parse_config(params: &Value) -> Result<RemoveOffCanvasPathsConfig> {
if params.is_null() {
Ok(RemoveOffCanvasPathsConfig::default())
} else {
serde_json::from_value(params.clone())
.map_err(|e| anyhow::anyhow!("Invalid plugin configuration: {}", e))
}
}
fn get_viewbox(&self, svg_element: &Element) -> Option<ViewBox> {
if svg_element.name != "svg" {
return None;
}
if let Some(viewbox_str) = svg_element.attr("viewBox") {
let parts: Vec<f64> = viewbox_str
.split_whitespace()
.filter_map(|s| s.parse::<f64>().ok())
.collect();
if parts.len() == 4 {
return Some(ViewBox {
x: parts[0],
y: parts[1],
width: parts[2],
height: parts[3],
});
}
}
let width = self.get_numeric_attr(svg_element, "width");
let height = self.get_numeric_attr(svg_element, "height");
if let (Some(w), Some(h)) = (width, height) {
Some(ViewBox {
x: 0.0,
y: 0.0,
width: w,
height: h,
})
} else {
None
}
}
fn process_element(&self, element: &mut Element, viewbox: &ViewBox, parent_transformed: bool) {
let element_transformed =
parent_transformed || element.attributes.contains_key("transform");
let mut i = 0;
while i < element.children.len() {
if let Node::Element(ref mut child_elem) = &mut element.children[i] {
let child_has_transform = child_elem.attributes.contains_key("transform");
let effectively_transformed = element_transformed || child_has_transform;
if !effectively_transformed && self.is_outside_viewbox(child_elem, viewbox) {
element.children.remove(i);
continue; }
self.process_element(child_elem, viewbox, effectively_transformed);
}
i += 1;
}
}
fn is_outside_viewbox(&self, element: &Element, viewbox: &ViewBox) -> bool {
match element.name.as_ref() {
"rect" => self.is_rect_outside(element, viewbox),
"circle" => self.is_circle_outside(element, viewbox),
"ellipse" => self.is_ellipse_outside(element, viewbox),
"line" => self.is_line_outside(element, viewbox),
"polygon" | "polyline" => self.is_polygon_outside(element, viewbox),
"path" => self.is_path_outside(element, viewbox),
_ => false, }
}
fn is_rect_outside(&self, element: &Element, viewbox: &ViewBox) -> bool {
let x = self.get_numeric_attr(element, "x").unwrap_or(0.0);
let y = self.get_numeric_attr(element, "y").unwrap_or(0.0);
let width = self.get_numeric_attr(element, "width").unwrap_or(0.0);
let height = self.get_numeric_attr(element, "height").unwrap_or(0.0);
x + width < viewbox.x
|| x > viewbox.x + viewbox.width
|| y + height < viewbox.y
|| y > viewbox.y + viewbox.height
}
fn is_circle_outside(&self, element: &Element, viewbox: &ViewBox) -> bool {
let cx = self.get_numeric_attr(element, "cx").unwrap_or(0.0);
let cy = self.get_numeric_attr(element, "cy").unwrap_or(0.0);
let r = self.get_numeric_attr(element, "r").unwrap_or(0.0);
cx + r < viewbox.x
|| cx - r > viewbox.x + viewbox.width
|| cy + r < viewbox.y
|| cy - r > viewbox.y + viewbox.height
}
fn is_ellipse_outside(&self, element: &Element, viewbox: &ViewBox) -> bool {
let cx = self.get_numeric_attr(element, "cx").unwrap_or(0.0);
let cy = self.get_numeric_attr(element, "cy").unwrap_or(0.0);
let rx = self.get_numeric_attr(element, "rx").unwrap_or(0.0);
let ry = self.get_numeric_attr(element, "ry").unwrap_or(0.0);
cx + rx < viewbox.x
|| cx - rx > viewbox.x + viewbox.width
|| cy + ry < viewbox.y
|| cy - ry > viewbox.y + viewbox.height
}
fn is_line_outside(&self, element: &Element, viewbox: &ViewBox) -> bool {
let x1 = self.get_numeric_attr(element, "x1").unwrap_or(0.0);
let y1 = self.get_numeric_attr(element, "y1").unwrap_or(0.0);
let x2 = self.get_numeric_attr(element, "x2").unwrap_or(0.0);
let y2 = self.get_numeric_attr(element, "y2").unwrap_or(0.0);
(x1 < viewbox.x && x2 < viewbox.x)
|| (x1 > viewbox.x + viewbox.width && x2 > viewbox.x + viewbox.width)
|| (y1 < viewbox.y && y2 < viewbox.y)
|| (y1 > viewbox.y + viewbox.height && y2 > viewbox.y + viewbox.height)
}
fn is_polygon_outside(&self, element: &Element, viewbox: &ViewBox) -> bool {
if let Some(points_str) = element.attr("points") {
let points = self.parse_points(points_str);
if points.is_empty() {
return false;
}
let all_left = points.iter().all(|(x, _)| *x < viewbox.x);
let all_right = points.iter().all(|(x, _)| *x > viewbox.x + viewbox.width);
let all_above = points.iter().all(|(_, y)| *y < viewbox.y);
let all_below = points.iter().all(|(_, y)| *y > viewbox.y + viewbox.height);
all_left || all_right || all_above || all_below
} else {
false
}
}
fn is_path_outside(&self, element: &Element, viewbox: &ViewBox) -> bool {
if let Some(d) = element.attr("d") {
let bounds = self.get_path_bounds(d);
if let Some((min_x, min_y, max_x, max_y)) = bounds {
max_x < viewbox.x
|| min_x > viewbox.x + viewbox.width
|| max_y < viewbox.y
|| min_y > viewbox.y + viewbox.height
} else {
false
}
} else {
false
}
}
fn parse_points(&self, points_str: &str) -> Vec<(f64, f64)> {
let numbers: Vec<f64> = points_str
.split(|c: char| c.is_whitespace() || c == ',')
.filter_map(|s| s.parse::<f64>().ok())
.collect();
numbers
.chunks(2)
.filter_map(|chunk| {
if chunk.len() == 2 {
Some((chunk[0], chunk[1]))
} else {
None
}
})
.collect()
}
fn get_path_bounds(&self, d: &str) -> Option<(f64, f64, f64, f64)> {
use vexy_vsvg::utils::paths::PathUtils;
let commands = PathUtils::parse_path_data(d);
if commands.is_empty() {
return None;
}
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
let mut cur_x = 0.0;
let mut cur_y = 0.0;
let mut start_x = 0.0;
let mut start_y = 0.0;
let mut update_bounds = |x: f64, y: f64| {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
};
for cmd in commands {
let p = &cmd.params;
let c = cmd.command;
if c == 'Z' || c == 'z' {
cur_x = start_x;
cur_y = start_y;
continue;
}
let stride = PathUtils::get_param_count(c);
if stride == 0 {
continue;
}
for (i, chunk) in p.chunks(stride).enumerate() {
if chunk.len() != stride {
break;
}
let effective_cmd = if (c == 'M' || c == 'm') && i > 0 {
if c == 'M' {
'L'
} else {
'l'
}
} else {
c
};
match effective_cmd {
'M' => {
cur_x = chunk[0];
cur_y = chunk[1];
start_x = cur_x;
start_y = cur_y;
update_bounds(cur_x, cur_y);
}
'm' => {
cur_x += chunk[0];
cur_y += chunk[1];
start_x = cur_x;
start_y = cur_y;
update_bounds(cur_x, cur_y);
}
'L' => {
cur_x = chunk[0];
cur_y = chunk[1];
update_bounds(cur_x, cur_y);
}
'l' => {
cur_x += chunk[0];
cur_y += chunk[1];
update_bounds(cur_x, cur_y);
}
'H' => {
cur_x = chunk[0];
update_bounds(cur_x, cur_y);
}
'h' => {
cur_x += chunk[0];
update_bounds(cur_x, cur_y);
}
'V' => {
cur_y = chunk[0];
update_bounds(cur_x, cur_y);
}
'v' => {
cur_y += chunk[0];
update_bounds(cur_x, cur_y);
}
'C' => {
update_bounds(chunk[0], chunk[1]);
update_bounds(chunk[2], chunk[3]);
cur_x = chunk[4];
cur_y = chunk[5];
update_bounds(cur_x, cur_y);
}
'c' => {
update_bounds(cur_x + chunk[0], cur_y + chunk[1]);
update_bounds(cur_x + chunk[2], cur_y + chunk[3]);
cur_x += chunk[4];
cur_y += chunk[5];
update_bounds(cur_x, cur_y);
}
'S' | 'Q' => {
update_bounds(chunk[0], chunk[1]);
cur_x = chunk[2];
cur_y = chunk[3];
update_bounds(cur_x, cur_y);
}
's' | 'q' => {
update_bounds(cur_x + chunk[0], cur_y + chunk[1]);
cur_x += chunk[2];
cur_y += chunk[3];
update_bounds(cur_x, cur_y);
}
'T' => {
cur_x = chunk[0];
cur_y = chunk[1];
update_bounds(cur_x, cur_y);
}
't' => {
cur_x += chunk[0];
cur_y += chunk[1];
update_bounds(cur_x, cur_y);
}
'A' => {
cur_x = chunk[5];
cur_y = chunk[6];
update_bounds(cur_x, cur_y);
}
'a' => {
cur_x += chunk[5];
cur_y += chunk[6];
update_bounds(cur_x, cur_y);
}
_ => {}
}
}
}
if min_x == f64::INFINITY {
None
} else {
Some((min_x, min_y, max_x, max_y))
}
}
fn get_numeric_attr(&self, element: &Element, attr_name: &str) -> Option<f64> {
element.attr(attr_name)?.parse::<f64>().ok()
}
}
impl Default for RemoveOffCanvasPathsPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for RemoveOffCanvasPathsPlugin {
fn name(&self) -> &'static str {
"removeOffCanvasPaths"
}
fn description(&self) -> &'static str {
"removes elements that are drawn outside of the viewBox (disabled by default)"
}
fn validate_params(&self, params: &Value) -> Result<()> {
Self::parse_config(params)?;
Ok(())
}
fn apply(&self, document: &mut Document) -> Result<()> {
if let Some(viewbox) = self.get_viewbox(&document.root) {
self.process_element(&mut document.root, &viewbox, false);
}
Ok(())
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use serde_json::json;
use vexy_vsvg::{parse_svg, stringify_with_config, StringifyConfig};
fn test_plugin(plugin: RemoveOffCanvasPathsPlugin, input: &str, expected: &str) {
let mut doc = parse_svg(input).expect("failed to parse");
plugin.apply(&mut doc).expect("failed to apply");
let config = StringifyConfig {
pretty: false,
..StringifyConfig::default()
};
let output = stringify_with_config(&doc, &config).expect("failed to stringify");
assert_eq!(output, expected);
}
#[test]
fn test_plugin_info() {
let plugin = RemoveOffCanvasPathsPlugin::new();
assert_eq!(plugin.name(), "removeOffCanvasPaths");
assert_eq!(
plugin.description(),
"removes elements that are drawn outside of the viewBox (disabled by default)"
);
}
#[test]
fn test_param_validation() {
let plugin = RemoveOffCanvasPathsPlugin::new();
assert!(plugin.validate_params(&Value::Null).is_ok());
assert!(plugin.validate_params(&json!({})).is_ok());
assert!(plugin
.validate_params(&json!({
"invalidParam": true
}))
.is_err());
}
#[test]
fn test_remove_rect_outside_viewbox() {
let input = r#"<svg viewBox="0 0 100 100">
<rect x="-50" y="10" width="40" height="40"/>
<rect x="110" y="10" width="40" height="40"/>
<rect x="10" y="10" width="40" height="40"/>
<rect x="90" y="10" width="20" height="40"/>
</svg>"#;
let expected = r#"<svg viewBox="0 0 100 100"><rect x="10" y="10" width="40" height="40"/><rect x="90" y="10" width="20" height="40"/></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, expected);
}
#[test]
fn test_remove_circle_outside_viewbox() {
let input = r#"<svg viewBox="0 0 100 100">
<circle cx="-20" cy="50" r="10"/>
<circle cx="50" cy="50" r="30"/>
<circle cx="95" cy="50" r="10"/>
</svg>"#;
let expected = r#"<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="30"/><circle cx="95" cy="50" r="10"/></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, expected);
}
#[test]
fn test_remove_line_outside_viewbox() {
let input = r#"<svg viewBox="0 0 100 100">
<line x1="-20" y1="10" x2="-10" y2="20"/>
<line x1="-10" y1="50" x2="110" y2="50"/>
<line x1="10" y1="10" x2="90" y2="90"/>
</svg>"#;
let expected = r#"<svg viewBox="0 0 100 100"><line x1="-10" y1="50" x2="110" y2="50"/><line x1="10" y1="10" x2="90" y2="90"/></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, expected);
}
#[test]
fn test_remove_polygon_outside_viewbox() {
let input = r#"<svg viewBox="0 0 100 100">
<polygon points="110,10 120,20 115,30"/>
<polygon points="10,10 50,10 30,50"/>
</svg>"#;
let expected = r#"<svg viewBox="0 0 100 100"><polygon points="10,10 50,10 30,50"/></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, expected);
}
#[test]
fn test_no_viewbox_no_removal() {
let input = r#"<svg><rect x="-50" y="10" width="40" height="40"/><circle cx="1000" cy="1000" r="50"/></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, input);
}
#[test]
fn test_nested_elements() {
let input = r#"<svg viewBox="0 0 100 100">
<g>
<rect x="-50" y="10" width="40" height="40"/>
<rect x="10" y="10" width="40" height="40"/>
</g>
</svg>"#;
let expected = r#"<svg viewBox="0 0 100 100"><g><rect x="10" y="10" width="40" height="40"/></g></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, expected);
}
#[test]
fn test_viewbox_with_offset() {
let input = r#"<svg viewBox="50 50 100 100">
<rect x="0" y="0" width="40" height="40"/>
<rect x="60" y="60" width="40" height="40"/>
</svg>"#;
let expected =
r#"<svg viewBox="50 50 100 100"><rect x="60" y="60" width="40" height="40"/></svg>"#;
test_plugin(RemoveOffCanvasPathsPlugin::new(), input, expected);
}
}