SVG import preserves selector matches while retaining metadata

This commit is contained in:
dotCooCoo
2026-11-03 21:09:57 +07:00
committed by bflatastic
parent 0f5e4c261a
commit b751dce8e3
8 changed files with 262 additions and 16 deletions
Generated
+1
View File
@@ -6458,6 +6458,7 @@ dependencies = [
"resvg",
"serde",
"serde_json",
"simplecss",
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
+1
View File
@@ -28,6 +28,7 @@ flate2 = { workspace = true }
image = { workspace = true }
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
svgtypes = "0.16"
simplecss = "0.2.2"
[dev-dependencies]
# Reading embedded font subsets back.
+3 -2
View File
@@ -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 {
+29 -4
View File
@@ -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 `<circle>` and `<ellipse>` elements, which come back as the ellipse VectorCraft
/// draws ([`drawn_ellipse`](super::convert::drawn_ellipse)).
pub(super) ellipses: HashSet<String>,
/// The text importer reads styles from this SVG before tracking IDs are added.
pub(super) text_source: Option<String>,
pub(super) warnings: Vec<String>,
}
@@ -38,6 +42,8 @@ pub(super) struct Found {
#[derive(Default)]
pub(crate) struct Edits {
list: Vec<(std::ops::Range<usize>, String)>,
/// These edits assign tracking IDs.
id_edits: Vec<(std::ops::Range<usize>, String)>,
/// The ids made up so far, by element.
ids: HashMap<roxmltree::NodeId, String>,
/// 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<usize> {
/// 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 `<svg:image>`.)
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 `:`)?
+113
View File
@@ -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> {
self.0.parent_element().map(Self)
}
fn prev_sibling_element(&self) -> Option<Self> {
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<String> = 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#"<svg xmlns="http://www.w3.org/2000/svg"><style>circle {fill:red}</style><circle/><circle/></svg>"#;
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 = "<circle/>".repeat(1000);
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><style>{rules}</style>{elements}</svg>"#);
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#"<svg xmlns="http://www.w3.org/2000/svg"><style>circle[id]{fill:red}<!-- -->text{font-size:30px}</style><text>x</text></svg>"#;
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;"));
}
}
+23 -10
View File
@@ -188,17 +188,30 @@ impl TextSlots {
}
/// Replace every `<text>` 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<String>) -> (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<String>,
) -> (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));
+52
View File
@@ -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#"<style>circle[id], ellipse[id] {{ fill:red }}</style><circle {attrs} cx="40" cy="50" r="20"/><ellipse {attrs} cx="100" cy="50" rx="20" ry="10"/>"#
));
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#"<style>circle[id] {fill:red} .dot {fill:blue} circle {fill:green}</style><circle class="dot" data-foo="kept" cx="40" cy="50" r="20"/>"#,
r#"<style>circle[id] {fill:red} circle {fill:blue}</style><style>circle {fill:green}</style><circle data-foo="kept" cx="40" cy="50" r="20"/>"#,
r#"<style>circle[id] {fill:red} circle {fill:blue!important}</style><circle style="fill:green" data-foo="kept" cx="40" cy="50" r="20"/>"#,
r#"<style>circle[id] {fill:red} circle {fill:blue!important}</style><circle style="fill:green!important" data-foo="kept" cx="40" cy="50" r="20"/>"#,
r#"<style>circle[id] {fill:red} g {fill:blue} circle + ellipse {fill:green}</style><g><circle data-foo="kept" cx="40" cy="50" r="20"/><ellipse cx="100" cy="50" rx="20" ry="10"/></g>"#,
r#"<style>circle[id] {fill:red} circle:first-child {fill:blue}</style><g><circle data-foo="kept" cx="40" cy="50" r="20"/><circle cx="100" cy="50" r="20"/></g>"#,
r#"<style>circle[id] {fill:red} #existing {fill:blue}</style><circle id="existing" data-foo="kept" cx="40" cy="50" r="20"/>"#,
] {
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();
+40
View File
@@ -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#"<text y="20">Hi</text>"#, (255, 0, 0)),
("#t{fill:blue} [id]{fill:red}", r#"<text id="t" y="20">Hi</text>"#, (0, 0, 255)),
("#holder{fill:blue} g[id]{fill:red}", r#"<g id="holder"><text y="20"><tspan>Hi</tspan></text></g>"#, (0, 0, 255)),
] {
for metadata in [false, true] {
let attribute = if metadata { r#" data-note="kept""# } else { "" };
let doc = open(&format!(r#"<style>{rules} rect[id]{{fill:black}}</style>{text}<rect width="5" height="5"{attribute}/>"#));
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#"<style>g{font-size:10px} text{font-size:2em} tspan{font-size:1.5em}</style><g transform="translate(4 5)"><text y="20">A<tspan>B</tspan></text></g>"#,
r#"<style>text{display:none;fill:red}</style><text y="20">Hidden</text>"#,
r#"<style>text{transform:translate(10px,20px)}</style><text y="20">Moved</text>"#,
r##"<defs><path id="p" d="M0 20L150 20"/></defs><text font-size="12"><textPath href="#p">Path</textPath></text>"##,
] {
let load = |attribute: &str| {
let doc = open(&format!(r#"<style>rect[id]{{fill:black}}</style>{body}<rect width="5" height="5"{attribute}/>"#));
art(&doc)
.into_iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some((n.visible, serde_json::to_value(t).unwrap())),
_ => None,
})
.collect::<Vec<_>>()
};
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#"<rect width="10" height="10"/><text x="5" y="20">Hi</text><rect y="30" width="10" height="10"/>"#);