diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 1f60936f2..41632cd0c 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -166,6 +166,20 @@ fn xml_attr_escape(s: &str) -> String { xml_escape(s).replace('\t', " ").replace('\n', " ").replace('\r', " ") } +/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line +/// and page breaks (U+000B, U+000C) other apps paste, which become spaces. +fn xml_text(s: &str) -> String { + let s: String = s + .chars() + .filter_map(|c| match c { + '\u{b}' | '\u{c}' => Some(' '), + c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c), + _ => None, + }) + .collect(); + xml_escape(&s) +} + fn valid_xml_value(s: &str) -> bool { s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) } @@ -1905,7 +1919,7 @@ impl Writer<'_> { } let diff = run_diff(&base.props, self.char_props(st, base.space)); attrs.push_str(&self.attrs(&diff)); - let text = xml_escape(&seg.text); + let text = xml_text(&seg.text); if attrs.is_empty() && fewer { s.push_str(&text); } else { @@ -2010,7 +2024,7 @@ impl Writer<'_> { for run in &t.runs { let diff = run_diff(&base, self.char_props(&run.style, space)); let a = self.attrs(&diff); - s.push_str(&format!("{}", xml_escape(&run.text.replace('\n', " ")))); + s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } s.push_str(""); self.line(&s); diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 8439e720f..f1d14c9af 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -386,6 +386,18 @@ fn text_roundtrip_and_escaping() { assert!(t.xf.translation().to_point().distance(Point::new(20.0, 50.0)) < 0.01); } +#[test] +fn text_with_control_characters_exports_well_formed_svg() { + // A pasted manual line break (U+000B) or other C0 control character is not allowed in XML 1.0. + let t = TextObject::point(Point::new(20.0, 50.0), "A\u{0}\u{8}B\u{b}C\u{fffe}D", CharStyle::default()); + let id = Document::new(200.0, 200.0).alloc_id(); + let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]); + let s = export(&d, &ExportOptions::default()); + let r = import(&s).unwrap_or_else(|e| panic!("exported SVG doesn't import: {e:?}")); + let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() }; + assert_eq!(t.plain_text(), "AB CD"); +} + #[test] fn scaled_text_roundtrips_at_the_size_it_draws() { // Type scaled with its object (its transform scales it) comes back at the size it draws at,