Skip to main content

rskit_media/stream/
subtitle.rs

1//! Subtitle types and SRT/VTT parsing.
2
3use rskit_errors::{AppError, AppResult, ErrorCode};
4use serde::{Deserialize, Serialize};
5
6use crate::time::TimeRange;
7
8/// A single subtitle entry.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SubtitleEntry {
11    /// Time range when this subtitle is displayed.
12    pub range: TimeRange,
13    /// The subtitle text.
14    pub text: String,
15    /// Optional styling for this entry.
16    pub style: Option<SubtitleStyle>,
17}
18
19/// Subtitle visual style.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SubtitleStyle {
22    /// Font family name.
23    pub font_family: Option<String>,
24    /// Font size in points.
25    pub font_size: Option<u16>,
26    /// Text color (CSS format, e.g., "#FFFFFF").
27    pub color: Option<String>,
28    /// Background color.
29    pub background: Option<String>,
30    /// Whether text is bold.
31    pub bold: bool,
32    /// Whether text is italic.
33    pub italic: bool,
34    /// Where to position the subtitle.
35    pub position: SubtitlePosition,
36}
37
38/// Subtitle display position.
39#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
40pub enum SubtitlePosition {
41    /// Bottom of the screen (default).
42    #[default]
43    Bottom,
44    /// Top of the screen.
45    Top,
46    /// Center of the screen.
47    Center,
48    /// Custom pixel coordinates.
49    Custom {
50        /// X coordinate.
51        x: u32,
52        /// Y coordinate.
53        y: u32,
54    },
55}
56
57/// A collection of subtitle entries.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SubtitleTrack {
60    /// Subtitle entries sorted by time.
61    pub entries: Vec<SubtitleEntry>,
62    /// Track language (BCP 47 tag).
63    pub language: Option<String>,
64    /// Default style for all entries (can be overridden per entry).
65    pub default_style: Option<SubtitleStyle>,
66}
67
68impl SubtitleTrack {
69    /// Create an empty subtitle track.
70    pub fn new() -> Self {
71        Self {
72            entries: Vec::new(),
73            language: None,
74            default_style: None,
75        }
76    }
77
78    /// Add a subtitle entry (builder pattern).
79    #[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    /// Set the track language.
90    #[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    /// Parse SRT format subtitle content.
97    ///
98    /// Handles common malformations:
99    /// - Extra blank lines between entries
100    /// - Windows (`\r\n`) and Unix (`\n`) line endings
101    /// - Missing or non-numeric sequence numbers
102    /// - BOM markers
103    pub fn from_srt(content: &str) -> AppResult<Self> {
104        let mut entries = Vec::new();
105        // Normalize line endings and strip BOM
106        let content = content
107            .strip_prefix('\u{feff}')
108            .unwrap_or(content)
109            .replace("\r\n", "\n");
110
111        // Split on 2+ consecutive newlines to handle extra blank lines
112        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            // Find the timestamp line (contains " --> ")
124            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            // Text is everything after the timestamp line
149            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    /// Parse WebVTT format subtitle content.
170    ///
171    /// Handles common malformations:
172    /// - BOM markers, `\r\n` line endings
173    /// - Extra blank lines between cues
174    /// - HTML tags in cue text (stripped)
175    /// - Position/alignment settings on the timestamp line (ignored)
176    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            // Find the timestamp line
198            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            // End timestamp may have position settings after it
211            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    /// Serialize to SRT format.
248    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    /// Serialize to WebVTT format.
264    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    /// Shift all entries by the given millisecond offset.
279    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    /// Return a new track containing only entries that overlap the given range.
286    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
306// ── SRT time parsing: "HH:MM:SS,mmm" ────────────────────────────────
307
308fn 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
323// ── VTT time parsing: "HH:MM:SS.mmm" or "MM:SS.mmm" ─────────────────
324
325fn 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
364/// Strip HTML/VTT tags like `<b>`, `<i>`, `<c>`, `<u>`, `<ruby>`, etc.
365fn 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
379/// Decode common HTML entities in VTT text.
380fn decode_html_entities(s: &str) -> String {
381    s.replace("&amp;", "&")
382        .replace("&lt;", "<")
383        .replace("&gt;", ">")
384        .replace("&quot;", "\"")
385        .replace("&#39;", "'")
386        .replace("&apos;", "'")
387        .replace("&nbsp;", " ")
388        .replace("&#x200B;", "")
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>&amp; 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}