use oxvg_ast::{
element::Element,
node::Ref,
visitor::{ContextFlags, Info, PrepareOutcome, Visitor},
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "serde")]
use serde_with::skip_serializing_none;
use crate::error::JobsError;
#[cfg(feature = "wasm")]
use tsify::Tsify;
macro_rules! jobs {
($($name:ident: $job:ident$(< $($t:ty),* >)? $((is_default: $default:ident))?,)+) => {
$(mod $name;)+
$(pub use self::$name::$job;)+
#[cfg_attr(feature = "serde", skip_serializing_none)]
#[cfg_attr(feature = "napi", napi(object))]
#[cfg_attr(feature = "wasm", derive(Tsify))]
#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))]
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct Jobs {
$(
#[doc=concat!("See [`", stringify!($job), "`]")]
pub $name: Option<$job $( < 'arena, $($t),* >)?>
),+
}
impl Default for Jobs {
fn default() -> Self {
macro_rules! is_default {
($_job:ident $_default:ident) => { Some($_job::default()) };
($_job:ident) => { None };
}
Self {
$($name: is_default!($job $($default)?)),+
}
}
}
impl Jobs {
fn run_jobs<'input, 'arena>(
&self,
element: &Element<'input, 'arena>,
info: &Info<'input, 'arena>
) -> Result<usize, JobsError<'input>> {
let mut count = 0;
$(if let Some(job) = self.$name.as_ref() {
log::debug!(concat!("💼 starting ", stringify!($name)));
match job.start_with_info(element, info, None) {
Err(e) if e.is_important() => return Err(e),
Err(e) => log::error!("{} failed {e}", stringify!($name)),
Ok(r) => if !r.contains(PrepareOutcome::skip) {
count += 1;
}
}
})+
Ok(count)
}
#[cfg(feature = "napi")]
#[doc(hidden)]
pub fn napi_from_svgo_plugin_config(value: Option<Vec<napi::bindgen_prelude::Unknown>>) -> napi::Result<Self> {
use napi::{bindgen_prelude::{Status, Object}, ValueType};
let Some(plugins) = value else { return Ok(Self::default()) };
let mut oxvg_config = Self::none();
for plugin in plugins {
match plugin.get_type()? {
ValueType::String => unsafe {
let svgo_name: String = plugin.cast()?;
oxvg_config
.from_svgo_plugin_string(&svgo_name)
.map_err(|_| napi::Error::new(Status::InvalidArg, format!("unknown job `{svgo_name}`")))?;
}
ValueType::Object => unsafe {
let plugin: Object = plugin.cast()?;
let svgo_name: Option<String> = plugin.get("name")?;
let Some(svgo_name) = svgo_name else {
return Err(napi::Error::new(Status::InvalidArg, "expected name to be string"));
};
let name = Self::from_svgo_plugin_to_snake_case(&svgo_name);
match name.as_str() {
$(stringify!($name) => oxvg_config.$name = Some(plugin.get("params")?.unwrap_or_default()),)+
_ => return Err(napi::Error::new(Status::InvalidArg, format!("unknown job `{name}`"))),
}
}
_ => return Err(napi::Error::new(Status::InvalidArg, "unexpected type")),
}
}
Ok(oxvg_config)
}
#[cfg(feature = "serde")]
pub fn from_svgo_plugin_config(value: Option<Vec<serde_json::Value>>) -> Result<Self, serde_json::Error> {
use serde::de::Error as _;
let Some(plugins) = value else { return Ok(Self::default()) };
let mut oxvg_config = Self::none();
for plugin in plugins {
match plugin {
serde_json::Value::String(svgo_name) => {
oxvg_config
.from_svgo_plugin_string(&svgo_name)
.map_err(|_| serde_json::Error::custom(format!("unknown job `{svgo_name}`")))?;
}
serde_json::Value::Object(mut plugin) => {
let svgo_name = plugin.remove("name").ok_or_else(|| serde_json::Error::missing_field("name"))?;
let serde_json::Value::String(svgo_name) = svgo_name else {
return Err(serde_json::Error::custom("expected name to be string"));
};
let name = Self::from_svgo_plugin_to_snake_case(&svgo_name);
let params = plugin.remove("params");
if let Some(params) = params {
match name.as_str() {
$(stringify!($name) => oxvg_config.$name = Some(serde_json::from_value(params)?),)+
_ => return Err(serde_json::Error::custom(format!("unknown job `{name}`"))),
}
} else {
match name.as_str() {
$(stringify!($name) => oxvg_config.$name = Some($job::default()),)+
_ => return Err(serde_json::Error::custom(format!("unknown job `{name}`"))),
};
}
}
_ => return Err(serde_json::Error::custom(format!("unexpected type"))),
}
}
Ok(oxvg_config)
}
fn from_svgo_plugin_string(&mut self, svgo_name: &str) -> Result<(), ()> {
if svgo_name == "preset-default" {
macro_rules! is_default {
($_name:ident $_job:ident $_default:ident) => {
self.$_name = Some($_job::default())
};
($_name:ident $_job:ident) => { () };
}
$(is_default!($name $job $($default)?);)+
return Ok(());
}
let name = Self::from_svgo_plugin_to_snake_case(&svgo_name);
match name.as_str() {
$(stringify!($name) => self.$name = Some($job::default()),)+
_ => return Err(()),
};
Ok(())
}
fn from_svgo_plugin_to_snake_case(name: &str) -> String {
let caps = name.chars().filter(|char| char.is_uppercase()).count();
let mut snake = String::with_capacity(name.len() + caps);
for char in name.chars() {
if char.is_uppercase() {
snake.push('_');
}
snake.push(char.to_ascii_lowercase());
}
snake
}
pub fn extend(&mut self, other: &Self) {
$(if other.$name.is_some() {
self.$name = other.$name.clone();
})+
}
pub fn omit(&mut self, name: &str) {
match name {
$(stringify!($name) => self.$name = None,)+
_ => {}
}
}
pub fn none() -> Self {
Self {
$($name: None),+
}
}
}
};
}
jobs! {
precheck: Precheck,
add_attributes_to_s_v_g_element: AddAttributesToSVGElement,
add_classes_to_s_v_g_element: AddClassesToSVGElement,
cleanup_list_of_values: CleanupListOfValues,
convert_one_stop_gradients: ConvertOneStopGradients,
convert_style_to_attrs: ConvertStyleToAttrs,
remove_attributes_by_selector: RemoveAttributesBySelector,
remove_attrs: RemoveAttrs,
remove_dimensions: RemoveDimensions,
remove_elements_by_attr: RemoveElementsByAttr,
remove_off_canvas_paths: RemoveOffCanvasPaths,
remove_raster_images: RemoveRasterImages,
remove_scripts: RemoveScripts,
remove_style_element: RemoveStyleElement,
remove_title: RemoveTitle,
remove_view_box: RemoveViewBox,
reuse_paths: ReusePaths,
remove_x_m_l_n_s: RemoveXMLNS,
remove_doctype: RemoveDoctype (is_default: true),
remove_x_m_l_proc_inst: RemoveXMLProcInst (is_default: true),
remove_comments: RemoveComments (is_default: true),
remove_deprecated_attrs: RemoveDeprecatedAttrs (is_default: true),
remove_metadata: RemoveMetadata (is_default: true),
remove_editors_n_s_data: RemoveEditorsNSData (is_default: true),
cleanup_attrs: CleanupAttrs (is_default: true),
merge_styles: MergeStyles (is_default: true),
inline_styles: InlineStyles (is_default: true),
minify_styles: MinifyStyles (is_default: true),
cleanup_ids: CleanupIds (is_default: true),
remove_useless_defs: RemoveUselessDefs (is_default: true),
cleanup_numeric_values: CleanupNumericValues (is_default: true),
convert_colors: ConvertColors (is_default: true),
remove_unknowns_and_defaults: RemoveUnknownsAndDefaults (is_default: true),
remove_non_inheritable_group_attrs: RemoveNonInheritableGroupAttrs (is_default: true),
remove_useless_stroke_and_fill: RemoveUselessStrokeAndFill (is_default: true),
cleanup_enable_background: CleanupEnableBackground (is_default: true),
remove_hidden_elems: RemoveHiddenElems (is_default: true),
remove_empty_text: RemoveEmptyText (is_default: true),
convert_shape_to_path: ConvertShapeToPath (is_default: true),
convert_ellipse_to_circle: ConvertEllipseToCircle (is_default: true),
move_elems_attrs_to_group: MoveElemsAttrsToGroup (is_default: true),
move_group_attrs_to_elems: MoveGroupAttrsToElems (is_default: true),
collapse_groups: CollapseGroups (is_default: true),
apply_transforms: ApplyTransforms (is_default: true),
convert_path_data: ConvertPathData (is_default: true),
convert_transform: ConvertTransform (is_default: true),
remove_empty_attrs: RemoveEmptyAttrs (is_default: true),
remove_empty_containers: RemoveEmptyContainers (is_default: true),
remove_unused_n_s: RemoveUnusedNS (is_default: true),
merge_paths: MergePaths (is_default: true),
sort_attrs: SortAttrs (is_default: true),
sort_defs_children: SortDefsChildren (is_default: true),
remove_desc: RemoveDesc (is_default: true),
prefix_ids: PrefixIds, remove_xlink: RemoveXlink, }
impl Jobs {
pub fn run<'input, 'arena>(
&self,
root: Ref<'input, 'arena>,
info: &Info<'input, 'arena>,
) -> Result<(), JobsError<'input>> {
let Some(root_element) = Element::from_parent(root) else {
log::warn!("No elements found in the document, skipping");
return Ok(());
};
let count = self.run_jobs(&root_element, info)?;
log::debug!("completed {count} jobs");
Ok(())
}
pub fn safe() -> Self {
Self {
precheck: Some(Precheck::default()),
remove_doctype: Some(RemoveDoctype::default()),
remove_x_m_l_proc_inst: Some(RemoveXMLProcInst::default()),
remove_comments: Some(RemoveComments::default()),
remove_deprecated_attrs: Some(RemoveDeprecatedAttrs::default()),
remove_metadata: Some(RemoveMetadata::default()),
remove_editors_n_s_data: Some(RemoveEditorsNSData::default()),
cleanup_attrs: Some(CleanupAttrs::default()),
merge_styles: Some(MergeStyles::default()),
inline_styles: Some(InlineStyles::default()),
minify_styles: Some(MinifyStyles::default()),
cleanup_ids: Some(CleanupIds::default()),
remove_useless_defs: Some(RemoveUselessDefs::default()),
cleanup_numeric_values: Some(CleanupNumericValues::default()),
convert_colors: Some(ConvertColors::default()),
remove_unknowns_and_defaults: Some(RemoveUnknownsAndDefaults::default()),
remove_non_inheritable_group_attrs: Some(RemoveNonInheritableGroupAttrs::default()),
remove_useless_stroke_and_fill: Some(RemoveUselessStrokeAndFill::default()),
cleanup_enable_background: Some(CleanupEnableBackground::default()),
remove_hidden_elems: Some(RemoveHiddenElems::default()),
remove_empty_text: Some(RemoveEmptyText::default()),
convert_shape_to_path: Some(ConvertShapeToPath::default()),
convert_ellipse_to_circle: Some(ConvertEllipseToCircle::default()),
move_elems_attrs_to_group: Some(MoveElemsAttrsToGroup::default()),
move_group_attrs_to_elems: Some(MoveGroupAttrsToElems::default()),
collapse_groups: Some(CollapseGroups::default()),
apply_transforms: Some(ApplyTransforms::default()),
convert_path_data: Some(ConvertPathData::default()),
convert_transform: Some(ConvertTransform::default()),
remove_empty_attrs: Some(RemoveEmptyAttrs::default()),
remove_empty_containers: Some(RemoveEmptyContainers::default()),
remove_unused_n_s: Some(RemoveUnusedNS::default()),
merge_paths: Some(MergePaths::default()),
sort_attrs: Some(SortAttrs::default()),
sort_defs_children: Some(SortDefsChildren::default()),
remove_desc: Some(RemoveDesc::default()),
..Self::none()
}
}
}
#[cfg(test)]
#[macro_export]
#[doc(hidden)]
macro_rules! test_config {
($config_json:literal, comment: $comment:literal$(,)?) => {
$crate::jobs::test_config(
$config_json,
Some(concat!(
r#"<svg xmlns="http://www.w3.org/2000/svg">
<!-- "#,
$comment,
r#" -->
test
</svg>"#
)),
)
};
($config_json:literal, svg: $svg:literal$(,)?) => {
$crate::jobs::test_config($config_json, Some($svg))
};
($config_json:literal) => {
$crate::jobs::test_config($config_json, None)
};
}
#[cfg(test)]
pub(crate) fn test_config(config_json: &str, svg: Option<&'static str>) -> anyhow::Result<String> {
use oxvg_ast::{
parse::roxmltree::{parse_with_options, ParsingOptions},
serialize::{Node as _, Options, Space},
};
let jobs: Jobs = serde_json::from_str(config_json)?;
parse_with_options(
svg.unwrap_or(
r#"<svg xmlns="http://www.w3.org/2000/svg">
test
</svg>"#,
),
ParsingOptions {
allow_dtd: true,
..ParsingOptions::default()
},
|dom, allocator| {
jobs.run(dom, &Info::new(allocator))
.map_err(|e| anyhow::Error::msg(format!("{e}")))?;
Ok(dom.serialize_with_options(Options {
trim_whitespace: Space::Default,
minify: true,
..Options::pretty()
})?)
},
)?
}
#[test]
fn test_jobs() -> anyhow::Result<()> {
test_config(
r#"{ "addAttributesToSvgElement": {
"attributes": { "foo": "bar" }
} }"#,
None,
)
.map(|_| ())
}