subx_cli/core/formats/
ass.rs

1//! Advanced SubStation Alpha (ASS/SSA) subtitle format implementation.
2//!
3//! This module provides parsing, serialization, and detection capabilities
4//! for the ASS/SSA subtitle format, including style and color definitions.
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! use subx_cli::core::formats::{SubtitleFormat, ass::AssFormat};
10//! let ass = AssFormat;
11//! let content = "[Events]\nFormat: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\nDialogue: 0,0:00:01.00,0:00:02.50,Default,,0000,0000,0000,,Hello";
12//! let subtitle = ass.parse(content).unwrap();
13//! ```
14
15use crate::Result;
16use crate::core::formats::{
17    Subtitle, SubtitleEntry, SubtitleFormat, SubtitleFormatType, SubtitleMetadata,
18};
19use crate::error::SubXError;
20use std::time::Duration;
21
22/// ASS style definition for subtitle entries.
23#[derive(Debug, Clone)]
24pub struct AssStyle {
25    /// Name identifier for this style
26    pub name: String,
27    /// Font family name to use for rendering
28    pub font_name: String,
29    /// Font size in points
30    pub font_size: u32,
31    /// Primary text color
32    pub primary_color: Color,
33    /// Secondary text color for styling effects
34    pub secondary_color: Color,
35    /// Outline border color
36    pub outline_color: Color,
37    /// Shadow color for text depth effect
38    pub shadow_color: Color,
39    /// Whether text should be rendered in bold
40    pub bold: bool,
41    /// Whether text should be rendered in italic
42    pub italic: bool,
43    /// Whether text should be underlined
44    pub underline: bool,
45    /// Text alignment value (1-9 for numpad positions)
46    pub alignment: i32,
47}
48
49/// ASS color structure for style entries.
50#[derive(Debug, Clone)]
51pub struct Color {
52    /// Red component (0-255)
53    pub r: u8,
54    /// Green component (0-255)
55    pub g: u8,
56    /// Blue component (0-255)
57    pub b: u8,
58}
59
60impl Color {
61    /// Creates a white color (RGB: 255, 255, 255).
62    pub fn white() -> Self {
63        Color {
64            r: 255,
65            g: 255,
66            b: 255,
67        }
68    }
69
70    /// Creates a black color (RGB: 0, 0, 0).
71    pub fn black() -> Self {
72        Color { r: 0, g: 0, b: 0 }
73    }
74
75    /// Creates a red color (RGB: 255, 0, 0).
76    pub fn red() -> Self {
77        Color { r: 255, g: 0, b: 0 }
78    }
79}
80
81/// Subtitle format implementation for ASS/SSA.
82///
83/// The `AssFormat` struct implements parsing, serialization, and detection
84/// for the ASS/SSA subtitle format.
85pub struct AssFormat;
86
87impl SubtitleFormat for AssFormat {
88    fn parse(&self, content: &str) -> Result<Subtitle> {
89        let mut entries = Vec::new();
90        let mut in_events = false;
91        let mut fields: Vec<&str> = Vec::new();
92        for line in content.lines() {
93            let l = line.trim_start();
94            if l.eq_ignore_ascii_case("[events]") {
95                in_events = true;
96                continue;
97            }
98            if !in_events {
99                continue;
100            }
101            if l.to_lowercase().starts_with("format:") {
102                fields = l["Format:".len()..].split(',').map(|s| s.trim()).collect();
103                continue;
104            }
105            if l.to_lowercase().starts_with("dialogue:") {
106                let data = l["Dialogue:".len()..].trim();
107                let parts: Vec<&str> = data.splitn(fields.len(), ',').collect();
108                if parts.len() < fields.len() {
109                    continue;
110                }
111                let start = parts[fields
112                    .iter()
113                    .position(|&f| f.eq_ignore_ascii_case("start"))
114                    .unwrap()]
115                .trim();
116                let end = parts[fields
117                    .iter()
118                    .position(|&f| f.eq_ignore_ascii_case("end"))
119                    .unwrap()]
120                .trim();
121                let text_index = fields
122                    .iter()
123                    .position(|&f| f.eq_ignore_ascii_case("text"))
124                    .unwrap();
125                let text = parts[text_index..].join(",").replace("\\N", "\n");
126                let start_time = parse_ass_time(start)?;
127                let end_time = parse_ass_time(end)?;
128                entries.push(SubtitleEntry {
129                    index: entries.len() + 1,
130                    start_time,
131                    end_time,
132                    text,
133                    styling: None,
134                });
135            }
136        }
137        Ok(Subtitle {
138            entries,
139            metadata: SubtitleMetadata {
140                title: None,
141                language: None,
142                encoding: "utf-8".to_string(),
143                frame_rate: None,
144                original_format: SubtitleFormatType::Ass,
145            },
146            format: SubtitleFormatType::Ass,
147        })
148    }
149
150    fn serialize(&self, subtitle: &Subtitle) -> Result<String> {
151        let mut output = String::new();
152        output.push_str("[Script Info]\n");
153        output.push_str("; Script generated by SubX\n");
154        output.push_str("ScriptType: v4.00+\n\n");
155        output.push_str("[V4+ Styles]\n");
156        output.push_str("Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\n");
157        output.push_str("Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1\n\n");
158        output.push_str("[Events]\n");
159        output.push_str("Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n");
160        for entry in &subtitle.entries {
161            let text = entry.text.replace('\n', "\\N");
162            let start = format_ass_time(entry.start_time);
163            let end = format_ass_time(entry.end_time);
164            output.push_str(&format!(
165                "Dialogue: 0,{},{},Default,,0000,0000,0000,,{}\n",
166                start, end, text
167            ));
168        }
169        Ok(output)
170    }
171
172    fn detect(&self, content: &str) -> bool {
173        content.contains("[Script Info]") || content.contains("Dialogue:")
174    }
175
176    fn format_name(&self) -> &'static str {
177        "ASS"
178    }
179
180    fn file_extensions(&self) -> &'static [&'static str] {
181        &["ass", "ssa"]
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const SAMPLE_ASS: &str = "[Script Info]\nScriptType: v4.00+\n\n[V4+ Styles]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\n[Events]\nFormat: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\nDialogue: 0,0:00:01.00,0:00:02.50,Default,,0000,0000,0000,,Hello\\NASS\n";
190
191    #[test]
192    fn test_detect_ass() {
193        let fmt = AssFormat;
194        assert!(fmt.detect(SAMPLE_ASS));
195        assert!(!fmt.detect("Not ASS content"));
196    }
197
198    #[test]
199    fn test_parse_ass_and_serialize() {
200        let fmt = AssFormat;
201        let subtitle = fmt.parse(SAMPLE_ASS).expect("ASS parse failed");
202        assert_eq!(subtitle.entries.len(), 1);
203        assert_eq!(subtitle.entries[0].text, "Hello\nASS");
204        let out = fmt.serialize(&subtitle).expect("ASS serialize failed");
205        assert!(out.contains("Dialogue: 0,0:00:01.00,0:00:02.50"));
206        assert!(out.contains("Hello\\NASS"));
207    }
208}
209
210fn parse_ass_time(time: &str) -> Result<Duration> {
211    let parts: Vec<&str> = time.split(&[':', '.'][..]).collect();
212    if parts.len() != 4 {
213        return Err(SubXError::subtitle_format(
214            "ASS",
215            format!("Invalid time format: {}", time),
216        ));
217    }
218    let hours: u64 = parts[0]
219        .parse()
220        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
221    let minutes: u64 = parts[1]
222        .parse()
223        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
224    let seconds: u64 = parts[2]
225        .parse()
226        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
227    let centi: u64 = parts[3]
228        .parse()
229        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
230    Ok(Duration::from_millis(
231        hours * 3600 * 1000 + minutes * 60 * 1000 + seconds * 1000 + centi * 10,
232    ))
233}
234
235fn format_ass_time(duration: Duration) -> String {
236    let total_ms = duration.as_millis();
237    let hours = total_ms / 3600000;
238    let minutes = (total_ms % 3600000) / 60000;
239    let seconds = (total_ms % 60000) / 1000;
240    let centi = (total_ms % 1000) / 10;
241    format!("{}:{:02}:{:02}.{:02}", hours, minutes, seconds, centi)
242}