Skip to main content

omni_dev/transcript/format/
srt.rs

1//! SubRip (`.srt`) renderer.
2//!
3//! ```text
4//! 1
5//! 00:00:01,000 --> 00:00:04,000
6//! Subtitle text
7//!
8//! 2
9//! ...
10//! ```
11
12use std::fmt::Write;
13
14use crate::transcript::cue::Cue;
15
16/// Render `cues` to a SubRip-formatted string. Returns `""` for an empty
17/// input.
18pub fn render(cues: &[Cue]) -> String {
19    let mut out = String::new();
20    for (idx, cue) in cues.iter().enumerate() {
21        let _ = writeln!(out, "{}", idx + 1);
22        let _ = writeln!(
23            out,
24            "{} --> {}",
25            format_timestamp(cue.start_ms),
26            format_timestamp(cue.end_ms)
27        );
28        out.push_str(&cue.text);
29        out.push('\n');
30        out.push('\n');
31    }
32    out
33}
34
35/// Format `ms` as `HH:MM:SS,mmm` (SRT's comma decimal separator).
36fn format_timestamp(ms: u64) -> String {
37    let hours = ms / 3_600_000;
38    let minutes = (ms % 3_600_000) / 60_000;
39    let seconds = (ms % 60_000) / 1_000;
40    let millis = ms % 1_000;
41    format!("{hours:02}:{minutes:02}:{seconds:02},{millis:03}")
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn empty_input_yields_empty_string() {
50        assert_eq!(render(&[]), "");
51    }
52
53    #[test]
54    fn single_cue_basic() {
55        let cues = vec![Cue::new(1_000, 4_000, "Hello, world.")];
56        let out = render(&cues);
57        assert_eq!(out, "1\n00:00:01,000 --> 00:00:04,000\nHello, world.\n\n");
58    }
59
60    #[test]
61    fn multiple_cues_are_sequentially_numbered() {
62        let cues = vec![
63            Cue::new(0, 1_000, "one"),
64            Cue::new(1_000, 2_000, "two"),
65            Cue::new(2_000, 3_000, "three"),
66        ];
67        let out = render(&cues);
68        assert!(out.starts_with("1\n"));
69        assert!(out.contains("\n2\n"));
70        assert!(out.contains("\n3\n"));
71        assert!(out.contains("one"));
72        assert!(out.contains("two"));
73        assert!(out.contains("three"));
74    }
75
76    #[test]
77    fn timestamps_pad_zero() {
78        assert_eq!(format_timestamp(0), "00:00:00,000");
79    }
80
81    #[test]
82    fn timestamps_handle_subsecond_millis() {
83        assert_eq!(format_timestamp(1), "00:00:00,001");
84        assert_eq!(format_timestamp(999), "00:00:00,999");
85    }
86
87    #[test]
88    fn timestamps_handle_minute_boundary() {
89        assert_eq!(format_timestamp(60_000), "00:01:00,000");
90        assert_eq!(format_timestamp(59_999), "00:00:59,999");
91    }
92
93    #[test]
94    fn timestamps_handle_hour_boundary() {
95        assert_eq!(format_timestamp(3_600_000), "01:00:00,000");
96        assert_eq!(format_timestamp(3_599_999), "00:59:59,999");
97    }
98
99    #[test]
100    fn timestamps_handle_multi_hour() {
101        assert_eq!(
102            format_timestamp(2 * 3_600_000 + 30 * 60_000 + 45 * 1_000 + 678),
103            "02:30:45,678"
104        );
105    }
106
107    #[test]
108    fn timestamps_use_comma_separator_not_period() {
109        let ts = format_timestamp(1_500);
110        assert!(ts.contains(','));
111        assert!(!ts.contains('.'));
112    }
113
114    #[test]
115    fn cue_text_with_newlines_is_preserved() {
116        let cues = vec![Cue::new(0, 1_000, "line one\nline two")];
117        let out = render(&cues);
118        assert!(out.contains("line one\nline two\n\n"));
119    }
120
121    #[test]
122    fn zero_length_cue_is_emitted() {
123        let cues = vec![Cue::new(500, 500, "instant")];
124        let out = render(&cues);
125        assert!(out.contains("00:00:00,500 --> 00:00:00,500"));
126        assert!(out.contains("instant"));
127    }
128
129    #[test]
130    fn empty_text_cue_still_has_blank_line() {
131        let cues = vec![Cue::new(0, 1_000, "")];
132        let out = render(&cues);
133        // Cue index, timecode, empty content, blank separator.
134        assert_eq!(out, "1\n00:00:00,000 --> 00:00:01,000\n\n\n");
135    }
136
137    #[test]
138    fn output_ends_with_blank_line_separator() {
139        let cues = vec![Cue::new(0, 1_000, "x")];
140        let out = render(&cues);
141        assert!(out.ends_with("\n\n"));
142    }
143
144    #[test]
145    fn special_characters_pass_through() {
146        let cues = vec![Cue::new(0, 1_000, "<i>italic</i> & \"quoted\"")];
147        let out = render(&cues);
148        assert!(out.contains("<i>italic</i> & \"quoted\""));
149    }
150}