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_time = Duration::from_millis(
52                    (start_frame as f64 * 1000.0 / fps as f64).round() as u64,
53                );
54                let end_time =
55                    Duration::from_millis((end_frame as f64 * 1000.0 / fps as f64).round() as u64);
56                entries.push(SubtitleEntry {
57                    index: entries.len() + 1,
58                    start_time,
59                    end_time,
60                    text,
61                    styling: None,
62                });
63            }
64        }
65        Ok(Subtitle {
66            entries,
67            metadata: SubtitleMetadata {
68                title: None,
69                language: None,
70                encoding: "utf-8".to_string(),
71                frame_rate: Some(fps),
72                original_format: SubtitleFormatType::Sub,
73            },
74            format: SubtitleFormatType::Sub,
75        })
76    }
77
78    fn serialize(&self, subtitle: &Subtitle) -> Result<String> {
79        let fps = subtitle.metadata.frame_rate.unwrap_or(DEFAULT_SUB_FPS);
80        let mut output = String::new();
81        for entry in &subtitle.entries {
82            let start_frame = (entry.start_time.as_secs_f64() * fps as f64).round() as u64;
83            let end_frame = (entry.end_time.as_secs_f64() * fps as f64).round() as u64;
84            let text = entry.text.replace("\n", "|");
85            output.push_str(&format!("{{{}}}{{{}}}{}\n", start_frame, end_frame, text));
86        }
87        Ok(output)
88    }
89
90    fn detect(&self, content: &str) -> bool {
91        if let Ok(re) = Regex::new(r"^\{\d+\}\{\d+\}") {
92            return re.is_match(content.trim_start());
93        }
94        false
95    }
96
97    fn format_name(&self) -> &'static str {
98        "SUB"
99    }
100
101    fn file_extensions(&self) -> &'static [&'static str] {
102        &["sub"]
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    const SAMPLE: &str = "{10}{20}Hello|World\n";
111
112    #[test]
113    fn test_parse_and_serialize() {
114        let fmt = SubFormat;
115        let subtitle = fmt.parse(SAMPLE).expect("parse failed");
116        assert_eq!(subtitle.entries.len(), 1);
117        let out = fmt.serialize(&subtitle).expect("serialize failed");
118        assert!(out.contains("{10}{20}Hello|World"));
119    }
120
121    #[test]
122    fn test_detect_true_and_false() {
123        let fmt = SubFormat;
124        assert!(fmt.detect(SAMPLE));
125        assert!(!fmt.detect("random text"));
126    }
127
128    #[test]
129    fn test_parse_multiple_and_frame_rate() {
130        let custom = "{0}{25}First|Line\n{25}{50}Second|Line\n";
131        let fmt = SubFormat;
132        let subtitle = fmt.parse(custom).expect("parse multiple failed");
133        assert_eq!(subtitle.entries.len(), 2);
134        assert_eq!(subtitle.metadata.frame_rate, Some(25.0));
135        assert_eq!(subtitle.entries[0].text, "First\nLine");
136        assert_eq!(subtitle.entries[1].text, "Second\nLine");
137    }
138
139    #[test]
140    fn test_serialize_with_nondefault_fps() {
141        let mut subtitle = Subtitle {
142            entries: Vec::new(),
143            metadata: SubtitleMetadata {
144                title: None,
145                language: None,
146                encoding: "utf-8".to_string(),
147                frame_rate: Some(50.0),
148                original_format: SubtitleFormatType::Sub,
149            },
150            format: SubtitleFormatType::Sub,
151        };
152        subtitle.entries.push(SubtitleEntry {
153            index: 1,
154            start_time: Duration::from_secs_f64(1.0),
155            end_time: Duration::from_secs_f64(2.0),
156            text: "X".into(),
157            styling: None,
158        });
159        let fmt = SubFormat;
160        let out = fmt.serialize(&subtitle).expect("serialize fps failed");
161        // 1s * 50fps = 50 frames
162        assert!(out.contains("{50}{100}X"));
163    }
164}