simple_editor\engine/
subtitles.rs

1//! Subtitle file formats: SubRip (.srt) and WebVTT (.vtt) — parse (auto-detected) and write.
2//! Basic formatting tags (<i>, <b>, {\an8}, VTT cue settings) are stripped on import; text is kept
3//! as plain lines. Times are seconds.
4
5use crate::model::Cue;
6
7/// Parse SRT or WebVTT (auto-detected by the "WEBVTT" header / timestamp style). Malformed blocks are
8/// skipped. Returns (start, end, text) triples in file order.
9pub fn parse(text: &str) -> Vec<(f64, f64, String)> {
10    let text = text.trim_start_matches('\u{feff}');
11    let mut out = Vec::new();
12    let mut block: Vec<&str> = Vec::new();
13    for line in text.lines().chain(std::iter::once("")) {
14        if line.trim().is_empty() {
15            parse_block(&block, &mut out);
16            block.clear();
17        } else {
18            block.push(line);
19        }
20    }
21    out
22}
23
24/// One blank-line separated block: [optional index / cue-id lines,] timestamp line, text lines.
25/// The timestamp line is the first containing "-->" (this also skips WEBVTT/NOTE/STYLE blocks).
26fn parse_block(block: &[&str], out: &mut Vec<(f64, f64, String)>) {
27    let Some(ts) = block.iter().position(|l| l.contains("-->")) else { return };
28    let Some((a, b)) = block[ts].split_once("-->") else { return };
29    let Some(start) = parse_time(a.trim()) else { return };
30    // VTT cue settings ("align:start position:10%") follow the end time — take the first token
31    let Some(end) = b.trim().split_whitespace().next().and_then(parse_time) else { return };
32    if end < start {
33        return;
34    }
35    let mut text = String::new();
36    for l in &block[ts + 1..] {
37        if !text.is_empty() {
38            text.push('\n');
39        }
40        strip_tags(l, &mut text);
41    }
42    out.push((start, end, text.trim().to_string()));
43}
44
45/// "HH:MM:SS,mmm" / "HH:MM:SS.mmm" / "MM:SS.mmm" → seconds.
46pub(crate) fn parse_time(s: &str) -> Option<f64> {
47    let mut total = 0.0;
48    let parts: Vec<&str> = s.split(':').collect();
49    if !(2..=3).contains(&parts.len()) {
50        return None;
51    }
52    for p in &parts[..parts.len() - 1] {
53        total = total * 60.0 + p.trim().parse::<u32>().ok()? as f64;
54    }
55    let secs: f64 = parts[parts.len() - 1].replace(',', ".").trim().parse().ok()?;
56    if !(0.0..60.0).contains(&secs) {
57        return None;
58    }
59    Some(total * 60.0 + secs)
60}
61
62/// Append `s` to `out` with `<...>` tags and `{\...}` override codes removed.
63fn strip_tags(s: &str, out: &mut String) {
64    let mut chars = s.chars().peekable();
65    while let Some(c) = chars.next() {
66        match c {
67            '<' => {
68                for c in chars.by_ref() {
69                    if c == '>' {
70                        break;
71                    }
72                }
73            }
74            '{' if chars.peek() == Some(&'\\') => {
75                for c in chars.by_ref() {
76                    if c == '}' {
77                        break;
78                    }
79                }
80            }
81            _ => out.push(c),
82        }
83    }
84}
85
86fn fmt_time(t: f64, sep: char) -> String {
87    let ms = (t.max(0.0) * 1000.0).round() as u64;
88    format!("{:02}:{:02}:{:02}{}{:03}", ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, sep, ms % 1000)
89}
90
91pub fn to_srt(cues: &[Cue]) -> String {
92    use std::fmt::Write;
93    let mut s = String::new();
94    for (i, c) in cues.iter().enumerate() {
95        let _ = write!(s, "{}\n{} --> {}\n{}\n\n", i + 1, fmt_time(c.start, ','), fmt_time(c.end, ','), c.text);
96    }
97    s
98}
99
100pub fn to_vtt(cues: &[Cue]) -> String {
101    use std::fmt::Write;
102    let mut s = String::from("WEBVTT\n\n");
103    for c in cues {
104        let _ = write!(s, "{} --> {}\n{}\n\n", fmt_time(c.start, '.'), fmt_time(c.end, '.'), c.text);
105    }
106    s
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn parses_srt() {
115        let srt = "1\r\n00:00:01,000 --> 00:00:02,500\r\nHello <i>world</i>\r\n\r\n2\r\n00:01:00,250 --> 00:01:02,000\r\n{\\an8}Two\r\nlines\r\n\r\n";
116        let cues = parse(srt);
117        assert_eq!(cues.len(), 2);
118        assert_eq!(cues[0], (1.0, 2.5, "Hello world".to_string()));
119        assert_eq!(cues[1], (60.25, 62.0, "Two\nlines".to_string()));
120    }
121
122    #[test]
123    fn parses_vtt() {
124        let vtt = "WEBVTT - some file\n\nNOTE a comment\nspanning lines\n\nintro\n00:01.000 --> 00:04.000 align:start position:10%\n<b>Never</b> gonna\n\n01:00:00.000 --> 01:00:30.000\n<c.yellow>styled</c>\n";
125        let cues = parse(vtt);
126        assert_eq!(cues.len(), 2);
127        assert_eq!(cues[0], (1.0, 4.0, "Never gonna".to_string()));
128        assert_eq!(cues[1], (3600.0, 3630.0, "styled".to_string()));
129    }
130
131    #[test]
132    fn skips_malformed_blocks() {
133        let srt = "1\n00:00:01,000 --> nonsense\nbad\n\n2\nno timestamp at all\n\n3\n00:00:02,000 --> 00:00:01,000\nend before start\n\n4\n00:00:03,000 --> 00:00:04,000\ngood\n";
134        let cues = parse(srt);
135        assert_eq!(cues.len(), 1);
136        assert_eq!(cues[0], (3.0, 4.0, "good".to_string()));
137        assert!(parse("").is_empty());
138        assert!(parse("garbage\nonly").is_empty());
139    }
140
141    #[test]
142    fn writes_and_round_trips() {
143        let cues = vec![
144            Cue { id: 1, start: 0.5, end: 2.0, text: "One".into() },
145            Cue { id: 2, start: 3661.25, end: 3662.0, text: "A\nB".into() },
146        ];
147        let srt = to_srt(&cues);
148        assert!(srt.starts_with("1\n00:00:00,500 --> 00:00:02,000\nOne\n\n2\n01:01:01,250 --> "), "{srt}");
149        let vtt = to_vtt(&cues);
150        assert!(vtt.starts_with("WEBVTT\n\n00:00:00.500 --> 00:00:02.000\nOne\n\n"), "{vtt}");
151        for text in [srt, vtt] {
152            let back = parse(&text);
153            assert_eq!(back.len(), 2);
154            for (got, want) in back.iter().zip(&cues) {
155                assert!((got.0 - want.start).abs() < 1e-9 && (got.1 - want.end).abs() < 1e-9);
156                assert_eq!(got.2, want.text);
157            }
158        }
159    }
160}