fn sanitize_control_chars(text: &str) -> String {
text.chars()
.map(|c| {
let code = c as u32;
if code <= 8 || (11..=12).contains(&code) || (14..=31).contains(&code) {
' '
} else {
c
}
})
.collect()
}
pub(crate) fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('\'', "'")
.replace('"', """)
}
pub(crate) fn build_ssml(text: &str, voice: &str, rate: &str, lang: &str) -> String {
let clean = sanitize_control_chars(text);
let escaped = xml_escape(&clean);
format!(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='{lang}'>\
<voice name='{voice}'>\
<prosody pitch='+0Hz' rate='{rate}' volume='+0%'>\
{escaped}\
</prosody></voice></speak>"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xml_escape_basic() {
assert_eq!(
xml_escape("a<b>c&d'e\"f"),
"a<b>c&d'e"f"
);
}
#[test]
fn sanitize_strips_control_chars() {
let cleaned = sanitize_control_chars("a\x01b\x07c");
assert_eq!(cleaned, "a b c");
}
#[test]
fn sanitize_preserves_newlines() {
assert_eq!(sanitize_control_chars("a\nb"), "a\nb");
}
#[test]
fn build_ssml_contains_voice_and_rate() {
let ssml = build_ssml("hi", "en-US-EmmaMultilingualNeural", "+25%", "en-US");
assert!(ssml.contains("name='en-US-EmmaMultilingualNeural'"));
assert!(ssml.contains("rate='+25%'"));
assert!(ssml.contains("xml:lang='en-US'"));
assert!(ssml.contains(">hi<"));
}
}