diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs
--- a/crates/engine/src/cmd/xml.rs
+++ b/crates/engine/src/cmd/xml.rs
@@ -37,8 +37,21 @@ fn tagged(d: &Document) -> Vec<ItemId> {
     keyed.into_iter().map(|(_, i)| i).collect()
 }
 
+/// Escapes markup characters and drops characters XML 1.0 cannot hold.
 fn esc(s: &str) -> String {
-    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            '\t' | '\n' | '\r' => out.push(c),
+            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
+            c => out.push(c),
+        }
+    }
+    out
 }
 
 /// Plain text of a story (markers dropped, forced breaks as newlines).
@@ -475,6 +488,18 @@ mod tests {
         assert!(s.execute("xml.validate", &json!({})).is_err());
     }
 
+    #[test]
+    fn export_xml_drops_forbidden_control_chars() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let a = s
+            .execute("frame.create", &json!({"rect": [72, 72, 400, 150], "content": "text", "text": "A\u{1}B\u{8}C\u{FFFE}\u{FFFF}", "caret": false}))
+            .unwrap();
+        s.execute("xml.tag", &json!({"tag": "story", "ids": [a["id"]]})).unwrap();
+        let x = s.execute("file.exportXml", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
+        assert!(x.contains("<story>ABC</story>"), "{x:?}");
+    }
+
     #[test]
     fn tag_export_and_import_xml() {
         let mut s = Session::new();
