subx_cli/core/formats/
sub.rs1use 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
25pub 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; 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 assert!(out.contains("{50}{100}X"));
176 }
177
178 #[test]
179 fn test_parse_sub_skips_huge_frame_numbers() {
180 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}