diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs
index 661b6381e..1f9f79ac0 100644
--- a/crates/svg/src/export.rs
+++ b/crates/svg/src/export.rs
@@ -135,12 +135,12 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
}
if !doc.title.is_empty() {
out.push_str(&w.indent(1));
- out.push_str(&format!("
{}{nl}", xml_escape(&doc.title)));
+ out.push_str(&format!("{}{nl}", xml_text(&doc.title)));
}
// File Info's description.
if !doc.metadata.description.trim().is_empty() {
out.push_str(&w.indent(1));
- out.push_str(&format!("{}{nl}", xml_escape(&doc.metadata.description)));
+ out.push_str(&format!("{}{nl}", xml_text(&doc.metadata.description)));
}
// Window › SVG Interactivity › Link JavaScript Files.
for file in &doc.svg_scripts {
diff --git a/crates/svg/tests/title_control_chars.rs b/crates/svg/tests/title_control_chars.rs
new file mode 100644
index 000000000..c7f842c0b
--- /dev/null
+++ b/crates/svg/tests/title_control_chars.rs
@@ -0,0 +1,26 @@
+// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+use vectorcraft_doc::Document;
+use vectorcraft_svg::{ExportOptions, export, import};
+
+#[test]
+fn title_and_description_control_chars_are_filtered() {
+ let mut d = Document::new(200.0, 200.0);
+ d.title = "Poster\u{b}v2".into();
+ d.metadata.description = "a\u{1}b".into();
+ let svg = export(&d, &ExportOptions::default());
+ assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}");
+ assert!(svg.contains("Poster v2"));
+ assert!(svg.contains("ab"));
+}
+
+#[test]
+fn title_and_description_markup_is_escaped() {
+ let mut d = Document::new(200.0, 200.0);
+ d.title = "A & ".into();
+ d.metadata.description = "x & y".into();
+ let svg = export(&d, &ExportOptions::default());
+ assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}");
+ assert!(svg.contains("A & <B>"));
+ assert!(svg.contains("x & y"));
+}