subx_cli/core/formats/
sub.rs1use crate::Result;
2use crate::core::formats::{
3 Subtitle, SubtitleEntry, SubtitleFormat, SubtitleFormatType, SubtitleMetadata,
4};
5use crate::error::SubXError;
6use regex::Regex;
7use std::time::Duration;
8
9const DEFAULT_SUB_FPS: f32 = 25.0;
10
11pub struct SubFormat;
13
14impl SubtitleFormat for SubFormat {
15 fn parse(&self, content: &str) -> Result<Subtitle> {
16 let fps = DEFAULT_SUB_FPS;
17 let re = Regex::new(r"^\{(\d+)\}\{(\d+)\}(.*)").map_err(|e: regex::Error| {
18 SubXError::subtitle_format(self.format_name(), e.to_string())
19 })?;
20 let mut entries = Vec::new();
21 for line in content.lines() {
22 let l = line.trim();
23 if l.is_empty() {
24 continue;
25 }
26 if let Some(cap) = re.captures(l) {
27 let start_frame: u64 = cap[1].parse().map_err(|e: std::num::ParseIntError| {
28 SubXError::subtitle_format(self.format_name(), e.to_string())
29 })?;
30 let end_frame: u64 = cap[2].parse().map_err(|e: std::num::ParseIntError| {
31 SubXError::subtitle_format(self.format_name(), e.to_string())
32 })?;
33 let text = cap[3].replace("|", "\n");
34 let start_time = Duration::from_millis(
35 (start_frame as f64 * 1000.0 / fps as f64).round() as u64,
36 );
37 let end_time =
38 Duration::from_millis((end_frame as f64 * 1000.0 / fps as f64).round() as u64);
39 entries.push(SubtitleEntry {
40 index: entries.len() + 1,
41 start_time,
42 end_time,
43 text,
44 styling: None,
45 });
46 }
47 }
48 Ok(Subtitle {
49 entries,
50 metadata: SubtitleMetadata {
51 title: None,
52 language: None,
53 encoding: "utf-8".to_string(),
54 frame_rate: Some(fps),
55 original_format: SubtitleFormatType::Sub,
56 },
57 format: SubtitleFormatType::Sub,
58 })
59 }
60
61 fn serialize(&self, subtitle: &Subtitle) -> Result<String> {
62 let fps = subtitle.metadata.frame_rate.unwrap_or(DEFAULT_SUB_FPS);
63 let mut output = String::new();
64 for entry in &subtitle.entries {
65 let start_frame = (entry.start_time.as_secs_f64() * fps as f64).round() as u64;
66 let end_frame = (entry.end_time.as_secs_f64() * fps as f64).round() as u64;
67 let text = entry.text.replace("\n", "|");
68 output.push_str(&format!("{{{}}}{{{}}}{}\n", start_frame, end_frame, text));
69 }
70 Ok(output)
71 }
72
73 fn detect(&self, content: &str) -> bool {
74 if let Ok(re) = Regex::new(r"^\{\d+\}\{\d+\}") {
75 return re.is_match(content.trim_start());
76 }
77 false
78 }
79
80 fn format_name(&self) -> &'static str {
81 "SUB"
82 }
83
84 fn file_extensions(&self) -> &'static [&'static str] {
85 &["sub"]
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 const SAMPLE: &str = "{10}{20}Hello|World\n";
94
95 #[test]
96 fn test_parse_and_serialize() {
97 let fmt = SubFormat;
98 let subtitle = fmt.parse(SAMPLE).expect("parse failed");
99 assert_eq!(subtitle.entries.len(), 1);
100 let out = fmt.serialize(&subtitle).expect("serialize failed");
101 assert!(out.contains("{10}{20}Hello|World"));
102 }
103
104 #[test]
105 fn test_detect_true_and_false() {
106 let fmt = SubFormat;
107 assert!(fmt.detect(SAMPLE));
108 assert!(!fmt.detect("random text"));
109 }
110
111 #[test]
112 fn test_parse_multiple_and_frame_rate() {
113 let custom = "{0}{25}First|Line\n{25}{50}Second|Line\n";
114 let fmt = SubFormat;
115 let subtitle = fmt.parse(custom).expect("parse multiple failed");
116 assert_eq!(subtitle.entries.len(), 2);
117 assert_eq!(subtitle.metadata.frame_rate, Some(25.0));
118 assert_eq!(subtitle.entries[0].text, "First\nLine");
119 assert_eq!(subtitle.entries[1].text, "Second\nLine");
120 }
121
122 #[test]
123 fn test_serialize_with_nondefault_fps() {
124 let mut subtitle = Subtitle {
125 entries: Vec::new(),
126 metadata: SubtitleMetadata {
127 title: None,
128 language: None,
129 encoding: "utf-8".to_string(),
130 frame_rate: Some(50.0),
131 original_format: SubtitleFormatType::Sub,
132 },
133 format: SubtitleFormatType::Sub,
134 };
135 subtitle.entries.push(SubtitleEntry {
136 index: 1,
137 start_time: Duration::from_secs_f64(1.0),
138 end_time: Duration::from_secs_f64(2.0),
139 text: "X".into(),
140 styling: None,
141 });
142 let fmt = SubFormat;
143 let out = fmt.serialize(&subtitle).expect("serialize fps failed");
144 assert!(out.contains("{50}{100}X"));
146 }
147}