1use rskit_errors::{AppError, AppResult, ErrorCode};
4use serde::{Deserialize, Serialize};
5
6use crate::time::TimeRange;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SubtitleEntry {
11 pub range: TimeRange,
13 pub text: String,
15 pub style: Option<SubtitleStyle>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SubtitleStyle {
22 pub font_family: Option<String>,
24 pub font_size: Option<u16>,
26 pub color: Option<String>,
28 pub background: Option<String>,
30 pub bold: bool,
32 pub italic: bool,
34 pub position: SubtitlePosition,
36}
37
38#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
40pub enum SubtitlePosition {
41 #[default]
43 Bottom,
44 Top,
46 Center,
48 Custom {
50 x: u32,
52 y: u32,
54 },
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SubtitleTrack {
60 pub entries: Vec<SubtitleEntry>,
62 pub language: Option<String>,
64 pub default_style: Option<SubtitleStyle>,
66}
67
68impl SubtitleTrack {
69 pub fn new() -> Self {
71 Self {
72 entries: Vec::new(),
73 language: None,
74 default_style: None,
75 }
76 }
77
78 #[must_use]
80 pub fn add(mut self, range: TimeRange, text: impl Into<String>) -> Self {
81 self.entries.push(SubtitleEntry {
82 range,
83 text: text.into(),
84 style: None,
85 });
86 self
87 }
88
89 #[must_use]
91 pub fn with_language(mut self, lang: impl Into<String>) -> Self {
92 self.language = Some(lang.into());
93 self
94 }
95
96 pub fn from_srt(content: &str) -> AppResult<Self> {
104 let mut entries = Vec::new();
105 let content = content
107 .strip_prefix('\u{feff}')
108 .unwrap_or(content)
109 .replace("\r\n", "\n");
110
111 let blocks: Vec<&str> = content
113 .split("\n\n")
114 .filter(|b| !b.trim().is_empty())
115 .collect();
116
117 for block in blocks {
118 let lines: Vec<&str> = block.trim().lines().collect();
119 if lines.is_empty() {
120 continue;
121 }
122
123 let time_idx = lines.iter().position(|l| l.contains(" --> "));
125 let Some(time_idx) = time_idx else {
126 continue;
127 };
128
129 let time_line = lines[time_idx];
130 let parts: Vec<&str> = time_line.split(" --> ").collect();
131 if parts.len() != 2 {
132 continue;
133 }
134
135 let start = parse_srt_time(parts[0].trim()).ok_or_else(|| {
136 AppError::new(
137 ErrorCode::InvalidFormat,
138 format!("invalid SRT time: {}", parts[0]),
139 )
140 })?;
141 let end = parse_srt_time(parts[1].trim()).ok_or_else(|| {
142 AppError::new(
143 ErrorCode::InvalidFormat,
144 format!("invalid SRT time: {}", parts[1]),
145 )
146 })?;
147
148 let text_lines = &lines[time_idx + 1..];
150 if text_lines.is_empty() {
151 continue;
152 }
153 let text = strip_html_tags(&text_lines.join("\n"));
154
155 entries.push(SubtitleEntry {
156 range: TimeRange::from_millis(start, end),
157 text,
158 style: None,
159 });
160 }
161
162 Ok(Self {
163 entries,
164 language: None,
165 default_style: None,
166 })
167 }
168
169 pub fn from_vtt(content: &str) -> AppResult<Self> {
177 let content = content
178 .strip_prefix('\u{feff}')
179 .unwrap_or(content)
180 .replace("\r\n", "\n");
181 let content = content
182 .strip_prefix("WEBVTT")
183 .unwrap_or(&content)
184 .trim_start();
185 let mut entries = Vec::new();
186 let blocks: Vec<&str> = content
187 .split("\n\n")
188 .filter(|b| !b.trim().is_empty())
189 .collect();
190
191 for block in blocks {
192 let lines: Vec<&str> = block.trim().lines().collect();
193 if lines.is_empty() {
194 continue;
195 }
196
197 let time_idx = lines.iter().position(|l| l.contains(" --> "));
199 let Some(time_idx) = time_idx else {
200 continue;
201 };
202
203 let time_line = lines[time_idx];
204 let parts: Vec<&str> = time_line.split(" --> ").collect();
205 if parts.len() != 2 {
206 continue;
207 }
208
209 let start_str = parts[0].trim();
210 let end_str = parts[1].split_whitespace().next().unwrap_or("");
212
213 let start = parse_vtt_time(start_str).ok_or_else(|| {
214 AppError::new(
215 ErrorCode::InvalidFormat,
216 format!("invalid VTT time: {start_str}"),
217 )
218 })?;
219 let end = parse_vtt_time(end_str).ok_or_else(|| {
220 AppError::new(
221 ErrorCode::InvalidFormat,
222 format!("invalid VTT time: {end_str}"),
223 )
224 })?;
225
226 let text_lines = &lines[time_idx + 1..];
227 if text_lines.is_empty() {
228 continue;
229 }
230 let raw_text = text_lines.join("\n");
231 let text = decode_html_entities(&strip_html_tags(&raw_text));
232
233 entries.push(SubtitleEntry {
234 range: TimeRange::from_millis(start, end),
235 text,
236 style: None,
237 });
238 }
239
240 Ok(Self {
241 entries,
242 language: None,
243 default_style: None,
244 })
245 }
246
247 pub fn to_srt(&self) -> String {
249 let mut out = String::new();
250 for (i, entry) in self.entries.iter().enumerate() {
251 out.push_str(&format!("{}\n", i + 1));
252 out.push_str(&format!(
253 "{} --> {}\n",
254 format_srt_time(entry.range.start.as_millis()),
255 format_srt_time(entry.range.end.as_millis()),
256 ));
257 out.push_str(&entry.text);
258 out.push_str("\n\n");
259 }
260 out
261 }
262
263 pub fn to_vtt(&self) -> String {
265 let mut out = String::from("WEBVTT\n\n");
266 for entry in &self.entries {
267 out.push_str(&format!(
268 "{} --> {}\n",
269 format_vtt_time(entry.range.start.as_millis()),
270 format_vtt_time(entry.range.end.as_millis()),
271 ));
272 out.push_str(&entry.text);
273 out.push_str("\n\n");
274 }
275 out
276 }
277
278 pub fn shift(&mut self, offset: i64) {
280 for entry in &mut self.entries {
281 entry.range = entry.range.shift(offset);
282 }
283 }
284
285 pub fn in_range(&self, range: &TimeRange) -> Self {
287 Self {
288 entries: self
289 .entries
290 .iter()
291 .filter(|e| e.range.overlaps(range))
292 .cloned()
293 .collect(),
294 language: self.language.clone(),
295 default_style: self.default_style.clone(),
296 }
297 }
298}
299
300impl Default for SubtitleTrack {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306fn parse_srt_time(s: &str) -> Option<u64> {
309 let s = s.replace(',', ".");
310 parse_time_dotted(&s)
311}
312
313fn format_srt_time(ms: u64) -> String {
314 let millis = ms % 1000;
315 let total_secs = ms / 1000;
316 let secs = total_secs % 60;
317 let total_mins = total_secs / 60;
318 let mins = total_mins % 60;
319 let hours = total_mins / 60;
320 format!("{hours:02}:{mins:02}:{secs:02},{millis:03}")
321}
322
323fn parse_vtt_time(s: &str) -> Option<u64> {
326 parse_time_dotted(s)
327}
328
329fn format_vtt_time(ms: u64) -> String {
330 let millis = ms % 1000;
331 let total_secs = ms / 1000;
332 let secs = total_secs % 60;
333 let total_mins = total_secs / 60;
334 let mins = total_mins % 60;
335 let hours = total_mins / 60;
336 format!("{hours:02}:{mins:02}:{secs:02}.{millis:03}")
337}
338
339fn parse_time_dotted(s: &str) -> Option<u64> {
340 let (main, frac) = if let Some((m, f)) = s.split_once('.') {
341 (m, f.parse::<u64>().ok()?)
342 } else {
343 (s, 0)
344 };
345
346 let parts: Vec<&str> = main.split(':').collect();
347 let (h, m, sec) = match parts.len() {
348 3 => (
349 parts[0].parse::<u64>().ok()?,
350 parts[1].parse::<u64>().ok()?,
351 parts[2].parse::<u64>().ok()?,
352 ),
353 2 => (
354 0,
355 parts[0].parse::<u64>().ok()?,
356 parts[1].parse::<u64>().ok()?,
357 ),
358 _ => return None,
359 };
360
361 Some(h * 3_600_000 + m * 60_000 + sec * 1000 + frac)
362}
363
364fn strip_html_tags(s: &str) -> String {
366 let mut result = String::with_capacity(s.len());
367 let mut in_tag = false;
368 for ch in s.chars() {
369 match ch {
370 '<' => in_tag = true,
371 '>' => in_tag = false,
372 _ if !in_tag => result.push(ch),
373 _ => {}
374 }
375 }
376 result
377}
378
379fn decode_html_entities(s: &str) -> String {
381 s.replace("&", "&")
382 .replace("<", "<")
383 .replace(">", ">")
384 .replace(""", "\"")
385 .replace("'", "'")
386 .replace("'", "'")
387 .replace(" ", " ")
388 .replace("​", "")
389}
390
391#[cfg(test)]
392mod tests {
393 use crate::time::TimeRange;
394
395 use super::*;
396
397 #[test]
398 fn malformed_srt_and_vtt_blocks_are_skipped_or_rejected() {
399 let srt =
400 "\u{feff}not a number\nno timestamp\n\n3\n00:00:01,000 --> 00:00:02,000\n<b>ok</b>";
401 assert!(SubtitleTrack::from_srt("1\nbad --> 00:00:02,000\nbad").is_err());
402 assert!(SubtitleTrack::from_srt("1\n00:00:01,000 --> bad\nbad").is_err());
403 let track = SubtitleTrack::from_srt(srt).unwrap();
404 assert_eq!(track.entries.len(), 1);
405 assert_eq!(track.entries[0].text, "ok");
406
407 assert!(SubtitleTrack::from_vtt("WEBVTT\n\nbad --> 00:00:02.000\nbad").is_err());
408 assert!(SubtitleTrack::from_vtt("WEBVTT\n\n00:00:01.000 --> bad\nbad").is_err());
409 let vtt = "WEBVTT\n\nNOTE no timestamp\n\ncue\n00:00:01.000 --> 00:00:02.000 align:start\n<c>& hi</c>";
410 let track = SubtitleTrack::from_vtt(vtt).unwrap();
411 assert_eq!(track.entries.len(), 1);
412 assert_eq!(track.entries[0].text, "& hi");
413 }
414
415 #[test]
416 fn subtitle_defaults_formatters_and_helpers_cover_edge_cases() {
417 let mut track = SubtitleTrack::default()
418 .with_language("en")
419 .add(TimeRange::from_millis(1_000, 2_500), "hello");
420 assert_eq!(track.language.as_deref(), Some("en"));
421 assert!(track.default_style.is_none());
422 assert!(track.to_srt().contains("00:00:01,000 --> 00:00:02,500"));
423 assert!(track.to_vtt().contains("00:00:01.000 --> 00:00:02.500"));
424
425 track.shift(-500);
426 assert_eq!(track.entries[0].range.start.as_millis(), 500);
427 assert_eq!(
428 track
429 .in_range(&TimeRange::from_millis(0, 600))
430 .entries
431 .len(),
432 1
433 );
434 assert_eq!(parse_srt_time("00:00:01,250"), Some(1_250));
435 assert_eq!(parse_vtt_time("01:02.003"), Some(62_003));
436 assert_eq!(parse_time_dotted("bad"), None);
437 assert_eq!(strip_html_tags("<b>a</b><i>b</i>"), "ab");
438 }
439}