diff --git a/crates/resvg/src/main.rs b/crates/resvg/src/main.rs index 830eb8d4f..355076cf9 100644 --- a/crates/resvg/src/main.rs +++ b/crates/resvg/src/main.rs @@ -206,6 +206,8 @@ OPTIONS: --export-area-drawing Use drawing's tight bounding box instead of image size. Used during normal rendering and not during --export-id + --color-scheme SCHEME Sets the color scheme for resolving CSS light-dark() function + [default: light] [possible values: light, dark] --perf Prints performance stats --quiet Disables warnings @@ -240,6 +242,7 @@ struct CliArgs { skip_system_fonts: bool, list_fonts: bool, style_sheet: Option, + color_scheme: usvg::ColorScheme, query_all: bool, export_id: Option, @@ -310,6 +313,9 @@ fn collect_args() -> Result { export_area_drawing: input.contains("--export-area-drawing"), style_sheet: input.opt_value_from_str("--stylesheet").unwrap_or_default(), + color_scheme: input + .opt_value_from_fn("--color-scheme", parse_color_scheme)? + .unwrap_or_default(), perf: input.contains("--perf"), quiet: input.contains("--quiet"), @@ -372,6 +378,14 @@ fn parse_languages(s: &str) -> Result, String> { Ok(langs) } +fn parse_color_scheme(s: &str) -> Result { + match s.to_lowercase().as_str() { + "light" => Ok(usvg::ColorScheme::Light), + "dark" => Ok(usvg::ColorScheme::Dark), + _ => Err("invalid color scheme, expected 'light' or 'dark'".to_string()), + } +} + #[derive(Clone, PartialEq, Debug)] enum InputFrom { Stdin, @@ -578,6 +592,7 @@ fn parse_args() -> Result { font_resolver: usvg::FontResolver::default(), fontdb: Arc::new(fontdb::Database::new()), style_sheet, + color_scheme: args.color_scheme, }; Ok(Args { diff --git a/crates/usvg/src/main.rs b/crates/usvg/src/main.rs index 84ae50250..83a1ef873 100644 --- a/crates/usvg/src/main.rs +++ b/crates/usvg/src/main.rs @@ -102,6 +102,8 @@ OPTIONS: --transforms-precision NUM Set the transform values numeric precision. Smaller precision can lead to a malformed output in some cases [values: 2..8 (inclusive)] [default: 8] + --color-scheme SCHEME Sets the color scheme for resolving CSS light-dark() function + [default: light] [possible values: light, dark] --quiet Disables warnings ARGS: @@ -139,6 +141,7 @@ struct Args { coordinates_precision: Option, transforms_precision: Option, style_sheet: Option, + color_scheme: usvg::ColorScheme, quiet: bool, @@ -209,6 +212,9 @@ fn collect_args() -> Result { .opt_value_from_fn("--coordinates-precision", parse_precision)?, transforms_precision: input.opt_value_from_fn("--transforms-precision", parse_precision)?, style_sheet: input.opt_value_from_str("--stylesheet").unwrap_or_default(), + color_scheme: input + .opt_value_from_fn("--color-scheme", parse_color_scheme)? + .unwrap_or_default(), quiet: input.contains("--quiet"), @@ -285,6 +291,14 @@ fn parse_precision(s: &str) -> Result { } } +fn parse_color_scheme(s: &str) -> Result { + match s.to_lowercase().as_str() { + "light" => Ok(usvg::ColorScheme::Light), + "dark" => Ok(usvg::ColorScheme::Dark), + _ => Err("invalid color scheme, expected 'light' or 'dark'".to_string()), + } +} + #[derive(Clone, PartialEq, Debug)] enum InputFrom<'a> { Stdin, @@ -432,6 +446,7 @@ fn process(args: Args) -> Result<(), String> { font_resolver: usvg::FontResolver::default(), fontdb: Arc::new(fontdb), style_sheet, + color_scheme: args.color_scheme, }; let input_svg = match in_svg { diff --git a/crates/usvg/src/parser/mod.rs b/crates/usvg/src/parser/mod.rs index b3fbccdd6..7c5577831 100644 --- a/crates/usvg/src/parser/mod.rs +++ b/crates/usvg/src/parser/mod.rs @@ -21,7 +21,7 @@ mod text; #[cfg(feature = "text")] pub(crate) use converter::Cache; pub use image::{ImageHrefDataResolverFn, ImageHrefResolver, ImageHrefStringResolverFn}; -pub use options::Options; +pub use options::{ColorScheme, Options}; pub(crate) use svgtree::{AId, EId}; /// List of all errors. @@ -136,7 +136,12 @@ impl crate::Tree { (opt.font_resolver.select_fallback)(c, used_fonts, db) }), }, - ..Options::default() + // Inherit font_family from parent + font_family: opt.font_family.clone(), + // Inherit style_sheet from parent + style_sheet: opt.style_sheet.clone(), + // Inherit color_scheme from parent so nested SVGs use the same scheme + color_scheme: opt.color_scheme, }; Self::from_data(data, &nested_opt) @@ -157,7 +162,7 @@ impl crate::Tree { /// Parses `Tree` from `roxmltree::Document`. pub fn from_xmltree(doc: &roxmltree::Document, opt: &Options) -> Result { - let doc = svgtree::Document::parse_tree(doc, opt.style_sheet.as_deref())?; + let doc = svgtree::Document::parse_tree(doc, opt.style_sheet.as_deref(), opt.color_scheme)?; self::converter::convert_doc(&doc, opt) } } diff --git a/crates/usvg/src/parser/options.rs b/crates/usvg/src/parser/options.rs index fcf70b114..918e63aec 100644 --- a/crates/usvg/src/parser/options.rs +++ b/crates/usvg/src/parser/options.rs @@ -8,6 +8,24 @@ use std::sync::Arc; use crate::FontResolver; use crate::{ImageHrefResolver, ImageRendering, ShapeRendering, Size, TextRendering}; +// TODO: Use svgtypes::ColorScheme once https://github.com/linebender/svgtypes/pull/59 is merged +/// The color scheme preference for resolving CSS `light-dark()` function. +/// +/// The CSS `light-dark()` function allows specifying two color values where the first +/// is for light mode and the second is for dark mode. This option controls which +/// value is extracted. +/// +/// This is useful for rendering SVGs exported from applications like Draw.io that +/// use `light-dark()` for dark mode support. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum ColorScheme { + /// Use the first value (light mode color). This is the default. + #[default] + Light, + /// Use the second value (dark mode color). + Dark, +} + /// Processing options. #[derive(Debug)] pub struct Options<'a> { @@ -98,6 +116,11 @@ pub struct Options<'a> { /// A CSS stylesheet that should be injected into the SVG. Can be used to overwrite /// certain attributes. pub style_sheet: Option, + + /// The color scheme to use when resolving CSS `light-dark()` function. + /// + /// Default: `ColorScheme::Light` + pub color_scheme: ColorScheme, } impl Default for Options<'_> { @@ -119,6 +142,7 @@ impl Default for Options<'_> { #[cfg(feature = "text")] fontdb: Arc::new(fontdb::Database::new()), style_sheet: None, + color_scheme: ColorScheme::default(), } } } diff --git a/crates/usvg/src/parser/svgtree/parse.rs b/crates/usvg/src/parser/svgtree/parse.rs index 2eae321b6..89a5dc81a 100644 --- a/crates/usvg/src/parser/svgtree/parse.rs +++ b/crates/usvg/src/parser/svgtree/parse.rs @@ -13,13 +13,95 @@ const SVG_NS: &str = "http://www.w3.org/2000/svg"; const XLINK_NS: &str = "http://www.w3.org/1999/xlink"; const XML_NAMESPACE_NS: &str = "http://www.w3.org/XML/1998/namespace"; +use crate::ColorScheme; + +// TODO: Use svgtypes::resolve_light_dark once https://github.com/linebender/svgtypes/pull/59 is merged +/// Resolves CSS `light-dark(value1, value2)` function based on the specified color scheme. +/// +/// The `light-dark()` CSS function is used for dark mode support. This function extracts +/// the appropriate value based on the color scheme: first value for light mode, second for dark. +/// +/// This function handles nested parentheses (e.g., `light-dark(rgb(0, 0, 0), rgb(255, 255, 255))`) +/// and recursively resolves any nested `light-dark()` calls. +fn resolve_light_dark(value: &str, color_scheme: ColorScheme) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + + let Some(start_idx) = value.find("light-dark(") else { + return Cow::Borrowed(value); + }; + + let func_start = start_idx + "light-dark(".len(); + let rest = &value[func_start..]; + + // Find both arguments by tracking parentheses depth + let mut depth = 1; + let mut first_arg_end = None; + let mut second_arg_start = None; + let mut func_end = None; + + for (i, c) in rest.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + func_end = Some(i); + if first_arg_end.is_none() { + first_arg_end = Some(i); + } + break; + } + } + ',' if depth == 1 && first_arg_end.is_none() => { + first_arg_end = Some(i); + second_arg_start = Some(i + 1); + } + _ => {} + } + } + + let Some(first_arg_end) = first_arg_end else { + return Cow::Borrowed(value); + }; + let func_end = func_end.unwrap_or(rest.len()); + + // Select the appropriate argument based on color scheme + let selected_arg = match color_scheme { + ColorScheme::Light => rest[..first_arg_end].trim(), + ColorScheme::Dark => { + if let Some(start) = second_arg_start { + rest[start..func_end].trim() + } else { + // No second argument, fall back to first + rest[..first_arg_end].trim() + } + } + }; + + // Reconstruct the value with light-dark() replaced by the selected argument + let mut result = String::with_capacity(value.len()); + result.push_str(&value[..start_idx]); + result.push_str(selected_arg); + // Append any remaining content after the closing parenthesis + if func_end + 1 < rest.len() { + result.push_str(&rest[func_end + 1..]); + } + + // Recursively resolve any remaining light-dark() calls + match resolve_light_dark(&result, color_scheme) { + Cow::Borrowed(_) => Cow::Owned(result), + Cow::Owned(s) => Cow::Owned(s), + } +} + impl<'input> Document<'input> { /// Parses a [`Document`] from a [`roxmltree::Document`]. pub fn parse_tree( xml: &roxmltree::Document<'input>, injected_stylesheet: Option<&'input str>, + color_scheme: ColorScheme, ) -> Result, Error> { - parse(xml, injected_stylesheet) + parse(xml, injected_stylesheet, color_scheme) } pub(crate) fn append(&mut self, parent_id: NodeId, kind: NodeKind) -> NodeId { @@ -65,6 +147,7 @@ impl<'input> Document<'input> { fn parse<'input>( xml: &roxmltree::Document<'input>, injected_stylesheet: Option<&'input str>, + color_scheme: ColorScheme, ) -> Result, Error> { let mut doc = Document { nodes: Vec::new(), @@ -101,6 +184,7 @@ fn parse<'input>( 0, &mut doc, &id_map, + color_scheme, )?; // Check that the root element is `svg`. @@ -152,6 +236,7 @@ fn parse_xml_node_children<'input>( depth: u32, doc: &mut Document<'input>, id_map: &HashMap<&str, roxmltree::Node<'_, 'input>>, + color_scheme: ColorScheme, ) -> Result<(), Error> { for node in parent.children() { parse_xml_node( @@ -163,6 +248,7 @@ fn parse_xml_node_children<'input>( depth, doc, id_map, + color_scheme, )?; } @@ -178,6 +264,7 @@ fn parse_xml_node<'input>( depth: u32, doc: &mut Document<'input>, id_map: &HashMap<&str, roxmltree::Node<'_, 'input>>, + color_scheme: ColorScheme, ) -> Result<(), Error> { if depth > 1024 { return Err(Error::NodesLimitReached); @@ -198,11 +285,28 @@ fn parse_xml_node<'input>( tag_name = EId::G; } - let node_id = parse_svg_element(node, parent_id, tag_name, style_sheet, ignore_ids, doc)?; + let node_id = parse_svg_element( + node, + parent_id, + tag_name, + style_sheet, + ignore_ids, + doc, + color_scheme, + )?; if tag_name == EId::Text { - super::text::parse_svg_text_element(node, node_id, style_sheet, doc)?; + super::text::parse_svg_text_element(node, node_id, style_sheet, doc, color_scheme)?; } else if tag_name == EId::Use { - parse_svg_use_element(node, origin, node_id, style_sheet, depth + 1, doc, id_map)?; + parse_svg_use_element( + node, + origin, + node_id, + style_sheet, + depth + 1, + doc, + id_map, + color_scheme, + )?; } else { parse_xml_node_children( node, @@ -213,6 +317,7 @@ fn parse_xml_node<'input>( depth + 1, doc, id_map, + color_scheme, )?; } @@ -226,6 +331,7 @@ pub(crate) fn parse_svg_element<'input>( style_sheet: &simplecss::StyleSheet, ignore_ids: bool, doc: &mut Document<'input>, + color_scheme: ColorScheme, ) -> Result { let attrs_start_idx = doc.attrs.len(); @@ -321,7 +427,10 @@ pub(crate) fn parse_svg_element<'input>( let mut write_declaration = |declaration: &Declaration| { // TODO: perform XML attribute normalization let imp = declaration.important; - let val = declaration.value; + // Resolve CSS light-dark() function by extracting the appropriate value. + // This handles Draw.io SVG exports that use light-dark() for dark mode support. + let val_cow = resolve_light_dark(declaration.value, color_scheme); + let val = val_cow.as_ref(); if declaration.name == "marker" { insert_attribute(AId::MarkerStart, val, imp); @@ -551,6 +660,7 @@ fn parse_svg_use_element<'input>( depth: u32, doc: &mut Document<'input>, id_map: &HashMap<&str, roxmltree::Node<'_, 'input>>, + color_scheme: ColorScheme, ) -> Result<(), Error> { let link = match resolve_href(node, id_map) { Some(v) => v, @@ -618,6 +728,7 @@ fn parse_svg_use_element<'input>( depth + 1, doc, id_map, + color_scheme, ) } diff --git a/crates/usvg/src/parser/svgtree/text.rs b/crates/usvg/src/parser/svgtree/text.rs index 11877da3f..6430a7823 100644 --- a/crates/usvg/src/parser/svgtree/text.rs +++ b/crates/usvg/src/parser/svgtree/text.rs @@ -6,6 +6,7 @@ use roxmltree::Error; use super::{AId, Document, EId, NodeId, NodeKind, SvgNode}; +use crate::ColorScheme; const XLINK_NS: &str = "http://www.w3.org/1999/xlink"; @@ -14,6 +15,7 @@ pub(crate) fn parse_svg_text_element<'input>( parent_id: NodeId, style_sheet: &simplecss::StyleSheet, doc: &mut Document<'input>, + color_scheme: ColorScheme, ) -> Result<(), Error> { debug_assert_eq!(parent.tag_name().name(), "text"); @@ -31,7 +33,7 @@ pub(crate) fn parse_svg_text_element<'input>( } }; - parse_svg_text_element_impl(parent, parent_id, style_sheet, space, doc)?; + parse_svg_text_element_impl(parent, parent_id, style_sheet, space, doc, color_scheme)?; trim_text_nodes(parent_id, space, doc); Ok(()) @@ -43,6 +45,7 @@ fn parse_svg_text_element_impl<'input>( style_sheet: &simplecss::StyleSheet, space: XmlSpace, doc: &mut Document<'input>, + color_scheme: ColorScheme, ) -> Result<(), Error> { for node in parent.children() { if node.is_text() { @@ -77,8 +80,15 @@ fn parse_svg_text_element_impl<'input>( is_tref = true; } - let node_id = - super::parse::parse_svg_element(node, parent_id, tag_name, style_sheet, false, doc)?; + let node_id = super::parse::parse_svg_element( + node, + parent_id, + tag_name, + style_sheet, + false, + doc, + color_scheme, + )?; let space = get_xmlspace(doc, node_id, space); if is_tref { @@ -93,7 +103,7 @@ fn parse_svg_text_element_impl<'input>( } } } else { - parse_svg_text_element_impl(node, node_id, style_sheet, space, doc)?; + parse_svg_text_element_impl(node, node_id, style_sheet, space, doc, color_scheme)?; } } diff --git a/crates/usvg/tests/parser.rs b/crates/usvg/tests/parser.rs index 7b34806d8..39b2d036d 100644 --- a/crates/usvg/tests/parser.rs +++ b/crates/usvg/tests/parser.rs @@ -547,3 +547,113 @@ fn no_text_nodes() { let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); assert!(!tree.has_text_nodes()); } + +/// Tests that the CSS `light-dark()` function is correctly parsed. +/// Draw.io exports SVGs with `light-dark()` for dark mode support. +/// usvg should extract the first (light-mode) value. +#[test] +fn light_dark_css_function() { + // Test with simple color values + let svg = r#" + + "#; + + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); + let usvg::Node::Path(ref path) = &tree.root().children()[0] else { + unreachable!() + }; + + // Should extract "red" (the first/light-mode value) + assert_eq!( + path.fill().unwrap().paint(), + &usvg::Paint::Color(Color::new_rgb(255, 0, 0)) + ); +} + +/// Tests that `light-dark()` with rgb() function arguments is correctly parsed. +#[test] +fn light_dark_css_function_with_rgb() { + let svg = r#" + + + + "#; + + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); + + let usvg::Node::Group(ref group) = &tree.root().children()[0] else { + unreachable!() + }; + let usvg::Node::Path(ref path) = &group.children()[0] else { + unreachable!() + }; + + // Should extract rgb(0, 128, 0) which is green + assert_eq!( + path.fill().unwrap().paint(), + &usvg::Paint::Color(Color::new_rgb(0, 128, 0)) + ); +} + +#[test] +fn light_dark_css_function_with_dark_scheme() { + use usvg::Color; + + let svg = r#" + + + + "#; + + let opt = usvg::Options { + color_scheme: usvg::ColorScheme::Dark, + ..Default::default() + }; + let tree = usvg::Tree::from_str(&svg, &opt).unwrap(); + + let usvg::Node::Group(ref group) = &tree.root().children()[0] else { + unreachable!() + }; + let usvg::Node::Path(ref path) = &group.children()[0] else { + unreachable!() + }; + + // With Dark scheme, should extract the second value (blue) + assert_eq!( + path.fill().unwrap().paint(), + &usvg::Paint::Color(Color::new_rgb(0, 0, 255)) + ); +} + +/// Regression test: Tree::from_data_nested should inherit color_scheme from parent Options. +/// This is used when resolving that reference other SVG files. +#[test] +fn nested_svg_inherits_color_scheme() { + use usvg::Color; + + // A nested SVG using light-dark() - this would be embedded via + let nested_svg = br#" + + "#; + + // Test with Dark color scheme + let opt = usvg::Options { + color_scheme: usvg::ColorScheme::Dark, + ..Default::default() + }; + + // from_data_nested should inherit color_scheme from opt + let tree = usvg::Tree::from_data_nested(nested_svg, &opt).unwrap(); + + let usvg::Node::Path(ref path) = &tree.root().children()[0] else { + unreachable!() + }; + + // With Dark scheme inherited, should extract blue (the second value) + assert_eq!( + path.fill().unwrap().paint(), + &usvg::Paint::Color(Color::new_rgb(0, 0, 255)) + ); +}