Repository: linebender/resvg (language: rust)

Issue:
Feature request: stylesheet injection
Title is self-explanatory: the ability to add a stylesheet when rendering an SVG without needing to modify the XML input.


Discussion / hints:
Yes, I do have plans on such feature, but I still not sure in what order an external css should be applied. Before or after the embedded one. 
@RazrFalcon FYI: @federicomenaquintero declared at librsvg:

> The external CSS is loaded as for the [User origin](https://developer.mozilla.org/en-US/docs/Glossary/Style_origin); the styles declared in the SVG document have the Author origin.  The cascading rules make use of the origin and specificity of a selector to determine what takes precedence.

That makes sense for me, since the svg-specific css is generally imho more specific than the external one.

Example: Wikimedia renders the math-latex-equations somehow from svg2png using librsvg (but wihtout any css). If something is specified in the equation something should overwrite (i.e. higher priority) general one from the surrounding text (e.g. current text-color).
Hi, are there still plans for that feature and/or are there specific blocking elements or other priorities ? I would be willing to contribute time to implement it, but I'm wondering if this is at the implementation or design stage.

Sincerely
The main blocking issue is that I have no time. 
The only hard part here is to figure out the order of CSS. Should the injected CSS override the SVG/embedded CSS properties or not. Which means testing. Which takes time.

If you want to try it, you should start here: https://github.com/RazrFalcon/resvg/blob/6be2f2d396e539ddfcf022dc67f304d307c1211a/crates/usvg-parser/src/svgtree/parse.rs#L505

Fix patch:
diff --git a/crates/resvg/src/main.rs b/crates/resvg/src/main.rs
index be7277353..45ced6017 100644
--- a/crates/resvg/src/main.rs
+++ b/crates/resvg/src/main.rs
@@ -144,6 +144,8 @@ OPTIONS:
                                 [default: 96] [possible values: 10..4000 (inclusive)]
   --background COLOR            Sets the background color
                                 Examples: red, #fff, #fff000
+  --stylesheet PATH             Inject a stylesheet that should be used when resolving
+                                CSS attributes.
 
   --languages LANG              Sets a comma-separated list of languages that
                                 will be used during the 'systemLanguage'
@@ -238,6 +240,7 @@ struct CliArgs {
     font_dirs: Vec<path::PathBuf>,
     skip_system_fonts: bool,
     list_fonts: bool,
+    style_sheet: Option<path::PathBuf>,
 
     query_all: bool,
     export_id: Option<String>,
@@ -307,6 +310,7 @@ fn collect_args() -> Result<CliArgs, pico_args::Error> {
         export_area_page: input.contains("--export-area-page"),
 
         export_area_drawing: input.contains("--export-area-drawing"),
+        style_sheet: input.opt_value_from_str("--stylesheet").unwrap_or_default(),
 
         perf: input.contains("--perf"),
         quiet: input.contains("--quiet"),
@@ -548,6 +552,16 @@ fn parse_args() -> Result<Args, String> {
         }
     };
 
+    let style_sheet = match args.style_sheet.as_ref() {
+        Some(p) => Some(
+            std::fs::read(&p)
+                .ok()
+                .and_then(|s| std::str::from_utf8(&s).ok().map(|s| s.to_string()))
+                .ok_or("failed to read stylesheet".to_string())?,
+        ),
+        None => None,
+    };
+
     let usvg = usvg::Options {
         resources_dir,
         dpi: args.dpi as f32,
@@ -564,6 +578,7 @@ fn parse_args() -> Result<Args, String> {
         image_href_resolver: usvg::ImageHrefResolver::default(),
         font_resolver: usvg::FontResolver::default(),
         fontdb: Arc::new(fontdb::Database::new()),
+        style_sheet,
     };
 
     Ok(Args {
diff --git a/crates/usvg/src/main.rs b/crates/usvg/src/main.rs
index b85ba1526..56a53b146 100644
--- a/crates/usvg/src/main.rs
+++ b/crates/usvg/src/main.rs
@@ -26,6 +26,8 @@ OPTIONS:
 
   --dpi DPI                         Sets the resolution
                                     [default: 96] [possible values: 10..4000 (inclusive)]
+  --stylesheet PATH                 Inject a stylesheet that should be used when resolving
+                                    CSS attributes.
   --languages LANG                  Sets a comma-separated list of languages that
                                     will be used during the 'systemLanguage'
                                     attribute resolving
@@ -137,6 +139,7 @@ struct Args {
     attrs_indent: xmlwriter::Indent,
     coordinates_precision: Option<u8>,
     transforms_precision: Option<u8>,
+    style_sheet: Option<PathBuf>,
 
     quiet: bool,
 
@@ -206,6 +209,7 @@ fn collect_args() -> Result<Args, pico_args::Error> {
         coordinates_precision: input
             .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(),
 
         quiet: input.contains("--quiet"),
 
@@ -400,6 +404,16 @@ fn process(args: Args) -> Result<(), String> {
         }
     };
 
+    let style_sheet = match args.style_sheet.as_ref() {
+        Some(p) => Some(
+            std::fs::read(&p)
+                .ok()
+                .and_then(|s| std::str::from_utf8(&s).ok().map(|s| s.to_string()))
+                .ok_or("failed to read stylesheet".to_string())?,
+        ),
+        None => None,
+    };
+
     let re_opt = usvg::Options {
         resources_dir,
         dpi: args.dpi as f32,
@@ -418,6 +432,7 @@ fn process(args: Args) -> Result<(), String> {
         image_href_resolver: usvg::ImageHrefResolver::default(),
         font_resolver: usvg::FontResolver::default(),
         fontdb: Arc::new(fontdb),
+        style_sheet,
     };
 
     let input_svg = match in_svg {
diff --git a/crates/usvg/src/parser/mod.rs b/crates/usvg/src/parser/mod.rs
index 1f52c3b85..21d46d2dd 100644
--- a/crates/usvg/src/parser/mod.rs
+++ b/crates/usvg/src/parser/mod.rs
@@ -121,7 +121,7 @@ impl crate::Tree {
 
     /// Parses `Tree` from `roxmltree::Document`.
     pub fn from_xmltree(doc: &roxmltree::Document, opt: &Options) -> Result<Self, Error> {
-        let doc = svgtree::Document::parse_tree(doc)?;
+        let doc = svgtree::Document::parse_tree(doc, opt.style_sheet.as_deref())?;
         self::converter::convert_doc(&doc, opt)
     }
 }
diff --git a/crates/usvg/src/parser/options.rs b/crates/usvg/src/parser/options.rs
index 7b3c51a77..5202dbb32 100644
--- a/crates/usvg/src/parser/options.rs
+++ b/crates/usvg/src/parser/options.rs
@@ -96,6 +96,9 @@ pub struct Options<'a> {
     /// be the same as this one.
     #[cfg(feature = "text")]
     pub fontdb: Arc<fontdb::Database>,
+    /// A CSS stylesheet that should be injected into the SVG. Can be used to overwrite
+    /// certain attributes.
+    pub style_sheet: Option<String>,
 }
 
 impl Default for Options<'_> {
@@ -116,6 +119,7 @@ impl Default for Options<'_> {
             font_resolver: FontResolver::default(),
             #[cfg(feature = "text")]
             fontdb: Arc::new(fontdb::Database::new()),
+            style_sheet: None,
         }
     }
 }
diff --git a/crates/usvg/src/parser/svgtree/parse.rs b/crates/usvg/src/parser/svgtree/parse.rs
index 785b25a0d..dfb04fb7b 100644
--- a/crates/usvg/src/parser/svgtree/parse.rs
+++ b/crates/usvg/src/parser/svgtree/parse.rs
@@ -16,8 +16,11 @@ const XML_NAMESPACE_NS: &str = "http://www.w3.org/XML/1998/namespace";
 
 impl<'input> Document<'input> {
     /// Parses a [`Document`] from a [`roxmltree::Document`].
-    pub fn parse_tree(xml: &roxmltree::Document<'input>) -> Result<Document<'input>, Error> {
-        parse(xml)
+    pub fn parse_tree(
+        xml: &roxmltree::Document<'input>,
+        injected_stylesheet: Option<&'input str>,
+    ) -> Result<Document<'input>, Error> {
+        parse(xml, injected_stylesheet)
     }
 
     pub(crate) fn append(&mut self, parent_id: NodeId, kind: NodeKind) -> NodeId {
@@ -51,7 +54,10 @@ impl<'input> Document<'input> {
     }
 }
 
-fn parse<'input>(xml: &roxmltree::Document<'input>) -> Result<Document<'input>, Error> {
+fn parse<'input>(
+    xml: &roxmltree::Document<'input>,
+    injected_stylesheet: Option<&'input str>,
+) -> Result<Document<'input>, Error> {
     let mut doc = Document {
         nodes: Vec::new(),
         attrs: Vec::new(),
@@ -76,7 +82,7 @@ fn parse<'input>(xml: &roxmltree::Document<'input>) -> Result<Document<'input>,
         kind: NodeKind::Root,
     });
 
-    let style_sheet = resolve_css(xml);
+    let style_sheet = resolve_css(xml, injected_stylesheet);
 
     parse_xml_node_children(
         xml.root(),
@@ -565,9 +571,18 @@ fn parse_svg_use_element<'input>(
     )
 }
 
-fn resolve_css<'a>(xml: &'a roxmltree::Document<'a>) -> simplecss::StyleSheet<'a> {
+fn resolve_css<'a>(
+    xml: &'a roxmltree::Document<'a>,
+    style_sheet: Option<&'a str>,
+) -> simplecss::StyleSheet<'a> {
     let mut sheet = simplecss::StyleSheet::new();
 
+    // Injected style sheets do not override internal ones (we mimic the logic of rsvg-convert),
+    // so we need to parse it first.
+    if let Some(style_sheet) = style_sheet {
+        sheet.parse_more(style_sheet);
+    }
+
     for node in xml.descendants().filter(|n| n.has_tag_name("style")) {
         match node.attribute("type") {
             Some("text/css") => {}


Test patch:
diff --git a/crates/usvg/tests/parser.rs b/crates/usvg/tests/parser.rs
index 0ca5e18d1..ea81afdb1 100644
--- a/crates/usvg/tests/parser.rs
+++ b/crates/usvg/tests/parser.rs
@@ -1,3 +1,5 @@
+use usvg::Color;
+
 #[test]
 fn clippath_with_invalid_child() {
     let svg = "
@@ -14,6 +16,65 @@ fn clippath_with_invalid_child() {
     assert_eq!(tree.root().has_children(), false);
 }
 
+#[test]
+fn stylesheet_injection() {
+    let svg = "<svg id='svg1' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'>
+    <style>
+        #rect4 {
+            fill: green
+        }
+    </style>
+    <rect id='rect1' x='20' y='20' width='60' height='60'/>
+    <rect id='rect2' x='120' y='20' width='60' height='60' fill='green'/>
+    <rect id='rect3' x='20' y='120' width='60' height='60' style='fill: green'/>
+    <rect id='rect4' x='120' y='120' width='60' height='60'/>
+</svg>
+";
+
+    let stylesheet = "rect { fill: red }".to_string();
+
+    let options = usvg::Options {
+        style_sheet: Some(stylesheet),
+        ..usvg::Options::default()
+    };
+
+    let tree = usvg::Tree::from_str(&svg, &options).unwrap();
+
+    let usvg::Node::Path(ref first) = &tree.root().children()[0] else {
+        unreachable!()
+    };
+
+    // Only the rects with no CSS attributes should be overridden.
+    assert_eq!(
+        first.fill().unwrap().paint(),
+        &usvg::Paint::Color(Color::new_rgb(255, 0, 0))
+    );
+
+    let usvg::Node::Path(ref second) = &tree.root().children()[1] else {
+        unreachable!()
+    };
+    assert_eq!(
+        second.fill().unwrap().paint(),
+        &usvg::Paint::Color(Color::new_rgb(255, 0, 0))
+    );
+
+    let usvg::Node::Path(ref third) = &tree.root().children()[2] else {
+        unreachable!()
+    };
+    assert_eq!(
+        third.fill().unwrap().paint(),
+        &usvg::Paint::Color(Color::new_rgb(0, 128, 0))
+    );
+
+    let usvg::Node::Path(ref third) = &tree.root().children()[3] else {
+        unreachable!()
+    };
+    assert_eq!(
+        third.fill().unwrap().paint(),
+        &usvg::Paint::Color(Color::new_rgb(0, 128, 0))
+    );
+}
+
 #[test]
 fn simplify_paths() {
     let svg = "