const NEUTRAL_PITCH: &str = "+0Hz";
const NEUTRAL_VOLUME: &str = "+0%";
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='{NEUTRAL_PITCH}' rate='{rate}' volume='{NEUTRAL_VOLUME}'>\
{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<"));
}
#[test]
fn xml_escape_empty_string() {
assert_eq!(xml_escape(""), "");
}
#[test]
fn xml_escape_no_special_chars() {
assert_eq!(xml_escape("hello world"), "hello world");
}
#[test]
fn sanitize_preserves_tab_and_cr() {
assert_eq!(sanitize_control_chars("a\tb"), "a\tb");
assert_eq!(sanitize_control_chars("a\rb"), "a\rb");
}
#[test]
fn sanitize_all_control_chars_replaced() {
let input: String = (0u8..=31).map(|b| b as char).collect();
let cleaned = sanitize_control_chars(&input);
let spaces = cleaned.chars().filter(|&c| c == ' ').count();
let control_chars = 9 + 2 + 18; assert_eq!(spaces, control_chars);
}
#[test]
fn build_ssml_escapes_special_chars() {
let ssml = build_ssml("a<b>&c", "v", "+0%", "en");
assert!(ssml.contains("<"));
assert!(ssml.contains(">"));
assert!(ssml.contains("&"));
assert!(!ssml.contains("a<b>&c"));
}
#[test]
fn build_ssml_empty_text() {
let ssml = build_ssml("", "v", "+0%", "en");
assert!(ssml.contains("></prosody>"));
}
}