collective

Browse / github.com/storytold/soundcraft / patch

Strip XML-forbidden characters from MusicXML/SVG score export

Strip XML-forbidden characters from MusicXML/SVG score export Found while testing `crates/engine/src/score.rs:216-218 (xml_escape), used by musicxml() and svg()` (no existing issue): MusicXML/SVG score export writes control characters from track names, which makes invalid XML. ## Cause `xml_escape` only replaced `&`, `<`, `>` and `"`. Control characters and other code points XML 1.0 forbids, such as U+0001 and U+FFFE, passed through unchanged from track names, the title and the composer. Strict MusicXML and SVG parsers reject the resulting files. ## Fix `xml_escape` now drops every character outside the XML 1.0 `Char` production before applying the exi

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #1061, open)
Base
541096d2cb5f96a13d5b58935e45711530471480
Size
+22 −1 in 1 file
Review
pending
Shared
2026-10-28
Id
f9675458f82d31d9c8573746

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/score.rs

diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs--- a/crates/engine/src/score.rs+++ b/crates/engine/src/score.rs@@ -214,7 +214,13 @@ fn spell(pitch: u8) -> (char, i32, i32) { }  fn xml_escape(s: &str) -> String {-    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")+    s.chars()+        .filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))+        .collect::<String>()+        .replace('&', "&amp;")+        .replace('<', "&lt;")+        .replace('>', "&gt;")+        .replace('"', "&quot;") }  fn note_type(units: u32) -> (&'static str, bool) {@@ -558,4 +564,19 @@ mod tests {         let s = svg(&parts, &setup);         assert!(s.starts_with("<svg") && s.contains("<ellipse"));     }++    #[test]+    fn exports_drop_characters_xml_forbids() {+        let e = crate::demo::demo_engine();+        let mut parts = layout(e.session(), &[]);+        parts[0].name = "Lead\u{1}1\u{FFFE}".to_string();+        let mut setup = ScoreSetup::from_session(e.session());+        setup.title = "T\u{2}itle".to_string();+        setup.composer = "C\u{3}omp".to_string();+        for out in [musicxml(&parts, &setup), svg(&parts, &setup)] {+            let bad: Vec<char> =+                out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect();+            assert!(bad.is_empty(), "forbidden characters: {bad:?}");+        }+    } }