Skip to main content

subx_cli/core/formats/
sub.rs

1//! MicroDVD/SubViewer SUB subtitle format implementation.
2//!
3//! This module provides parsing, serialization, and detection capabilities
4//! for the MicroDVD/SubViewer SUB subtitle format, which uses frame-based timing.
5//!
6//! # Examples
7//!
8//! ```rust
9//! use subx_cli::core::formats::{SubtitleFormat, sub::SubFormat};
10//! let sub = SubFormat;
11//! let content = "{0}{25}Hello\n";
12//! let subtitle = sub.parse(content).unwrap();
13//! ```
14
15use crate::Result;
16use crate::core::formats::{
17    Subtitle, SubtitleEntry, SubtitleFormat, SubtitleFormatType, SubtitleMetadata,
18};
19use crate::error::SubXError;
20use regex::Regex;
21use std::time::Duration;
22
23const DEFAULT_SUB_FPS: f32 = 25.0;
24
25/// Subtitle format implementation for MicroDVD/SubViewer SUB.
26///
27/// The `SubFormat` struct implements parsing, serialization, and
28/// detection for SUB files using frame-based timing.
29pub struct SubFormat;
30
31impl SubtitleFormat for SubFormat {
32    fn parse(&self, content: &str) -> Result<Subtitle> {
33        let fps = DEFAULT_SUB_FPS;
34        let re = Regex::new(r"^\{(\d+)\}\{(\d+)\}(.*)").map_err(|e: regex::Error| {
35            SubXError::subtitle_format(self.format_name(), e.to_string())
36        })?;
37        let mut entries = Vec::new();
38        for line in content.lines() {
39            let l = line.trim();
40            if l.is_empty() {
41                continue;
42            }
43            if let Some(cap) = re.captures(l) {
44                let start_frame: u64 = cap[1].parse().map_err(|e: std::num::ParseIntError| {
45                    SubXError::subtitle_format(self.format_name(), e.to_string())
46                })?;
47                let end_frame: u64 = cap[2].parse().map_err(|e: std::num::ParseIntError| {
48                    SubXError::subtitle_format(self.format_name(), e.to_string())
49                })?;
50                let text = cap[3].replace("|", "\n");
51                let start_ms = (start_frame as f64 * 1000.0 / fps as f64).round() as u64;
52                let end_ms = (end_frame as f64 * 1000.0 / fps as f64).round() as u64;
53
54                const MAX_DURATION_MS: u64 = 86_400_000; // 24 hours
55                if start_ms > MAX_DURATION_MS || end_ms > MAX_DURATION_MS {
56                    log::debug!(
57                        "Skipping SUB entry with out-of-range frames: {{{}}}{{{}}} (computed {}ms -> {}ms, limit {}ms)",
58                        start_frame,
59                        end_frame,
60                        start_ms,
61                        end_ms,
62                        MAX_DURATION_MS
63                    );
64                    continue;
65                }
66
67                let start_time = Duration::from_millis(start_ms);
68                let end_time = Duration::from_millis(end_ms);
69                entries.push(SubtitleEntry {
70                    index: entries.len() + 1,
71                    start_time,
72                    end_time,
73                    text,
74                    styling: None,
75                });
76            }
77        }
78        Ok(Subtitle {
79            entries,
80            metadata: SubtitleMetadata {
81                title: None,
82                language: None,
83                encoding: "utf-8".to_string(),
84                frame_rate: Some(fps),
85                original_format: SubtitleFormatType::Sub,
86            },
87            format: SubtitleFormatType::Sub,
88        })
89    }
90
91    fn serialize(&self, subtitle: &Subtitle) -> Result<String> {
92        let fps = subtitle.metadata.frame_rate.unwrap_or(DEFAULT_SUB_FPS);
93        let mut output = String::new();
94        for entry in &subtitle.entries {
95            let start_frame = (entry.start_time.as_secs_f64() * fps as f64).round() as u64;
96            let end_frame = (entry.end_time.as_secs_f64() * fps as f64).round() as u64;
97            let text = entry.text.replace("\n", "|");
98            output.push_str(&format!("{{{}}}{{{}}}{}\n", start_frame, end_frame, text));
99        }
100        Ok(output)
101    }
102
103    fn detect(&self, content: &str) -> bool {
104        if let Ok(re) = Regex::new(r"^\{\d+\}\{\d+\}") {
105            return re.is_match(content.trim_start());
106        }
107        false
108    }
109
110    fn format_name(&self) -> &'static str {
111        "SUB"
112    }
113
114    fn file_extensions(&self) -> &'static [&'static str] {
115        &["sub"]
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    const SAMPLE: &str = "{10}{20}Hello|World\n";
124
125    #[test]
126    fn test_parse_and_serialize() {
127        let fmt = SubFormat;
128        let subtitle = fmt.parse(SAMPLE).expect("parse failed");
129        assert_eq!(subtitle.entries.len(), 1);
130        let out = fmt.serialize(&subtitle).expect("serialize failed");
131        assert!(out.contains("{10}{20}Hello|World"));
132    }
133
134    #[test]
135    fn test_detect_true_and_false() {
136        let fmt = SubFormat;
137        assert!(fmt.detect(SAMPLE));
138        assert!(!fmt.detect("random text"));
139    }
140
141    #[test]
142    fn test_parse_multiple_and_frame_rate() {
143        let custom = "{0}{25}First|Line\n{25}{50}Second|Line\n";
144        let fmt = SubFormat;
145        let subtitle = fmt.parse(custom).expect("parse multiple failed");
146        assert_eq!(subtitle.entries.len(), 2);
147        assert_eq!(subtitle.metadata.frame_rate, Some(25.0));
148        assert_eq!(subtitle.entries[0].text, "First\nLine");
149        assert_eq!(subtitle.entries[1].text, "Second\nLine");
150    }
151
152    #[test]
153    fn test_serialize_with_nondefault_fps() {
154        let mut subtitle = Subtitle {
155            entries: Vec::new(),
156            metadata: SubtitleMetadata {
157                title: None,
158                language: None,
159                encoding: "utf-8".to_string(),
160                frame_rate: Some(50.0),
161                original_format: SubtitleFormatType::Sub,
162            },
163            format: SubtitleFormatType::Sub,
164        };
165        subtitle.entries.push(SubtitleEntry {
166            index: 1,
167            start_time: Duration::from_secs_f64(1.0),
168            end_time: Duration::from_secs_f64(2.0),
169            text: "X".into(),
170            styling: None,
171        });
172        let fmt = SubFormat;
173        let out = fmt.serialize(&subtitle).expect("serialize fps failed");
174        // 1s * 50fps = 50 frames
175        assert!(out.contains("{50}{100}X"));
176    }
177
178    #[test]
179    fn test_parse_sub_skips_huge_frame_numbers() {
180        // 25fps -> 86_400_000ms (24h) corresponds to 2_160_000 frames.
181        // Use a frame count well above that so the entry must be skipped.
182        let content = "{0}{25}Good\n{999999999}{999999999}TooBig\n{50}{75}AlsoGood\n";
183        let fmt = SubFormat;
184        let subtitle = fmt.parse(content).expect("parse must succeed");
185        assert_eq!(subtitle.entries.len(), 2);
186        assert_eq!(subtitle.entries[0].text, "Good");
187        assert_eq!(subtitle.entries[1].text, "AlsoGood");
188    }
189}