simple_editor\engine/
subtitles.rs1use crate::model::Cue;
6
7pub 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
24fn 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 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
45pub(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
62fn 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}