use crate::Plugin;
use anyhow::Result;
use std::collections::HashSet;
use vexy_vsvg::ast::{Document, Element, Node};
use vexy_vsvg::error::VexyError;
use vexy_vsvg::visitor::Visitor;
pub struct RemoveViewBoxPlugin;
impl RemoveViewBoxPlugin {
pub fn new() -> Self {
Self
}
fn viewbox_elements() -> &'static HashSet<&'static str> {
static VIEWBOX_ELEMENTS: std::sync::OnceLock<HashSet<&'static str>> =
std::sync::OnceLock::new();
VIEWBOX_ELEMENTS.get_or_init(|| ["pattern", "svg", "symbol"].into_iter().collect())
}
fn can_remove_viewbox(element: &Element, is_nested_svg: bool) -> bool {
if element.name == "svg" && is_nested_svg {
return false;
}
let Some(viewbox_attr) = element.attributes.get("viewBox") else {
return false;
};
let Some(width_attr) = element.attributes.get("width") else {
return false;
};
let Some(height_attr) = element.attributes.get("height") else {
return false;
};
let viewbox_parts: Vec<&str> = viewbox_attr
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|part| !part.is_empty())
.collect();
if viewbox_parts.len() != 4 {
return false;
}
if viewbox_parts[0] != "0" || viewbox_parts[1] != "0" {
return false;
}
let width_value = width_attr.strip_suffix("px").unwrap_or(width_attr);
let height_value = height_attr.strip_suffix("px").unwrap_or(height_attr);
viewbox_parts[2] == width_value && viewbox_parts[3] == height_value
}
fn is_formatting_whitespace(text: &str) -> bool {
text.trim().is_empty() && text.chars().any(|c| matches!(c, '\n' | '\r' | '\t'))
}
fn cleanup_formatting_whitespace(nodes: &mut Vec<Node>) {
let has_non_whitespace_content = nodes.iter().any(|node| match node {
Node::Element(_) => true,
Node::Text(text) => !text.trim().is_empty(),
_ => false,
});
if has_non_whitespace_content {
nodes.retain(
|node| !matches!(node, Node::Text(text) if Self::is_formatting_whitespace(text)),
);
}
}
fn cleanup_formatting_whitespace_recursive(element: &mut Element<'_>) {
let preserve = matches!(
element.name.as_ref(),
"text" | "tspan" | "tref" | "textPath" | "altGlyph"
);
for child in &mut element.children {
if let Node::Text(text) = child {
if !preserve && (text.contains('\n') || text.contains('\r') || text.contains('\t'))
{
let trimmed = text.trim();
if !trimmed.is_empty() {
*text = trimmed.to_string().into();
}
}
}
}
Self::cleanup_formatting_whitespace(&mut element.children);
for child in &mut element.children {
if let Node::Element(child_element) = child {
Self::cleanup_formatting_whitespace_recursive(child_element);
}
}
}
}
impl Default for RemoveViewBoxPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for RemoveViewBoxPlugin {
fn name(&self) -> &'static str {
"removeViewBox"
}
fn description(&self) -> &'static str {
"Remove viewBox attribute when possible"
}
fn validate_params(&self, params: &serde_json::Value) -> anyhow::Result<()> {
if let Some(obj) = params.as_object() {
if !obj.is_empty() {
return Err(anyhow::anyhow!(
"removeViewBox plugin does not accept parameters"
));
}
}
Ok(())
}
fn apply(&self, document: &mut Document) -> anyhow::Result<()> {
let mut visitor = ViewBoxRemovalVisitor::new();
vexy_vsvg::visitor::walk_document(&mut visitor, document)?;
Self::cleanup_formatting_whitespace_recursive(&mut document.root);
Ok(())
}
}
struct ViewBoxRemovalVisitor {
element_stack: Vec<String>, }
impl ViewBoxRemovalVisitor {
fn new() -> Self {
Self {
element_stack: Vec::new(),
}
}
fn is_nested_svg(&self, element_name: &str) -> bool {
element_name == "svg" && !self.element_stack.is_empty()
}
}
impl Visitor<'_> for ViewBoxRemovalVisitor {
fn visit_element_enter(&mut self, element: &mut Element<'_>) -> Result<(), VexyError> {
if RemoveViewBoxPlugin::viewbox_elements().contains(element.name.as_ref()) {
let is_nested_svg = self.is_nested_svg(&element.name);
if RemoveViewBoxPlugin::can_remove_viewbox(element, is_nested_svg) {
element.attributes.shift_remove("viewBox");
}
}
self.element_stack.push(element.name.to_string());
Ok(())
}
fn visit_element_exit(&mut self, _element: &mut Element<'_>) -> Result<(), VexyError> {
self.element_stack.pop();
Ok(())
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use serde_json::json;
use std::borrow::Cow;
use vexy_vsvg::ast::{Document, Element, Node};
fn create_element(name: &'static str) -> Element<'static> {
let mut element = Element::new(name);
element.name = Cow::Borrowed(name);
element
}
fn create_element_with_attrs(
name: &'static str,
attrs: &[(&'static str, &'static str)],
) -> Element<'static> {
let mut element = create_element(name);
for (key, value) in attrs {
element
.attributes
.insert(Cow::Borrowed(key), Cow::Borrowed(value));
}
element
}
#[test]
fn test_plugin_creation() {
let plugin = RemoveViewBoxPlugin::new();
assert_eq!(plugin.name(), "removeViewBox");
}
#[test]
fn test_parameter_validation() {
let plugin = RemoveViewBoxPlugin::new();
assert!(plugin.validate_params(&json!({})).is_ok());
assert!(plugin.validate_params(&json!({"param": true})).is_err());
}
#[test]
fn test_viewbox_elements() {
let elements = RemoveViewBoxPlugin::viewbox_elements();
assert!(elements.contains("svg"));
assert!(elements.contains("pattern"));
assert!(elements.contains("symbol"));
assert!(!elements.contains("rect"));
}
#[test]
fn test_can_remove_viewbox() {
let element = create_element_with_attrs(
"svg",
&[
("viewBox", "0 0 100 50"),
("width", "100"),
("height", "50"),
],
);
assert!(RemoveViewBoxPlugin::can_remove_viewbox(&element, false));
let element = create_element_with_attrs(
"svg",
&[
("viewBox", "0 0 100.5 0.5"),
("width", "100.5px"),
("height", "0.5px"),
],
);
assert!(RemoveViewBoxPlugin::can_remove_viewbox(&element, false));
let element = create_element_with_attrs(
"svg",
&[
("viewBox", "0 0 100 50"),
("width", "100"),
("height", "50"),
],
);
assert!(!RemoveViewBoxPlugin::can_remove_viewbox(&element, true));
let element = create_element_with_attrs(
"svg",
&[
("viewBox", "10 10 100 50"),
("width", "100"),
("height", "50"),
],
);
assert!(!RemoveViewBoxPlugin::can_remove_viewbox(&element, false));
let element = create_element_with_attrs(
"svg",
&[
("viewBox", "0 0 100 50"),
("width", "200"),
("height", "50"),
],
);
assert!(!RemoveViewBoxPlugin::can_remove_viewbox(&element, false));
let element =
create_element_with_attrs("svg", &[("viewBox", "0 0 100 50"), ("width", "100")]);
assert!(!RemoveViewBoxPlugin::can_remove_viewbox(&element, false));
}
#[test]
fn test_visitor_nesting_detection() {
let mut visitor = ViewBoxRemovalVisitor::new();
assert!(!visitor.is_nested_svg("svg"));
visitor.element_stack.push("g".to_string());
assert!(visitor.is_nested_svg("svg"));
visitor.element_stack.clear();
visitor.element_stack.push("g".to_string());
assert!(!visitor.is_nested_svg("rect"));
}
#[test]
fn test_plugin_apply() {
let plugin = RemoveViewBoxPlugin::new();
let mut doc = Document::new();
doc.root
.attributes
.insert(Cow::Borrowed("viewBox"), Cow::Borrowed("0 0 100 50"));
doc.root
.attributes
.insert(Cow::Borrowed("width"), Cow::Borrowed("100"));
doc.root
.attributes
.insert(Cow::Borrowed("height"), Cow::Borrowed("50"));
plugin.apply(&mut doc).unwrap();
assert!(!doc.root.attributes.contains_key("viewBox"));
assert!(doc.root.attributes.contains_key("width"));
assert!(doc.root.attributes.contains_key("height"));
}
#[test]
fn test_plugin_apply_preservation() {
let plugin = RemoveViewBoxPlugin::new();
let mut doc = Document::new();
doc.root
.attributes
.insert(Cow::Borrowed("viewBox"), Cow::Borrowed("10 10 100 50"));
doc.root
.attributes
.insert(Cow::Borrowed("width"), Cow::Borrowed("100"));
doc.root
.attributes
.insert(Cow::Borrowed("height"), Cow::Borrowed("50"));
plugin.apply(&mut doc).unwrap();
assert!(doc.root.attributes.contains_key("viewBox"));
assert_eq!(
doc.root.attributes.get("viewBox"),
Some(&Cow::Borrowed("10 10 100 50"))
);
}
#[test]
fn test_plugin_apply_nested_svg() {
let plugin = RemoveViewBoxPlugin::new();
let mut doc = Document::new();
doc.root
.attributes
.insert(Cow::Borrowed("viewBox"), Cow::Borrowed("0 0 200 100"));
doc.root
.attributes
.insert(Cow::Borrowed("width"), Cow::Borrowed("200"));
doc.root
.attributes
.insert(Cow::Borrowed("height"), Cow::Borrowed("100"));
let nested_svg = create_element_with_attrs(
"svg",
&[
("viewBox", "0 0 100 50"),
("width", "100"),
("height", "50"),
],
);
doc.root.children.push(Node::Element(nested_svg));
plugin.apply(&mut doc).unwrap();
assert!(!doc.root.attributes.contains_key("viewBox"));
if let Some(Node::Element(nested_svg)) = doc.root.children.first() {
assert!(nested_svg.attributes.contains_key("viewBox"));
assert_eq!(
nested_svg.attributes.get("viewBox"),
Some(&Cow::Borrowed("0 0 100 50"))
);
}
}
#[test]
fn test_plugin_apply_pattern_element() {
let plugin = RemoveViewBoxPlugin::new();
let mut doc = Document::new();
let pattern_element = create_element_with_attrs(
"pattern",
&[("viewBox", "0 0 10 10"), ("width", "10"), ("height", "10")],
);
doc.root.children.push(Node::Element(pattern_element));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(pattern)) = doc.root.children.first() {
assert!(!pattern.attributes.contains_key("viewBox"));
assert!(pattern.attributes.contains_key("width"));
assert!(pattern.attributes.contains_key("height"));
}
}
}
#[cfg(test)]
#[cfg(test)]
vexy_vsvg_test_utils::plugin_fixture_tests!(RemoveViewBoxPlugin, "removeViewBox");