Skip to main content

floravox_ssml/
lib.rs

1//! # floravox-ssml
2//!
3//! Streaming SSML / plain-text parser with byte- and char-exact source span
4//! tracking, built on `quick-xml`.
5//!
6//! Every word token emitted by [`parse`] carries its exact offsets into the
7//! raw input string, so downstream consumers (highlighting, index marks,
8//! lipsync) can map audio timing back onto original text — even when the input
9//! contains XML entities (`&amp;`), `<sub alias>` replacements, or
10//! `<phoneme>` overrides.
11//!
12//! ```
13//! use floravox_ssml::{parse, Segment};
14//!
15//! let doc = parse("<speak>Hello <mark name=\"m1\"/>world</speak>").unwrap();
16//! assert!(matches!(doc.segments[1], Segment::Mark { ref name, .. } if name == "m1"));
17//! ```
18
19use std::fmt;
20use std::ops::Range;
21
22/// Effective prosody for a word or segment, as resolved multipliers
23/// (1.0 = unchanged). `None` fields inherit the engine default.
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub struct Prosody {
26    /// Speaking-rate multiplier (0.8 = 20% slower).
27    pub rate: Option<f32>,
28    /// Pitch multiplier (1.05 = +5%).
29    pub pitch: Option<f32>,
30    /// Volume multiplier (1.2 = louder).
31    pub volume: Option<f32>,
32}
33
34impl Prosody {
35    /// Merge `other` on top of `self` (non-`None` fields win).
36    fn overlay(self, other: Self) -> Self {
37        Self {
38            rate: other.rate.or(self.rate),
39            pitch: other.pitch.or(self.pitch),
40            volume: other.volume.or(self.volume),
41        }
42    }
43
44    /// True when every field is `None` (engine defaults).
45    #[must_use]
46    pub fn is_default(&self) -> bool {
47        self.rate.is_none() && self.pitch.is_none() && self.volume.is_none()
48    }
49}
50
51/// How a word should be spoken, from `<say-as interpret-as="...">`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum SayAs {
54    /// No `<say-as>` in scope.
55    #[default]
56    None,
57    /// Spell out character by character (`interpret-as="characters"`).
58    Characters,
59    /// Read as cardinal number (`interpret-as="cardinal"` / `"number"`).
60    Cardinal,
61    /// Read as ordinal number (`interpret-as="ordinal"`).
62    Ordinal,
63    /// Date / time / currency / telephone — recorded for future expansion.
64    Other,
65}
66
67/// A word token with exact source mapping.
68///
69/// Byte and char offsets index into the **raw input string** (`char_end` /
70/// `byte_end` exclusive). When a word contains XML entities the byte span
71/// covers the full raw source of the decoded text.
72#[derive(Debug, Clone, PartialEq)]
73pub struct WordSpan {
74    /// The word as written (highlight/display target; e.g. `"WWW"`).
75    pub text: String,
76    /// The text that should be spoken (after `<sub alias>` substitution;
77    /// equals `text` unless inside a `<sub>`).
78    pub spoken: String,
79    /// Character range in the raw input.
80    pub char_span: Range<usize>,
81    /// Byte range in the raw input (UTF-8).
82    pub byte_span: Range<usize>,
83    /// Explicit phoneme override from `<phoneme ph="f ə n ɛ t ɪ k s">`.
84    /// Each element is one IPA symbol.
85    pub phonemes: Option<Vec<String>>,
86    /// Effective prosody snapshot for this word.
87    pub prosody: Prosody,
88    /// Effective `<say-as>` mode for this word.
89    pub say_as: SayAs,
90    /// Nearest enclosing `<voice name="...">`, if any.
91    pub voice: Option<String>,
92}
93
94impl WordSpan {
95    /// Length in characters of the raw source span.
96    #[must_use]
97    pub fn char_len(&self) -> usize {
98        self.char_span.end - self.char_span.start
99    }
100
101    /// Length in bytes of the raw source span.
102    #[must_use]
103    pub fn byte_len(&self) -> usize {
104        self.byte_span.end - self.byte_span.start
105    }
106}
107
108/// A flat, ordered representation of the input after SSML resolution.
109#[derive(Debug, Clone, PartialEq, Default)]
110pub struct SsmlDocument {
111    /// Ordered segments (words, breaks, marks, structural markers).
112    pub segments: Vec<Segment>,
113    /// Non-fatal issues encountered while parsing (unknown tags, malformed
114    /// values, ...). Input is always parsed leniently.
115    pub warnings: Vec<String>,
116    /// `xml:lang` from the `<speak>` envelope, when present. Documented
117    /// intent for the utterance's language; consumers use it for G2P/lexicon
118    /// routing. Not validated beyond UTF-8.
119    pub lang: Option<String>,
120}
121
122impl SsmlDocument {
123    /// All word spans in document order.
124    #[must_use]
125    pub fn words(&self) -> Vec<&WordSpan> {
126        self.segments
127            .iter()
128            .filter_map(|s| match s {
129                Segment::Words { words } => Some(words.iter()),
130                _ => None,
131            })
132            .flatten()
133            .collect()
134    }
135
136    /// The text as it will be spoken (sub aliases applied, tags stripped).
137    #[must_use]
138    pub fn spoken_text(&self) -> String {
139        let mut parts: Vec<String> = Vec::new();
140        for seg in &self.segments {
141            if let Segment::Words { words } = seg {
142                parts.extend(words.iter().map(|w| w.spoken.clone()));
143            }
144        }
145        parts.join(" ")
146    }
147}
148
149/// One element of the flattened document.
150#[derive(Debug, Clone, PartialEq)]
151pub enum Segment {
152    /// A run of words sharing one prosody context.
153    Words {
154        /// Word tokens with source spans.
155        words: Vec<WordSpan>,
156    },
157    /// An explicit `<break time="500ms"/>`.
158    Break {
159        /// Pause length in milliseconds.
160        ms: u32,
161        /// Character position of the tag's `<` in the raw input.
162        char_pos: usize,
163        /// Byte position of the tag's `<` in the raw input.
164        byte_pos: usize,
165    },
166    /// An SSML `<mark name="..."/>`; the engine emits an index-mark event at
167    /// the exact sample where speech reaches this point.
168    Mark {
169        /// Mark name as given by the client.
170        name: String,
171        /// Character position of the tag's `<` in the raw input.
172        char_pos: usize,
173        /// Byte position of the tag's `<` in the raw input.
174        byte_pos: usize,
175    },
176    /// A sentence boundary (`</s>`), usable for streaming segmentation.
177    SentenceEnd {
178        /// Character position of the tag in the raw input.
179        char_pos: usize,
180        /// Byte position of the tag in the raw input.
181        byte_pos: usize,
182    },
183    /// A paragraph boundary (`</p>`).
184    ParagraphEnd {
185        /// Character position of the tag in the raw input.
186        char_pos: usize,
187        /// Byte position of the tag in the raw input.
188        byte_pos: usize,
189    },
190}
191
192/// Error type: parsing is lenient, so failures are near-impossible by design.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ParseError(pub String);
195
196impl fmt::Display for ParseError {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        write!(f, "SSML parse error: {}", self.0)
199    }
200}
201
202impl std::error::Error for ParseError {}
203
204/// Parse SSML or plain text into an [`SsmlDocument`].
205///
206/// Plain text (nothing that looks like markup) is treated as a single text
207/// run. Input that looks like XML but fails to parse falls back to plain-text
208/// treatment with a warning: a TTS frontend should always speak *something*.
209#[allow(clippy::missing_errors_doc)]
210pub fn parse(input: &str) -> Result<SsmlDocument, ParseError> {
211    if input.contains('<') {
212        match parse_xml(input) {
213            ParseOutcome::Ok(doc) => return Ok(doc),
214            ParseOutcome::Fallback(mut bad) => {
215                let mut plain = SsmlDocument::default();
216                push_plain_words(&mut plain, input.as_bytes(), 0, &OffsetMap::identity(input));
217                bad.warnings.insert(
218                    0,
219                    "input looked like XML but did not parse cleanly; treated as plain text".into(),
220                );
221                plain.warnings = bad.warnings;
222                return Ok(plain);
223            }
224        }
225    }
226    let mut doc = SsmlDocument::default();
227    push_plain_words(&mut doc, input.as_bytes(), 0, &OffsetMap::identity(input));
228    Ok(doc)
229}
230
231enum ParseOutcome {
232    Ok(SsmlDocument),
233    Fallback(SsmlDocument),
234}
235
236/// Byte-offset → char-offset conversion table for one input.
237struct OffsetMap {
238    /// `bytes_to_chars[i]` = char index of byte `i` (`input.len()` + 1 entries).
239    bytes_to_chars: Vec<usize>,
240}
241
242impl OffsetMap {
243    fn identity(input: &str) -> Self {
244        Self::build(input)
245    }
246
247    fn build(input: &str) -> Self {
248        let bytes = input.as_bytes();
249        let mut map = Vec::with_capacity(bytes.len() + 1);
250        map.push(0usize);
251        let mut starts = 0usize;
252        for (b, byte) in bytes.iter().enumerate() {
253            // A UTF-8 char start is byte 0 or any non-continuation byte.
254            if b == 0 || (byte & 0xC0) != 0x80 {
255                starts += 1;
256            }
257            map.push(starts);
258        }
259        Self {
260            bytes_to_chars: map,
261        }
262    }
263
264    fn char_at(&self, byte: usize) -> usize {
265        self.bytes_to_chars
266            .get(byte)
267            .copied()
268            .unwrap_or_else(|| self.bytes_to_chars.last().copied().unwrap_or(0))
269    }
270}
271
272struct Override {
273    is_phoneme: bool,
274    /// Byte offset just after the start tag (`>`).
275    inner_start: usize,
276    /// Byte offset just before the matching end tag (`<`), set as text arrives.
277    inner_end: usize,
278    phonemes: Option<Vec<String>>,
279    alias: Option<String>,
280}
281
282struct ParserState {
283    prosody_stack: Vec<Prosody>,
284    say_as_stack: Vec<SayAs>,
285    voice_stack: Vec<String>,
286    override_active: Option<Override>,
287    pending: Vec<WordSpan>,
288    warnings: Vec<String>,
289    fatal: bool,
290    /// Byte offset just past the last text run seen (for slice recovery).
291    last_text_end: usize,
292    /// Accumulates words across Text/GeneralRef event splits.
293    assembler: WordAssembler,
294}
295
296impl ParserState {
297    fn new() -> Self {
298        Self {
299            prosody_stack: vec![Prosody::default()],
300            say_as_stack: vec![SayAs::None],
301            voice_stack: Vec::new(),
302            override_active: None,
303            pending: Vec::new(),
304            warnings: Vec::new(),
305            fatal: false,
306            last_text_end: 0,
307            assembler: WordAssembler::default(),
308        }
309    }
310
311    fn prosody(&self) -> Prosody {
312        *self.prosody_stack.last().unwrap_or(&Prosody::default())
313    }
314
315    fn say_as(&self) -> SayAs {
316        self.say_as_stack.last().copied().unwrap_or_default()
317    }
318
319    fn voice(&self) -> Option<String> {
320        self.voice_stack.last().cloned()
321    }
322}
323
324fn parse_xml(input: &str) -> ParseOutcome {
325    use quick_xml::events::Event;
326
327    let mut doc = SsmlDocument::default();
328    let mut st = ParserState::new();
329    let offsets = OffsetMap::build(input);
330    let mut reader = quick_xml::Reader::from_str(input);
331    reader.config_mut().expand_empty_elements = false;
332    reader.config_mut().trim_text(false);
333
334    loop {
335        // Position at the END of the previous event == start of the next.
336        let event_start = usize::try_from(reader.buffer_position()).unwrap_or(usize::MAX);
337        let event = match reader.read_event() {
338            Ok(ev) => ev,
339            Err(e) => {
340                st.warnings
341                    .push(format!("XML error near byte {event_start}: {e}"));
342                st.fatal = true;
343                break;
344            }
345        };
346        let event_end = usize::try_from(reader.buffer_position()).unwrap_or(usize::MAX);
347
348        match event {
349            Event::Start(tag) => {
350                let name = local_name(&tag);
351                handle_open(
352                    &mut st,
353                    &mut doc,
354                    &tag,
355                    &name,
356                    event_start,
357                    event_end,
358                    false,
359                    &offsets,
360                );
361            }
362            Event::Empty(tag) => {
363                let name = local_name(&tag);
364                handle_open(
365                    &mut st,
366                    &mut doc,
367                    &tag,
368                    &name,
369                    event_start,
370                    event_start,
371                    true,
372                    &offsets,
373                );
374            }
375            Event::End(tag) => {
376                let name = String::from_utf8_lossy(tag.name().as_ref()).into_owned();
377                handle_close(&mut st, &mut doc, &name, event_start, input, &offsets);
378            }
379            Event::Text(t) => {
380                // Raw (still escaped) source bytes are exactly this slice.
381                let raw = &input[event_start..event_end.min(input.len())];
382                debug_assert_eq!(raw.as_bytes(), t.as_ref(), "quick-xml text slice mismatch");
383                st.on_text(raw.as_bytes(), input, &offsets);
384            }
385            Event::GeneralRef(g) => {
386                // quick-xml splits text runs at entities; the event carries
387                // the entity body without `&`/`;`, while the raw source
388                // slice below includes them (which is what span tracking
389                // needs).
390                let raw = &input[event_start..event_end.min(input.len())];
391                debug_assert!(
392                    raw.len() >= 2 && raw.as_bytes()[1..raw.len() - 1] == *g.as_ref(),
393                    "quick-xml genref slice mismatch"
394                );
395                st.on_text(raw.as_bytes(), input, &offsets);
396            }
397            Event::CData(t) => {
398                // The event spans `<![CDATA[` + content + `]]>`; recover the
399                // inner raw slice from the source.
400                let start = event_start + b"<![CDATA[".len();
401                let end = event_end.saturating_sub(b"]]>".len()).max(start);
402                let raw = &input[start..end.min(input.len())];
403                let _ = t;
404                st.on_text(raw.as_bytes(), input, &offsets);
405            }
406            Event::Comment(_) | Event::Decl(_) | Event::PI(_) | Event::DocType(_) => {}
407            Event::Eof => break,
408        }
409    }
410
411    st.flush(&mut doc, &offsets);
412    doc.warnings = st.warnings;
413    if st.fatal {
414        ParseOutcome::Fallback(doc)
415    } else {
416        ParseOutcome::Ok(doc)
417    }
418}
419
420impl ParserState {
421    fn on_text(&mut self, raw: &[u8], input: &str, _offsets: &OffsetMap) {
422        let run_start = locate_run(input, self.last_text_end, raw);
423        self.last_text_end = run_start + raw.len();
424        if let Some(ov) = self.override_active.as_mut() {
425            ov.inner_end = self.last_text_end;
426            return;
427        }
428        self.assembler.push_run(raw, run_start);
429    }
430
431    /// Flush accumulated text into pending word spans. Called at tag
432    /// boundaries so words split across Text/GeneralRef events merge.
433    fn drain_text(&mut self, offsets: &OffsetMap) {
434        let words = self.assembler.finish();
435        for w in words {
436            self.pending.push(WordSpan {
437                char_span: offsets.char_at(w.byte_span.start)..offsets.char_at(w.byte_span.end),
438                byte_span: w.byte_span,
439                text: w.text.clone(),
440                spoken: w.text,
441                phonemes: None,
442                prosody: self.prosody(),
443                say_as: self.say_as(),
444                voice: self.voice(),
445            });
446        }
447    }
448
449    fn flush(&mut self, doc: &mut SsmlDocument, offsets: &OffsetMap) {
450        self.drain_text(offsets);
451        if !self.pending.is_empty() {
452            doc.segments.push(Segment::Words {
453                words: std::mem::take(&mut self.pending),
454            });
455        }
456    }
457}
458
459/// Find where `raw` (a raw text-run byte slice) occurs in `input`, searching
460/// from `from`. Text runs are located by forward scan because quick-xml does
461/// not hand out source slices.
462fn locate_run(input: &str, from: usize, raw: &[u8]) -> usize {
463    if raw.is_empty() {
464        return from;
465    }
466    let hay = input.as_bytes();
467    let start = from.min(hay.len());
468    if hay.len() >= raw.len() && hay[start..].starts_with(raw) {
469        return start;
470    }
471    // Fallback: full scan (rare; only if `from` drifted).
472    memfind(hay, raw).unwrap_or(start)
473}
474
475fn memfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
476    if needle.is_empty() || hay.len() < needle.len() {
477        return None;
478    }
479    hay.windows(needle.len()).position(|w| w == needle)
480}
481
482/// Handle a start (or empty) tag.
483#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
484fn handle_open(
485    st: &mut ParserState,
486    doc: &mut SsmlDocument,
487    tag: &quick_xml::events::BytesStart<'_>,
488    name: &str,
489    tag_start: usize,
490    tag_end: usize,
491    is_empty_form: bool,
492    offsets: &OffsetMap,
493) {
494    // Scoped tags close the current word run so prosody changes are visible.
495    match name {
496        "break" | "mark" | "prosody" | "emphasis" | "say-as" | "voice" | "phoneme" | "sub" => {
497            st.flush(doc, offsets);
498        }
499        _ => {}
500    }
501    match name {
502        "speak" => {
503            // Envelope language: recorded for G2P/lexicon routing.
504            if let Some(lang) = attr(tag, "xml:lang") {
505                doc.lang = Some(lang);
506            }
507        }
508        "break" => {
509            let ms = attr(tag, "time")
510                .and_then(|t| parse_time(&t))
511                .or_else(|| attr(tag, "strength").and_then(|s| parse_break_strength(&s)))
512                .unwrap_or_else(|| {
513                    st.warnings
514                        .push("break without usable time/strength; using 0 ms".into());
515                    0
516                });
517            doc.segments.push(Segment::Break {
518                ms,
519                char_pos: offsets.char_at(tag_start),
520                byte_pos: tag_start,
521            });
522        }
523        "mark" => {
524            let mark_name = attr(tag, "name").unwrap_or_default();
525            doc.segments.push(Segment::Mark {
526                name: mark_name,
527                char_pos: offsets.char_at(tag_start),
528                byte_pos: tag_start,
529            });
530        }
531        "prosody" => {
532            let mut p = Prosody::default();
533            if let Some(v) = attr(tag, "rate") {
534                match parse_rate(&v) {
535                    Some(r) => p.rate = Some(r),
536                    None => st.warnings.push(format!("ignored prosody rate {v:?}")),
537                }
538            }
539            if let Some(v) = attr(tag, "pitch") {
540                match parse_pitch(&v) {
541                    Some(r) => p.pitch = Some(r),
542                    None => st.warnings.push(format!("ignored prosody pitch {v:?}")),
543                }
544            }
545            if let Some(v) = attr(tag, "volume") {
546                match parse_volume(&v) {
547                    Some(r) => p.volume = Some(r),
548                    None => st.warnings.push(format!("ignored prosody volume {v:?}")),
549                }
550            }
551            st.prosody_stack.push(st.prosody().overlay(p));
552        }
553        "emphasis" => {
554            let level = attr(tag, "level").unwrap_or_else(|| "moderate".into());
555            let p = match level.as_str() {
556                "strong" => Prosody {
557                    rate: Some(0.97),
558                    pitch: Some(1.15),
559                    volume: Some(1.2),
560                },
561                "moderate" => Prosody {
562                    rate: Some(0.99),
563                    pitch: Some(1.06),
564                    volume: Some(1.1),
565                },
566                "reduced" => Prosody {
567                    rate: Some(0.92),
568                    pitch: Some(0.9),
569                    volume: Some(0.8),
570                },
571                other => {
572                    st.warnings
573                        .push(format!("unknown emphasis level {other:?}; using default"));
574                    Prosody::default()
575                }
576            };
577            st.prosody_stack.push(st.prosody().overlay(p));
578        }
579        "say-as" => {
580            let mode = match attr(tag, "interpret-as").as_deref() {
581                Some("characters" | "spell-out") => SayAs::Characters,
582                Some("cardinal" | "number") => SayAs::Cardinal,
583                Some("ordinal") => SayAs::Ordinal,
584                Some(_) | None => SayAs::Other,
585            };
586            st.say_as_stack.push(mode);
587        }
588        "voice" => {
589            st.voice_stack.push(attr(tag, "name").unwrap_or_default());
590        }
591        "phoneme" => {
592            let ph = attr(tag, "ph")
593                .map(|s| s.split_whitespace().map(str::to_owned).collect::<Vec<_>>());
594            match ph {
595                Some(phonemes) => {
596                    st.override_active = Some(Override {
597                        is_phoneme: true,
598                        inner_start: tag_end,
599                        inner_end: tag_end,
600                        phonemes: Some(phonemes),
601                        alias: None,
602                    });
603                }
604                None => st
605                    .warnings
606                    .push("phoneme tag without ph attribute; override ignored".into()),
607            }
608        }
609        "sub" => match attr(tag, "alias") {
610            Some(alias) => {
611                st.override_active = Some(Override {
612                    is_phoneme: false,
613                    inner_start: tag_end,
614                    inner_end: tag_end,
615                    phonemes: None,
616                    alias: Some(alias),
617                });
618            }
619            None => st
620                .warnings
621                .push("sub tag without alias attribute; override ignored".into()),
622        },
623        "audio" => {
624            st.warnings
625                .push("audio tag ignored (pre-recorded audio not supported)".into());
626        }
627        _ => {}
628    }
629    // Self-closing variants of scoped tags need an immediate pop.
630    if is_empty_form {
631        match name {
632            "prosody" | "emphasis" => {
633                st.prosody_stack.pop();
634            }
635            "say-as" => {
636                st.say_as_stack.pop();
637            }
638            "voice" => {
639                st.voice_stack.pop();
640            }
641            _ => {}
642        }
643    }
644}
645
646/// Handle an end tag.
647fn handle_close(
648    st: &mut ParserState,
649    doc: &mut SsmlDocument,
650    name: &str,
651    tag_start: usize,
652    input: &str,
653    offsets: &OffsetMap,
654) {
655    match name {
656        "prosody" | "emphasis" => {
657            st.flush(doc, offsets);
658            st.prosody_stack.pop();
659        }
660        "say-as" => {
661            st.flush(doc, offsets);
662            st.say_as_stack.pop();
663        }
664        "voice" => {
665            st.flush(doc, offsets);
666            st.voice_stack.pop();
667        }
668        "phoneme" | "sub" => {
669            if let Some(ov) = st.override_active.take() {
670                let inner = &input[ov.inner_start..ov.inner_end.min(input.len())];
671                let decoded: Vec<String> = decode_words(inner.as_bytes(), ov.inner_start)
672                    .into_iter()
673                    .map(|w| w.text)
674                    .collect();
675                if decoded.is_empty() {
676                    st.warnings
677                        .push(format!("{name} element with no inner text; ignored"));
678                } else {
679                    let byte_span = ov.inner_start..ov.inner_end.min(input.len());
680                    let joined = decoded.join(" ");
681                    let spoken = if ov.is_phoneme {
682                        joined.clone()
683                    } else {
684                        ov.alias.clone().unwrap_or(joined.clone())
685                    };
686                    st.pending.push(WordSpan {
687                        text: joined,
688                        spoken,
689                        char_span: offsets.char_at(byte_span.start)..offsets.char_at(byte_span.end),
690                        byte_span,
691                        phonemes: ov.phonemes,
692                        prosody: st.prosody(),
693                        say_as: st.say_as(),
694                        voice: st.voice(),
695                    });
696                }
697            }
698        }
699        "s" => {
700            st.flush(doc, offsets);
701            doc.segments.push(Segment::SentenceEnd {
702                char_pos: offsets.char_at(tag_start),
703                byte_pos: tag_start,
704            });
705        }
706        "p" => {
707            st.flush(doc, offsets);
708            doc.segments.push(Segment::ParagraphEnd {
709                char_pos: offsets.char_at(tag_start),
710                byte_pos: tag_start,
711            });
712        }
713        _ => {}
714    }
715}
716
717fn local_name(tag: &quick_xml::events::BytesStart<'_>) -> String {
718    String::from_utf8_lossy(tag.name().as_ref()).into_owned()
719}
720
721fn attr(tag: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
722    tag.attributes()
723        .flatten()
724        .find(|a| a.key.as_ref() == key.as_bytes())
725        .and_then(|a| a.unescape_value().ok().map(std::borrow::Cow::into_owned))
726}
727
728/// Push plain (non-XML) text as a single word run.
729fn push_plain_words(doc: &mut SsmlDocument, raw: &[u8], start: usize, offsets: &OffsetMap) {
730    let mut words = Vec::new();
731    for w in decode_words(raw, start) {
732        words.push(WordSpan {
733            char_span: offsets.char_at(w.byte_span.start)..offsets.char_at(w.byte_span.end),
734            byte_span: w.byte_span,
735            text: w.text.clone(),
736            spoken: w.text,
737            phonemes: None,
738            prosody: Prosody::default(),
739            say_as: SayAs::None,
740            voice: None,
741        });
742    }
743    if !words.is_empty() {
744        doc.segments.push(Segment::Words { words });
745    }
746}
747
748/// A decoded word with its raw source byte span (entities included).
749struct DecodedWord {
750    text: String,
751    byte_span: Range<usize>,
752}
753
754/// Assembles words across multiple raw text runs (quick-xml splits text at
755/// entity references), preserving exact byte spans in the original input.
756#[derive(Default)]
757struct WordAssembler {
758    words: Vec<DecodedWord>,
759    text: String,
760    start: Option<usize>,
761    end: usize,
762}
763
764impl WordAssembler {
765    fn push_run(&mut self, raw: &[u8], run_start: usize) {
766        let mut i = 0;
767        while i < raw.len() {
768            let b = raw[i];
769            if b == b'&' {
770                if let Some(semi) = raw[i + 1..].iter().position(|&c| c == b';') {
771                    let ent = &raw[i..i + semi + 2];
772                    let decoded = decode_entity(ent);
773                    for ch in decoded.chars() {
774                        if ch.is_whitespace() {
775                            self.flush_word(run_start + i);
776                        } else {
777                            if self.start.is_none() {
778                                self.start = Some(run_start + i);
779                            }
780                            self.text.push(ch);
781                        }
782                    }
783                    self.end = run_start + i + semi + 2;
784                    i += semi + 2;
785                    continue;
786                }
787            }
788            let len = utf8_len(b);
789            let chunk = &raw[i..(i + len).min(raw.len())];
790            let s = String::from_utf8_lossy(chunk).into_owned();
791            for ch in s.chars() {
792                if ch.is_whitespace() {
793                    self.flush_word(run_start + i);
794                } else {
795                    if self.start.is_none() {
796                        self.start = Some(run_start + i);
797                    }
798                    self.text.push(ch);
799                }
800            }
801            self.end = run_start + i + len;
802            i += len;
803        }
804    }
805
806    fn flush_word(&mut self, pos: usize) {
807        if self.text.is_empty() {
808            self.start = None;
809        } else {
810            let start = self.start.take().unwrap_or(pos);
811            self.words.push(DecodedWord {
812                text: std::mem::take(&mut self.text),
813                byte_span: start..self.end.max(pos),
814            });
815        }
816    }
817
818    /// Return assembled words and reset for the next text sequence.
819    fn finish(&mut self) -> Vec<DecodedWord> {
820        if !self.text.is_empty() {
821            let start = self.start.take().unwrap_or(self.end);
822            let end = self.end;
823            self.words.push(DecodedWord {
824                text: std::mem::take(&mut self.text),
825                byte_span: start..end,
826            });
827        }
828        std::mem::take(&mut self.words)
829    }
830}
831
832/// Decode one raw text run into words (single-run convenience).
833fn decode_words(raw: &[u8], run_start: usize) -> Vec<DecodedWord> {
834    let mut a = WordAssembler::default();
835    a.push_run(raw, run_start);
836    a.finish()
837}
838
839fn utf8_len(b: u8) -> usize {
840    match b {
841        0x00..=0x7F => 1,
842        0xC0..=0xDF => 2,
843        0xE0..=0xEF => 3,
844        _ => 4,
845    }
846}
847
848/// Decode a single `&...;` entity to its replacement text.
849fn decode_entity(ent: &[u8]) -> String {
850    if ent.len() < 3 {
851        return String::from_utf8_lossy(ent).into_owned();
852    }
853    let inner = &ent[1..ent.len() - 1];
854    match inner {
855        b"amp" => "&".into(),
856        b"lt" => "<".into(),
857        b"gt" => ">".into(),
858        b"quot" => "\"".into(),
859        b"apos" => "'".into(),
860        b"nbsp" => "\u{a0}".into(),
861        _ => {
862            if let Some(hex) = inner
863                .strip_prefix(b"#x")
864                .or_else(|| inner.strip_prefix(b"#X"))
865            {
866                u32::from_str_radix(&String::from_utf8_lossy(hex), 16)
867                    .ok()
868                    .and_then(char::from_u32)
869                    .map(String::from)
870                    .unwrap_or_default()
871            } else if let Some(dec) = inner.strip_prefix(b"#") {
872                String::from_utf8_lossy(dec)
873                    .parse::<u32>()
874                    .ok()
875                    .and_then(char::from_u32)
876                    .map(String::from)
877                    .unwrap_or_default()
878            } else {
879                String::new()
880            }
881        }
882    }
883}
884
885/// Parse an SSML time value (`"500ms"`, `"2s"`, `"1.5s"`, bare number = ms).
886#[must_use]
887#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
888pub fn parse_time(t: &str) -> Option<u32> {
889    let t = t.trim();
890    if let Some(v) = t.strip_suffix("ms") {
891        v.trim().parse::<f64>().ok().map(|f| f.max(0.0) as u32)
892    } else if let Some(v) = t.strip_suffix('s') {
893        v.trim()
894            .parse::<f64>()
895            .ok()
896            .map(|f| (f * 1000.0).max(0.0) as u32)
897    } else {
898        t.parse::<f64>().ok().map(|f| f.max(0.0) as u32)
899    }
900}
901
902/// W3C break strengths mapped to default millisecond pauses.
903#[must_use]
904pub fn parse_break_strength(s: &str) -> Option<u32> {
905    match s {
906        "none" => Some(0),
907        "x-weak" => Some(50),
908        "weak" => Some(100),
909        "medium" => Some(250),
910        "strong" => Some(500),
911        "x-strong" => Some(800),
912        _ => None,
913    }
914}
915
916/// Parse a rate value: ratio (`"0.8"`), percentage (`"80%"`), relative
917/// (`"+20%"` / `"-20%"`), or W3C names.
918#[must_use]
919pub fn parse_rate(r: &str) -> Option<f32> {
920    let r = r.trim();
921    let named = match r {
922        "x-slow" => Some(0.5),
923        "slow" => Some(0.75),
924        "medium" | "default" => Some(1.0),
925        "fast" => Some(1.25),
926        "x-fast" => Some(1.5),
927        _ => None,
928    };
929    named.or_else(|| parse_relative_percent(r)).or_else(|| {
930        let v = r.parse::<f32>().ok()?;
931        (0.1..=10.0).contains(&v).then_some(v)
932    })
933}
934
935/// Parse a pitch value: percentage, semitones (`"+3st"`), or W3C names.
936#[must_use]
937pub fn parse_pitch(p: &str) -> Option<f32> {
938    let p = p.trim();
939    let named = match p {
940        "x-low" => Some(0.7),
941        "low" => Some(0.85),
942        "medium" | "default" => Some(1.0),
943        "high" => Some(1.15),
944        "x-high" => Some(1.3),
945        _ => None,
946    };
947    if let Some(v) = named {
948        return Some(v);
949    }
950    if let Some(st) = p.strip_suffix("st") {
951        return st
952            .trim()
953            .parse::<f32>()
954            .ok()
955            .map(|n| 2.0_f32.powf(n / 12.0));
956    }
957    parse_relative_percent(p)
958}
959
960/// Parse a volume value: percentage, relative, W3C names, or 0–100 number.
961#[must_use]
962pub fn parse_volume(v: &str) -> Option<f32> {
963    let v = v.trim();
964    let named = match v {
965        "silent" => Some(0.0),
966        "x-soft" => Some(0.3),
967        "soft" => Some(0.6),
968        "medium" | "default" => Some(1.0),
969        "loud" => Some(1.3),
970        "x-loud" => Some(1.6),
971        _ => None,
972    };
973    if let Some(x) = named {
974        return Some(x);
975    }
976    if v.ends_with('%') {
977        let n = v.strip_suffix('%')?.trim().parse::<f32>().ok()?;
978        return Some((n / 100.0).clamp(0.0, 2.0));
979    }
980    let n = v.parse::<f32>().ok()?;
981    Some((n / 100.0).clamp(0.0, 2.0))
982}
983
984/// `"+20%"` / `"-20%"` / `"80%"` → multiplier against 1.0.
985fn parse_relative_percent(s: &str) -> Option<f32> {
986    let s = s.trim();
987    let pct = s.strip_suffix('%')?.trim();
988    if let Some(rel) = pct.strip_prefix('+') {
989        let r: f32 = rel.trim().parse().ok()?;
990        return Some((1.0 + r / 100.0).max(0.05));
991    }
992    if let Some(rel) = pct.strip_prefix('-') {
993        let r: f32 = rel.trim().parse().ok()?;
994        return Some((1.0 - r / 100.0).max(0.05));
995    }
996    let n: f32 = pct.parse().ok()?;
997    Some((n / 100.0).max(0.05))
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    #[test]
1005    fn speak_envelope_lang_is_recorded() {
1006        let doc = parse(r#"<speak xml:lang="de-DE">Guten Tag</speak>"#).unwrap();
1007        assert_eq!(doc.lang.as_deref(), Some("de-DE"));
1008        assert!(parse("<speak>hi</speak>").unwrap().lang.is_none());
1009        assert!(parse("plain").unwrap().lang.is_none());
1010    }
1011
1012    #[test]
1013    fn plain_text_words() {
1014        let doc = parse("Hello world").unwrap();
1015        let words = doc.words();
1016        assert_eq!(words.len(), 2);
1017        assert_eq!(words[0].text, "Hello");
1018        assert_eq!(words[0].char_span, 0..5);
1019        assert_eq!(words[1].char_span, 6..11);
1020        assert_eq!(words[1].byte_span, 6..11);
1021    }
1022
1023    #[test]
1024    fn speak_root_strips() {
1025        let doc = parse("<speak>Hello world</speak>").unwrap();
1026        assert_eq!(doc.words().len(), 2);
1027        assert_eq!(doc.spoken_text(), "Hello world");
1028    }
1029
1030    #[test]
1031    fn break_time_variants() {
1032        let doc = parse(
1033            "<speak>a<break time=\"500ms\"/>b<break time=\"2s\"/>c<break time=\"1.5s\"/>d</speak>",
1034        )
1035        .unwrap();
1036        let breaks: Vec<u32> = doc
1037            .segments
1038            .iter()
1039            .filter_map(|s| match s {
1040                Segment::Break { ms, .. } => Some(*ms),
1041                _ => None,
1042            })
1043            .collect();
1044        assert_eq!(breaks, vec![500, 2000, 1500]);
1045    }
1046
1047    #[test]
1048    fn break_strength() {
1049        let doc = parse("<speak>a<break strength=\"strong\"/>b</speak>").unwrap();
1050        assert!(doc
1051            .segments
1052            .iter()
1053            .any(|s| matches!(s, Segment::Break { ms: 500, .. })));
1054    }
1055
1056    #[test]
1057    fn mark_name_and_position() {
1058        let ssml = "<speak>Hi <mark name=\"m1\"/>there</speak>";
1059        let doc = parse(ssml).unwrap();
1060        match &doc.segments[1] {
1061            Segment::Mark {
1062                name,
1063                char_pos,
1064                byte_pos,
1065            } => {
1066                assert_eq!(name, "m1");
1067                // "<speak>" (7) + "Hi " (3) → the mark tag's '<' sits at byte 10.
1068                assert_eq!(*byte_pos, 10);
1069                assert_eq!(*char_pos, 10);
1070                assert_eq!(&ssml[(*byte_pos)..=(*byte_pos)], "<");
1071            }
1072            other => panic!("expected mark, got {other:?}"),
1073        }
1074        // words after the mark still parse
1075        assert_eq!(doc.words()[1].text, "there");
1076    }
1077
1078    #[test]
1079    fn prosody_resolution() {
1080        let doc = parse(
1081            "<speak><prosody rate=\"80%\">slow <prosody pitch=\"+5%\">both</prosody></prosody>normal</speak>",
1082        )
1083        .unwrap();
1084        let words = doc.words();
1085        assert_eq!(words[0].prosody.rate, Some(0.8));
1086        assert_eq!(words[0].prosody.pitch, None);
1087        assert_eq!(words[1].prosody.rate, Some(0.8));
1088        assert_eq!(words[1].prosody.pitch, Some(1.05));
1089        assert!(words[2].prosody.is_default());
1090    }
1091
1092    #[test]
1093    fn phoneme_override() {
1094        let doc =
1095            parse("<speak>Say <phoneme ph=\"f ə n ɛ t ɪ k s\">phonetics</phoneme> now</speak>")
1096                .unwrap();
1097        let words = doc.words();
1098        assert_eq!(words.len(), 3);
1099        assert_eq!(words[1].text, "phonetics");
1100        let ph = words[1].phonemes.as_deref().expect("override present");
1101        assert_eq!(ph, &["f", "ə", "n", "ɛ", "t", "ɪ", "k", "s"]);
1102    }
1103
1104    #[test]
1105    fn sub_alias() {
1106        let ssml = "<speak>Visit <sub alias=\"World Wide Web\">WWW</sub> today</speak>";
1107        let doc = parse(ssml).unwrap();
1108        let words = doc.words();
1109        assert_eq!(words.len(), 3);
1110        assert_eq!(words[0].text, "Visit");
1111        assert_eq!(words[1].text, "WWW");
1112        assert_eq!(words[1].spoken, "World Wide Web");
1113        // Byte span covers the ORIGINAL inner text ("WWW"), not the alias.
1114        let start = ssml.find("WWW").expect("WWW in source");
1115        assert_eq!(words[1].byte_span, start..start + 3);
1116    }
1117
1118    #[test]
1119    fn entities_preserve_spans() {
1120        // "AT&T" written with &amp; — 12 raw bytes for the word
1121        let ssml = "<speak>AT&amp;T rules</speak>";
1122        let doc = parse(ssml).unwrap();
1123        let words = doc.words();
1124        assert_eq!(words[0].text, "AT&T");
1125        assert_eq!(words[0].byte_len(), "AT&amp;T".len());
1126        assert_eq!(words[0].char_len(), "AT&amp;T".len()); // all-ASCII source
1127        let span = words[0].byte_span.clone();
1128        assert_eq!(&ssml[span], "AT&amp;T");
1129        assert_eq!(words[1].text, "rules");
1130    }
1131
1132    #[test]
1133    fn numeric_entity() {
1134        let doc = parse("<speak>&#65;&#66; cd</speak>").unwrap();
1135        assert_eq!(doc.words()[0].text, "AB");
1136    }
1137
1138    #[test]
1139    fn unicode_char_vs_byte() {
1140        // "<speak>" is 7 ASCII bytes; h=byte7/char7, é=bytes8-9/char8 …
1141        let ssml = "<speak>héllo wörld</speak>";
1142        let doc = parse(ssml).unwrap();
1143        let w = &doc.words()[0];
1144        assert_eq!(w.text, "héllo");
1145        assert_eq!(w.byte_span, 7..13); // h(1) é(2) l l o = 6 bytes
1146        assert_eq!(w.char_span, 7..12); // 5 characters
1147        let w2 = &doc.words()[1];
1148        assert_eq!(w2.text, "wörld");
1149        assert_eq!(w2.byte_span, 14..20); // w ö(2) r l d = 6 bytes
1150        assert_eq!(w2.char_span, 13..18);
1151    }
1152
1153    #[test]
1154    fn say_as_characters() {
1155        let doc =
1156            parse("<speak>code <say-as interpret-as=\"characters\">ABC</say-as></speak>").unwrap();
1157        assert_eq!(doc.words()[1].say_as, SayAs::Characters);
1158    }
1159
1160    #[test]
1161    fn malformed_falls_back_to_plain() {
1162        let doc = parse("<speak><unclosed malformed").unwrap();
1163        assert!(!doc.warnings.is_empty());
1164        assert!(doc.spoken_text().contains("unclosed"));
1165    }
1166
1167    #[test]
1168    fn unknown_tags_transparent() {
1169        let doc = parse("<speak>Hello <marvelous>brave</marvelous> world</speak>").unwrap();
1170        assert_eq!(doc.spoken_text(), "Hello brave world");
1171        assert!(doc.warnings.is_empty());
1172    }
1173
1174    #[test]
1175    fn sentence_and_paragraph_markers() {
1176        let doc = parse("<speak><p>One<s>two</s></p></speak>").unwrap();
1177        assert!(doc
1178            .segments
1179            .iter()
1180            .any(|s| matches!(s, Segment::SentenceEnd { .. })));
1181        assert!(doc
1182            .segments
1183            .iter()
1184            .any(|s| matches!(s, Segment::ParagraphEnd { .. })));
1185    }
1186
1187    #[test]
1188    fn empty_element_prosody_no_hang() {
1189        let doc = parse("<speak><prosody rate=\"slow\"/>word</speak>").unwrap();
1190        assert_eq!(doc.words().len(), 1);
1191        assert!(doc.words()[0].prosody.is_default());
1192    }
1193
1194    #[test]
1195    fn cdata_text_spoken() {
1196        let doc = parse("<speak><![CDATA[Hello there]]></speak>").unwrap();
1197        assert_eq!(doc.spoken_text(), "Hello there");
1198    }
1199
1200    #[test]
1201    fn time_value_parsing() {
1202        assert_eq!(parse_time("500ms"), Some(500));
1203        assert_eq!(parse_time("2s"), Some(2000));
1204        assert_eq!(parse_time("1.5s"), Some(1500));
1205        assert_eq!(parse_time("300"), Some(300));
1206        assert_eq!(parse_time("junk"), None);
1207    }
1208
1209    #[test]
1210    fn rate_parsing_variants() {
1211        assert_eq!(parse_rate("0.8"), Some(0.8));
1212        assert_eq!(parse_rate("80%"), Some(0.8));
1213        assert_eq!(parse_rate("+20%"), Some(1.2));
1214        assert_eq!(parse_rate("-20%"), Some(0.8));
1215        assert_eq!(parse_rate("slow"), Some(0.75));
1216        assert_eq!(parse_rate("wat"), None);
1217    }
1218
1219    #[test]
1220    fn pitch_semitones() {
1221        let p = parse_pitch("+3st").unwrap();
1222        assert!((p - 2.0_f32.powf(3.0 / 12.0)).abs() < 1e-6);
1223        assert_eq!(parse_pitch("x-high"), Some(1.3));
1224    }
1225}