use crate::Plugin;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vexy_vsvg::ast::Document;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UsvgConfig {
#[serde(default = "default_dpi")]
pub dpi: f32,
#[serde(default = "default_coordinates_precision")]
pub coordinates_precision: u8,
#[serde(default = "default_transforms_precision")]
pub transforms_precision: u8,
}
fn default_dpi() -> f32 {
96.0
}
fn default_coordinates_precision() -> u8 {
8
}
fn default_transforms_precision() -> u8 {
8
}
impl Default for UsvgConfig {
fn default() -> Self {
Self {
dpi: default_dpi(),
coordinates_precision: default_coordinates_precision(),
transforms_precision: default_transforms_precision(),
}
}
}
#[derive(Clone)]
pub struct UsvgPlugin {
config: UsvgConfig,
}
impl UsvgPlugin {
pub fn new() -> Self {
Self {
config: UsvgConfig::default(),
}
}
pub fn with_config(config: UsvgConfig) -> Self {
Self { config }
}
fn parse_config(params: &Value) -> Result<UsvgConfig> {
if params.is_null() {
Ok(UsvgConfig::default())
} else {
serde_json::from_value(params.clone())
.map_err(|e| anyhow::anyhow!("Invalid usvg plugin configuration: {}", e))
}
}
fn build_usvg_options(&self) -> usvg::Options<'static> {
usvg::Options {
dpi: self.config.dpi,
..usvg::Options::default()
}
}
fn build_write_options(&self) -> usvg::WriteOptions {
usvg::WriteOptions {
coordinates_precision: self.config.coordinates_precision,
transforms_precision: self.config.transforms_precision,
..usvg::WriteOptions::default()
}
}
}
impl Default for UsvgPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for UsvgPlugin {
fn name(&self) -> &'static str {
"usvg"
}
fn description(&self) -> &'static str {
"Normalize SVG through usvg (resolves styles, converts shapes to paths)"
}
fn category(&self) -> &'static str {
"global"
}
fn validate_params(&self, params: &serde_json::Value) -> Result<()> {
if !params.is_null() {
if let Some(obj) = params.as_object() {
let mut filtered = obj.clone();
filtered.remove("enabled");
if !filtered.is_empty() {
let filtered_value = serde_json::Value::Object(filtered);
Self::parse_config(&filtered_value)?;
}
} else {
Self::parse_config(params)?;
}
}
Ok(())
}
fn apply<'a>(&self, document: &mut Document<'a>) -> Result<()> {
let svg_text = match vexy_vsvg::stringify(document) {
Ok(text) => text,
Err(e) => {
eprintln!("usvg plugin: failed to stringify document, passing through: {e}");
return Ok(());
}
};
let usvg_opts = self.build_usvg_options();
let tree = match usvg::Tree::from_str(&svg_text, &usvg_opts) {
Ok(tree) => tree,
Err(e) => {
eprintln!("usvg plugin: failed to parse SVG, passing through: {e}");
return Ok(());
}
};
let write_opts = self.build_write_options();
let normalized_svg = tree.to_string(&write_opts);
let new_doc = match vexy_vsvg::parse_svg(&normalized_svg) {
Ok(doc) => doc,
Err(e) => {
eprintln!("usvg plugin: failed to re-parse normalized SVG, passing through: {e}");
return Ok(());
}
};
document.root = new_doc.root;
document.prologue = new_doc.prologue;
document.epilogue = new_doc.epilogue;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = UsvgConfig::default();
assert!((config.dpi - 96.0).abs() < f32::EPSILON);
assert_eq!(config.coordinates_precision, 8);
assert_eq!(config.transforms_precision, 8);
}
#[test]
fn test_parse_config_null() {
let config = UsvgPlugin::parse_config(&Value::Null);
assert!(config.is_ok());
let config = config.expect("config should parse");
assert!((config.dpi - 96.0).abs() < f32::EPSILON);
}
#[test]
fn test_parse_config_custom() {
let params = serde_json::json!({
"dpi": 72.0,
"coordinatesPrecision": 3,
"transformsPrecision": 4
});
let config = UsvgPlugin::parse_config(¶ms);
assert!(config.is_ok());
let config = config.expect("config should parse");
assert!((config.dpi - 72.0).abs() < f32::EPSILON);
assert_eq!(config.coordinates_precision, 3);
assert_eq!(config.transforms_precision, 4);
}
#[test]
fn test_parse_config_invalid() {
let params = serde_json::json!({ "dpi": "not_a_number" });
let config = UsvgPlugin::parse_config(¶ms);
assert!(config.is_err());
}
#[test]
fn test_parse_config_unknown_field() {
let params = serde_json::json!({ "unknownField": true });
let config = UsvgPlugin::parse_config(¶ms);
assert!(config.is_err(), "Unknown fields should be rejected");
}
#[test]
fn test_plugin_name() {
let plugin = UsvgPlugin::new();
assert_eq!(plugin.name(), "usvg");
}
#[test]
fn test_plugin_category() {
let plugin = UsvgPlugin::new();
assert_eq!(plugin.category(), "global");
}
#[test]
fn test_validate_params_null() {
let plugin = UsvgPlugin::new();
assert!(plugin.validate_params(&Value::Null).is_ok());
}
#[test]
fn test_validate_params_valid() {
let plugin = UsvgPlugin::new();
let params = serde_json::json!({ "dpi": 72.0 });
assert!(plugin.validate_params(¶ms).is_ok());
}
#[test]
fn test_validate_params_with_enabled() {
let plugin = UsvgPlugin::new();
let params = serde_json::json!({ "enabled": true, "dpi": 72.0 });
assert!(plugin.validate_params(¶ms).is_ok());
}
#[test]
fn test_validate_params_invalid() {
let plugin = UsvgPlugin::new();
let params = serde_json::json!({ "dpi": "bad" });
assert!(plugin.validate_params(¶ms).is_err());
}
#[test]
fn test_apply_simple_svg() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect x="10" y="10" width="80" height="80" fill="red"/>
</svg>"#;
let mut doc = vexy_vsvg::parse_svg(svg).expect("should parse test SVG");
let plugin = UsvgPlugin::new();
let result = plugin.apply(&mut doc);
assert!(result.is_ok(), "usvg plugin should succeed on valid SVG");
let output = vexy_vsvg::stringify(&doc).expect("should stringify");
assert!(
output.contains("<svg"),
"Output should still be valid SVG: {output}"
);
}
#[test]
fn test_apply_empty_document() {
let mut doc = Document::new();
let plugin = UsvgPlugin::new();
let result = plugin.apply(&mut doc);
assert!(
result.is_ok(),
"usvg plugin should not error on empty document"
);
}
#[test]
fn test_with_config() {
let config = UsvgConfig {
dpi: 72.0,
coordinates_precision: 3,
transforms_precision: 4,
};
let plugin = UsvgPlugin::with_config(config);
assert!((plugin.config.dpi - 72.0).abs() < f32::EPSILON);
assert_eq!(plugin.config.coordinates_precision, 3);
assert_eq!(plugin.config.transforms_precision, 4);
}
}