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,