mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 06:01:12 +00:00
SVG import preserves selector matches while retaining metadata
This commit is contained in:
Generated
+1
@@ -6458,6 +6458,7 @@ dependencies = [
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simplecss",
|
||||
"skrifa 0.47.0",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 `:`)?
|
||||
|
||||
@@ -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;"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"/>"#);
|
||||
|
||||
Reference in New Issue
Block a user