Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions crates/resvg/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -240,6 +242,7 @@ struct CliArgs {
skip_system_fonts: bool,
list_fonts: bool,
style_sheet: Option<path::PathBuf>,
color_scheme: usvg::ColorScheme,

query_all: bool,
export_id: Option<String>,
Expand Down Expand Up @@ -310,6 +313,9 @@ fn collect_args() -> Result<CliArgs, pico_args::Error> {

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"),
Expand Down Expand Up @@ -372,6 +378,14 @@ fn parse_languages(s: &str) -> Result<Vec<String>, String> {
Ok(langs)
}

fn parse_color_scheme(s: &str) -> Result<usvg::ColorScheme, String> {
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,
Expand Down Expand Up @@ -578,6 +592,7 @@ fn parse_args() -> Result<Args, String> {
font_resolver: usvg::FontResolver::default(),
fontdb: Arc::new(fontdb::Database::new()),
style_sheet,
color_scheme: args.color_scheme,
};

Ok(Args {
Expand Down
15 changes: 15 additions & 0 deletions crates/usvg/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -139,6 +141,7 @@ struct Args {
coordinates_precision: Option<u8>,
transforms_precision: Option<u8>,
style_sheet: Option<PathBuf>,
color_scheme: usvg::ColorScheme,

quiet: bool,

Expand Down Expand Up @@ -209,6 +212,9 @@ fn collect_args() -> Result<Args, pico_args::Error> {
.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"),

Expand Down Expand Up @@ -285,6 +291,14 @@ fn parse_precision(s: &str) -> Result<u8, String> {
}
}

fn parse_color_scheme(s: &str) -> Result<usvg::ColorScheme, String> {
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,
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions crates/usvg/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -157,7 +162,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, 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)
}
}
Expand Down
24 changes: 24 additions & 0 deletions crates/usvg/src/parser/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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<String>,

/// The color scheme to use when resolving CSS `light-dark()` function.
///
/// Default: `ColorScheme::Light`
pub color_scheme: ColorScheme,
}

impl Default for Options<'_> {
Expand All @@ -119,6 +142,7 @@ impl Default for Options<'_> {
#[cfg(feature = "text")]
fontdb: Arc::new(fontdb::Database::new()),
style_sheet: None,
color_scheme: ColorScheme::default(),
}
}
}
Expand Down
121 changes: 116 additions & 5 deletions crates/usvg/src/parser/svgtree/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Document<'input>, Error> {
parse(xml, injected_stylesheet)
parse(xml, injected_stylesheet, color_scheme)
}

pub(crate) fn append(&mut self, parent_id: NodeId, kind: NodeKind) -> NodeId {
Expand Down Expand Up @@ -65,6 +147,7 @@ impl<'input> Document<'input> {
fn parse<'input>(
xml: &roxmltree::Document<'input>,
injected_stylesheet: Option<&'input str>,
color_scheme: ColorScheme,
) -> Result<Document<'input>, Error> {
let mut doc = Document {
nodes: Vec::new(),
Expand Down Expand Up @@ -101,6 +184,7 @@ fn parse<'input>(
0,
&mut doc,
&id_map,
color_scheme,
)?;

// Check that the root element is `svg`.
Expand Down Expand Up @@ -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(
Expand All @@ -163,6 +248,7 @@ fn parse_xml_node_children<'input>(
depth,
doc,
id_map,
color_scheme,
)?;
}

Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -213,6 +317,7 @@ fn parse_xml_node<'input>(
depth + 1,
doc,
id_map,
color_scheme,
)?;
}

Expand All @@ -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<NodeId, Error> {
let attrs_start_idx = doc.attrs.len();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -618,6 +728,7 @@ fn parse_svg_use_element<'input>(
depth + 1,
doc,
id_map,
color_scheme,
)
}

Expand Down
Loading