diff --git a/crates/captions/src/vtt.rs b/crates/captions/src/vtt.rs index 2487d25e..e7f77067 100644 --- a/crates/captions/src/vtt.rs +++ b/crates/captions/src/vtt.rs @@ -188,7 +188,11 @@ pub fn write(doc: &Document) -> String { out.push('\n'); let mut text = String::new(); if let Some(s) = c.speaker.as_deref().filter(|s| !s.is_empty()) { - text.push_str(&format!("")); + // `>` would end the voice tag early and spill the rest of the name into the cue text, so it + // is written as the lookalike `›` (U+203A), and a line break as a space. A name ending + // in `-` gets a space before the closing `>`, so the two never read as an arrow. + let name = s.replace('>', "\u{203a}").replace(['\r', '\n'], " "); + text.push_str(&format!("", if name.ends_with('-') { " " } else { "" })); } text.push_str(&c.text); for l in text.lines().filter(|l| !l.trim().is_empty()) { @@ -242,6 +246,19 @@ mod tests { assert_eq!(parse(&w).unwrap().cues, d.cues); } + #[test] + fn speaker_with_gt_keeps_cue_text_intact() { + for (name, back_name) in [("A > B", "A \u{203a} B"), ("Tom & Jerry", "Tom & Jerry"), ("A > --", "A \u{203a} --"), ("A\nB", "A B")] { + let mut doc = Document::default(); + doc.cues.push(Cue { start: Tick(0), end: Tick(TICKS_PER_MS), text: "Hi".into(), speaker: Some(name.into()), ..Default::default() }); + let written = write(&doc); + assert!(!written.contains("-->"), "the voice tag keeps its own `>`: {written}"); + let back = parse(&written).unwrap(); + assert_eq!(back.cues[0].speaker.as_deref(), Some(back_name)); + assert_eq!(back.cues[0].text, "Hi"); + } + } + #[test] fn newline_in_cue_id_round_trips() { let mut doc = Document::default();