diff --git a/Cargo.lock b/Cargo.lock index 46998c4c3..137e9a2be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6458,6 +6458,7 @@ dependencies = [ "resvg", "serde", "serde_json", + "simplecss", "skrifa 0.47.0", "svgtypes 0.16.1", "thiserror 2.0.21", diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 6ef3b64dd..44b8c696e 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -28,6 +28,7 @@ flate2 = { workspace = true } image = { workspace = true } # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" +simplecss = "0.2.2" [dev-dependencies] # Reading embedded font subsets back. diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 0201c591e..78c12043f 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -113,7 +113,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let Some(height) = depth::height(&xml, depth::MAX_ELEMENT_DEPTH) else { return Err(too_deep()); }; - let (svg, found) = prepass(svg, &scan.markers, &xml, opts); + let (svg, found) = prepass(svg, &scan.markers, &xml, opts)?; // The prepass's edits are in the text: the XML is read again only when it made any. let xml = match &svg { Cow::Borrowed(_) => xml, @@ -126,7 +126,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let units = RootUnits::of(xml.root_element()); let root_name = root_layer_name(xml.root_element()); let mut warnings = found.warnings; - let (src, slots) = text::prepare(svg, &xml, units.dpi, &mut warnings); + let text_xml = found.text_source.as_deref().map(parse).transpose()?; + let (src, slots) = text::prepare(svg, &xml, text_xml.as_ref(), units.dpi, &mut warnings); let files = found.files; let tree = { let opt = usvg::Options { diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 5a393e79a..f02f436cd 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -5,6 +5,8 @@ use super::{ symbol_units, vector_effect, }; +mod id_styles; + /// What usvg would lose, found in the XML before it runs ([`prepass`]). #[derive(Default)] pub(super) struct Found { @@ -31,6 +33,8 @@ pub(super) struct Found { /// Ids of the `` and `` elements, which come back as the ellipse VectorCraft /// draws ([`drawn_ellipse`](super::convert::drawn_ellipse)). pub(super) ellipses: HashSet, + /// The text importer reads styles from this SVG before tracking IDs are added. + pub(super) text_source: Option, pub(super) warnings: Vec, } @@ -38,6 +42,8 @@ pub(super) struct Found { #[derive(Default)] pub(crate) struct Edits { list: Vec<(std::ops::Range, String)>, + /// These edits assign tracking IDs. + id_edits: Vec<(std::ops::Range, String)>, /// The ids made up so far, by element. ids: HashMap, /// The numbers `n` of the `{MADE_UP_ID}n` the file names (as an element's id, in a style @@ -70,7 +76,13 @@ impl Edits { } let id = format!("{MADE_UP_ID}{}", self.next); self.next += 1; - self.set(svg, n, "id", &id); + match n.attributes().find(|a| a.name() == "id" && a.namespace().is_none()) { + Some(a) => self.id_edits.push((a.range_value(), id.clone())), + None => { + let at = tag_name_end(svg, n); + self.id_edits.push((at..at, format!(" id=\"{id}\""))); + } + } self.ids.insert(n.id(), id.clone()); id } @@ -98,6 +110,7 @@ impl Edits { /// `svg` with the edits made; overlapping ones and ranges off character boundaries are /// skipped. fn apply(mut self, svg: &str) -> Cow<'_, str> { + self.list.append(&mut self.id_edits); for (at, has_style, decls) in std::mem::take(&mut self.styles).into_values() { self.insert(at, if has_style { decls } else { format!(" style=\"{}\"", decls.strip_prefix(';').unwrap_or(&decls)) }); } @@ -157,7 +170,12 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet { /// Whether each is there at all is read from `markers`: `svg` with its data URLs left out /// ([`depth::prescan`]), so an embedded picture isn't searched a dozen times. The edits are made /// in `svg` itself. -pub(super) fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document, opts: &ImportOptions) -> (Cow<'s, str>, Found) { +pub(super) fn prepass<'s>( + svg: &'s str, + markers: &str, + xml: &roxmltree::Document, + opts: &ImportOptions, +) -> Result<(Cow<'s, str>, Found), crate::SvgError> { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) let css_transform = has_css_transform(markers); @@ -176,7 +194,7 @@ pub(super) fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document .iter() .any(|t| markers.contains(t)) { - return (svg.into(), found); + return Ok((svg.into(), found)); } let mut edits = Edits::default(); let elements = || xml.descendants().filter(|n| n.is_element()); @@ -245,7 +263,14 @@ pub(super) fn prepass<'s>(svg: &'s str, markers: &str, xml: &roxmltree::Document if css_transform { edits.list.extend(css_transform::rewrites(svg, xml)); } - (edits.apply(svg), found) + if attribute_selectors && !edits.ids.is_empty() { + if elements().any(|n| matches!(n.tag_name().name(), "text" | "flowRoot")) { + let text_edits = Edits { list: edits.list.clone(), styles: edits.styles.clone(), ..Default::default() }; + found.text_source = Some(text_edits.apply(svg).into_owned()); + } + id_styles::preserve_matches(svg, xml, &mut edits)?; + } + Ok((edits.apply(svg), found)) } /// Is there a `transform` CSS declaration in `markers` (`transform` followed by `:`)? diff --git a/crates/svg/src/import/prepass/id_styles.rs b/crates/svg/src/import/prepass/id_styles.rs new file mode 100644 index 000000000..7804dfa4d --- /dev/null +++ b/crates/svg/src/import/prepass/id_styles.rs @@ -0,0 +1,113 @@ +use super::{Edits, XNode, css_transform, roxmltree}; +use crate::SvgError; + +struct Element<'a, 'input>(XNode<'a, 'input>); + +impl simplecss::Element for Element<'_, '_> { + fn parent_element(&self) -> Option { + self.0.parent_element().map(Self) + } + + fn prev_sibling_element(&self) -> Option { + self.0.prev_sibling_element().map(Self) + } + + fn has_local_name(&self, name: &str) -> bool { + self.0.tag_name().name() == name + } + + fn attribute_matches(&self, name: &str, operator: simplecss::AttributeOperator<'_>) -> bool { + self.0.attribute(name).is_some_and(|value| operator.matches(value)) + } + + fn pseudo_class_matches(&self, class: simplecss::PseudoClass<'_>) -> bool { + matches!(class, simplecss::PseudoClass::FirstChild) && self.0.prev_sibling_element().is_none() + } +} + +fn append(out: &mut String, declaration: &simplecss::Declaration<'_>, remaining: &mut usize) -> Result<(), SvgError> { + let transform = if declaration.name == "transform" { css_transform::to_svg(declaration.value) } else { None }; + let value = transform.as_deref().unwrap_or(declaration.value); + let important = if declaration.important { "!important" } else { "" }; + let text = crate::xml_escape(&format!("{}:{value}{important};", declaration.name)); + *remaining = remaining.checked_sub(text.len()).ok_or_else(|| SvgError::TooBig("expanded SVG styles exceed the import size limit".into()))?; + out.push_str(&text); + Ok(()) +} + +pub(super) fn preserve_matches(svg: &str, xml: &roxmltree::Document, edits: &mut Edits) -> Result<(), SvgError> { + preserve_with_limit(svg, xml, edits, usize::try_from(crate::MAX_SVGZ).unwrap_or(usize::MAX)) +} + +fn preserve_with_limit(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, mut remaining: usize) -> Result<(), SvgError> { + let styles: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("style") && n.attribute("type").is_none_or(|t| t == "text/css")).collect(); + let texts: Vec = styles.iter().map(|n| n.children().filter(XNode::is_text).filter_map(|n| n.text()).collect()).collect(); + let mut sheet = simplecss::StyleSheet::new(); + for text in &texts { + sheet.parse_more(text); + } + let element_count = xml.descendants().filter(XNode::is_element).count(); + if element_count.checked_mul(sheet.rules.len()).is_none_or(|work| work > 1_000_000) { + return Err(SvgError::TooBig("SVG stylesheet matching exceeds the import work limit".into())); + } + for node in xml.descendants().filter(XNode::is_element) { + let mut declarations = String::new(); + for rule in sheet.rules.iter().filter(|r| r.selector.matches(&Element(node))) { + for declaration in &rule.declarations { + append(&mut declarations, declaration, &mut remaining)?; + } + } + if declarations.is_empty() { + continue; + } + for declaration in simplecss::DeclarationTokenizer::from(node.attribute("style").unwrap_or_default()) { + append(&mut declarations, &declaration, &mut remaining)?; + } + if let Some((_, _, previous)) = edits.styles.remove(&node.id()) { + declarations.push_str(&previous); + } + if let Some(attribute) = node.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { + edits.list.push((attribute.range_value(), declarations)); + } else { + edits.style(svg, node, &declarations); + } + } + for style in styles { + edits.list.extend(style.children().map(|child| (child.range(), String::new()))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expanded_styles_are_bounded() { + let svg = r#""#; + let xml = roxmltree::Document::parse(svg).unwrap(); + assert!(matches!(preserve_with_limit(svg, &xml, &mut Edits::default(), 1), Err(SvgError::TooBig(_)))); + assert!(preserve_with_limit(svg, &xml, &mut Edits::default(), 64).is_ok()); + } + + #[test] + fn selector_matching_is_bounded_even_without_matches() { + let rules = "circle[missing]{fill:red}".repeat(1001); + let elements = "".repeat(1000); + let svg = format!(r#"{elements}"#); + let xml = roxmltree::Document::parse(&svg).unwrap(); + assert!(matches!(preserve_matches(&svg, &xml, &mut Edits::default()), Err(SvgError::TooBig(_)))); + } + + #[test] + fn stylesheet_text_after_comments_is_preserved() { + let svg = r#"x"#; + let xml = roxmltree::Document::parse(svg).unwrap(); + let mut edits = Edits::default(); + preserve_matches(svg, &xml, &mut edits).unwrap(); + let output = edits.apply(svg); + let xml = roxmltree::Document::parse(&output).unwrap(); + let text = xml.descendants().find(|n| n.has_tag_name("text")).unwrap(); + assert_eq!(text.attribute("style"), Some("font-size:30px;")); + } +} diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 0e26fc524..57a156b82 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -188,17 +188,30 @@ impl TextSlots { } /// Replace every `` usvg would draw by a placeholder group (see the module docs). -pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, warnings: &mut Vec) -> (Cow<'s, str>, TextSlots) { +/// `original_xml` supplies text styles; `xml` supplies placeholder positions and tracking IDs. +pub(super) fn prepare<'s>( + svg: &'s str, + xml: &roxmltree::Document, + original_xml: Option<&roxmltree::Document>, + dpi: f64, + warnings: &mut Vec, +) -> (Cow<'s, str>, TextSlots) { let mut prefix = String::from("vectorcraft-text-"); while xml.descendants().any(|n| n.attribute("id").is_some_and(|id| id.starts_with(&prefix))) { prefix.insert(0, '_'); } let mut slots = TextSlots { prefix, texts: vec![] }; - let cx = Ctx::new(xml, dpi); + let style_xml = original_xml.unwrap_or(xml); + let cx = Ctx::new(style_xml, dpi); let mut out = String::new(); let mut copied = 0; - let is_text = |n: &XNode| n.is_element() && matches!(n.tag_name().name(), "text" | "flowRoot") && n.tag_name().namespace() == Some(SVG_NS); - for t in xml.descendants().filter(is_text) { + let is_text = |n: &XNode| { + n.is_element() + && matches!(n.tag_name().name(), "text" | "flowRoot") + && n.tag_name().namespace() == Some(SVG_NS) + && !n.ancestors().any(|a| a.has_tag_name("style")) + }; + for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") { warnings.push(format!("{label} in a clip path ignored")); @@ -216,13 +229,13 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war }; let flowed = t.tag_name().name() == "flowRoot"; let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue }; - if cx.css.own(t, "display").as_deref() == Some("none") || r.start < copied { + if cx.css.own(style_t, "display").as_deref() == Some("none") || r.start < copied { continue; } - let built = if flowed { cx.build_flow(t, &label, warnings) } else { cx.build(xml, t, &label, warnings) }; + let built = if flowed { cx.build_flow(style_t, &label, warnings) } else { cx.build(style_xml, style_t, &label, warnings) }; let Some((text, paints)) = built else { continue }; out.push_str(&svg[copied..r.start]); - placeholder(&mut out, q, &cx.css, t, &slots.prefix, slots.texts.len(), text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, slots.texts.len(), text.marker, &paints); copied = r.end; slots.texts.push(text); } @@ -235,7 +248,7 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war /// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. #[allow(clippy::too_many_arguments)] -fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { +fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { let _ = write!(out, "<{q}g"); for a in ["id", "transform", "systemLanguage", "requiredFeatures", "requiredExtensions"] { if let Some(v) = t.attribute(a) { @@ -247,9 +260,9 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, prefix: &str, for (p, initial) in [("opacity", "1"), ("clip-path", "none"), ("mask", "none"), ("filter", "none"), ("mix-blend-mode", "normal"), ("isolation", "auto")] { - let _ = write!(style, "{p}:{};", css.own(t, p).as_deref().unwrap_or(initial)); + let _ = write!(style, "{p}:{};", css.own(style_t, p).as_deref().unwrap_or(initial)); } - if let Some(v) = css.own(t, "visibility") { + if let Some(v) = css.own(style_t, "visibility") { let _ = write!(style, "visibility:{v};"); } let _ = write!(out, " style=\"{}display:inline\">", xml_escape(&style)); diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index dbac48947..b4dd58256 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -37,6 +37,58 @@ fn decoded_id_selectors_do_not_recolor_bare_ellipses() { } } +#[test] +fn metadata_ids_preserve_attribute_selector_colors_and_metadata() { + for attrs in [r#"data-foo="kept""#, r#"xmlns:meta="https://example.org/metadata" meta:key="kept""#, r#"onclick="kept""#] { + let source = svg(&format!( + r#""# + )); + let doc = import(&source).unwrap(); + let actual = render_artboard(&doc); + let expected = resvg_render(&source, 200, 120); + for (x, y) in [(40, 50), (100, 50)] { + let at = 4 * (y * actual.width + x) as usize; + assert_eq!(&actual.rgba[at..at + 4], &expected.rgba[at..at + 4], "{attrs}: ({x}, {y})"); + } + let has_metadata = |doc: &Document| { + art(doc) + .into_iter() + .filter(|n| { + n.extras().attrs.as_deref().is_some_and(|a| { + a.data.iter().any(|(k, v)| k == "foo" && v == "kept") + || a.foreign.iter().any(|a| a.ns == "https://example.org/metadata" && a.name == "key" && a.value == "kept") + || a.events.iter().any(|(k, v)| k == "onclick" && v == "kept") + }) + }) + .count() + }; + assert_eq!(has_metadata(&doc), 2, "{attrs}"); + let back = import(&export(&doc, &ExportOptions::default())).unwrap(); + assert_eq!(has_metadata(&back), 2, "round trip: {attrs}"); + } +} + +#[test] +fn metadata_id_styles_keep_the_original_cascade_and_structure() { + for body in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + let source = svg(body); + let actual = render_artboard(&import(&source).unwrap()); + let expected = resvg_render(&source, 200, 120); + for (x, y) in [(40, 50), (100, 50)] { + let at = 4 * (y * actual.width + x) as usize; + assert_eq!(&actual.rgba[at..at + 4], &expected.rgba[at..at + 4], "{body}: ({x}, {y})"); + } + } +} + /// All non-layer nodes in paint order. fn art(d: &Document) -> Vec<&Node> { let mut v = Vec::new(); diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index f688e60e3..5fa2b5d7f 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -51,6 +51,46 @@ fn close(a: f64, b: f64, tol: f64) -> bool { (a - b).abs() < tol } +#[test] +fn metadata_ids_preserve_the_original_text_cascade() { + for (rules, text, color) in [ + ("text:not(.x){fill:red!important} text{fill:blue!important}", r#"Hi"#, (255, 0, 0)), + ("#t{fill:blue} [id]{fill:red}", r#"Hi"#, (0, 0, 255)), + ("#holder{fill:blue} g[id]{fill:red}", r#"Hi"#, (0, 0, 255)), + ] { + for metadata in [false, true] { + let attribute = if metadata { r#" data-note="kept""# } else { "" }; + let doc = open(&format!(r#"{text}"#)); + let expected = Paint::solid(vectorcraft_color::Color::rgb8(color.0, color.1, color.2)); + assert_eq!(only_text(&doc).runs[0].style.fill, expected, "{rules}, metadata {metadata}"); + } + } +} + +#[test] +fn metadata_ids_preserve_text_preprocessing_and_relative_styles() { + for body in [ + r#"AB"#, + r#"Hidden"#, + r#"Moved"#, + r##"Path"##, + ] { + let load = |attribute: &str| { + let doc = open(&format!(r#"{body}"#)); + art(&doc) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((n.visible, serde_json::to_value(t).unwrap())), + _ => None, + }) + .collect::>() + }; + let expected = load(""); + assert!(!expected.is_empty(), "{body}"); + assert_eq!(load(r#" data-note="kept""#), expected, "{body}"); + } +} + #[test] fn text_keeps_its_z_order_between_shapes() { let d = open(r#"Hi"#);