diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 918033a60..b1377d2fe 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -1239,7 +1239,7 @@ impl Clipboard { /// filter when pasted back into the document it came from, and elsewhere keeps its own under a /// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would /// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else - /// a free letter or digit; the filter's id is always its name. An error when no free name fits. + /// a free letter, digit or other printable ASCII character; the filter's id is always its name. An error when no free name fits. fn filter_links(&self, d: &Document, same_doc: bool) -> Result> { let mut found = BTreeSet::new(); for n in &self.nodes { @@ -1281,12 +1281,18 @@ impl Clipboard { } } // Free names made from the starts of the name, longest first, then free letters and - // digits: the first under which the filter still draws. + // digits, then other single printable ASCII characters: the first under which the + // filter still draws. let mut ends: Vec = name.char_indices().map(|(i, _)| i).skip(1).collect(); ends.push(name.len()); let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty()); let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n))); - let single = ('a'..='z').chain('0'..='9').chain('A'..='Z').map(String::from).filter(|n| !taken.contains(n)); + let single = ('a'..='z') + .chain('0'..='9') + .chain('A'..='Z') + .chain(('!'..='~').filter(|c| !c.is_ascii_alphanumeric())) + .map(String::from) + .filter(|n| !taken.contains(n)); let renamed = shorter.chain(single).find_map(|new| { let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new) .filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?; diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index ad4ac7e23..10f0ad75c 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -330,7 +330,7 @@ fn pasted_art_takes_no_name_the_documents_brushes_use() { fn a_renamed_paste_takes_a_free_capital_letter() { let mut s = session(); for n in ('a'..='z').chain('0'..='9') { - run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); } run(&mut s, "svgFilter.define", json!({"xml": r#""#})); let (open, close) = (r#""#); @@ -347,13 +347,24 @@ fn a_renamed_paste_takes_a_free_capital_letter() { assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); } -/// A pasted filter at the size limit with a one-letter name, when every free name it could take is -/// in use, is refused rather than pasted under the name of the document's filter. +/// A filter named `n`, with the characters a quoted attribute value cannot hold escaped. +fn one_char_filter(n: char) -> String { + let id = match n { + '"' => """.to_string(), + '&' => "&".to_string(), + '<' => "<".to_string(), + c => c.to_string(), + }; + format!(r#""#) +} + +/// A pasted filter at the size limit with a one-letter name takes a free punctuation character when +/// every letter and digit is in use. #[test] -fn a_paste_with_no_free_name_for_its_filter_is_refused() { +fn a_renamed_paste_takes_a_free_punctuation_character() { let mut s = session(); for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { - run(&mut s, "svgFilter.define", json!({"xml": format!(r#""#)})); + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); } let (open, close) = (r#""#); let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); @@ -361,9 +372,41 @@ fn a_paste_with_no_free_name_for_its_filter_is_refused() { r#"{big}"# ); run(&mut s, "clipboard.importSvg", json!({"svg": svg})); - let before = steps(&s); + run(&mut s, "edit.pasteInPlace", json!({})); + let pasted = filter_effects(&s); + assert_eq!(pasted.len(), 1); + assert_eq!(pasted[0].0, "!"); + assert_eq!(vectorcraft_render::effects::svg_filter::check(&pasted[0].1), Ok("!".to_string())); + assert!(pasted[0].1.contains("dx=\"9\""), "its own filter"); +} + +/// A pasted filter at the size limit with a one-letter name, when every free name it could take is +/// in use, is refused rather than pasted under the name of the document's filter. +#[test] +fn a_paste_with_no_free_name_for_its_filter_is_refused() { + let mut s = session(); + for n in ('a'..='z').chain('0'..='9').chain('A'..='Z') { + run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)})); + } + // A document holds at most 64 filters: the other one-character names are taken by effects + // whose filters were deleted. + for n in ('!'..='~').filter(|c| !c.is_ascii_alphanumeric()) { + let a = rect(&mut s, 10.0); + let name = run(&mut s, "svgFilter.define", json!({"xml": one_char_filter(n)}))["name"].as_str().unwrap().to_string(); + assert_eq!(name, n.to_string()); + run(&mut s, "svgFilter.apply", json!({"name": name, "ids": [a.0]})); + run(&mut s, "svgFilter.delete", json!({"name": name})); + } + let (open, close) = (r#""#); + let big = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len())); + let svg = format!( + r#"{big}"# + ); + run(&mut s, "clipboard.importSvg", json!({"svg": svg})); + let (before, effects) = (steps(&s), filter_effects(&s).len()); + assert_eq!(effects, 32); assert!(err(&mut s, "edit.pasteInPlace", json!({})).contains("no free name")); - assert!(filter_effects(&s).is_empty(), "nothing pasted"); + assert_eq!(filter_effects(&s).len(), effects, "nothing pasted"); assert_eq!(steps(&s), before); }