subx_cli/core/formats/
ass.rs

1use crate::Result;
2use crate::core::formats::{
3    Subtitle, SubtitleEntry, SubtitleFormat, SubtitleFormatType, SubtitleMetadata,
4};
5use crate::error::SubXError;
6use std::time::Duration;
7
8/// ASS 樣式定義
9#[derive(Debug, Clone)]
10pub struct AssStyle {
11    pub name: String,
12    pub font_name: String,
13    pub font_size: u32,
14    pub primary_color: Color,
15    pub secondary_color: Color,
16    pub outline_color: Color,
17    pub shadow_color: Color,
18    pub bold: bool,
19    pub italic: bool,
20    pub underline: bool,
21    pub alignment: i32,
22}
23
24/// ASS 顏色結構
25#[derive(Debug, Clone)]
26pub struct Color {
27    pub r: u8,
28    pub g: u8,
29    pub b: u8,
30}
31
32impl Color {
33    pub fn white() -> Self {
34        Color {
35            r: 255,
36            g: 255,
37            b: 255,
38        }
39    }
40    pub fn black() -> Self {
41        Color { r: 0, g: 0, b: 0 }
42    }
43    pub fn red() -> Self {
44        Color { r: 255, g: 0, b: 0 }
45    }
46}
47
48/// ASS/SSA 高級字幕格式解析(暫未實作)
49pub struct AssFormat;
50
51impl SubtitleFormat for AssFormat {
52    fn parse(&self, content: &str) -> Result<Subtitle> {
53        let mut entries = Vec::new();
54        let mut in_events = false;
55        let mut fields: Vec<&str> = Vec::new();
56        for line in content.lines() {
57            let l = line.trim_start();
58            if l.eq_ignore_ascii_case("[events]") {
59                in_events = true;
60                continue;
61            }
62            if !in_events {
63                continue;
64            }
65            if l.to_lowercase().starts_with("format:") {
66                fields = l["Format:".len()..].split(',').map(|s| s.trim()).collect();
67                continue;
68            }
69            if l.to_lowercase().starts_with("dialogue:") {
70                let data = l["Dialogue:".len()..].trim();
71                let parts: Vec<&str> = data.splitn(fields.len(), ',').collect();
72                if parts.len() < fields.len() {
73                    continue;
74                }
75                let start = parts[fields
76                    .iter()
77                    .position(|&f| f.eq_ignore_ascii_case("start"))
78                    .unwrap()]
79                .trim();
80                let end = parts[fields
81                    .iter()
82                    .position(|&f| f.eq_ignore_ascii_case("end"))
83                    .unwrap()]
84                .trim();
85                let text_index = fields
86                    .iter()
87                    .position(|&f| f.eq_ignore_ascii_case("text"))
88                    .unwrap();
89                let text = parts[text_index..].join(",").replace("\\N", "\n");
90                let start_time = parse_ass_time(start)?;
91                let end_time = parse_ass_time(end)?;
92                entries.push(SubtitleEntry {
93                    index: entries.len() + 1,
94                    start_time,
95                    end_time,
96                    text,
97                    styling: None,
98                });
99            }
100        }
101        Ok(Subtitle {
102            entries,
103            metadata: SubtitleMetadata {
104                title: None,
105                language: None,
106                encoding: "utf-8".to_string(),
107                frame_rate: None,
108                original_format: SubtitleFormatType::Ass,
109            },
110            format: SubtitleFormatType::Ass,
111        })
112    }
113
114    fn serialize(&self, subtitle: &Subtitle) -> Result<String> {
115        let mut output = String::new();
116        output.push_str("[Script Info]\n");
117        output.push_str("; Script generated by SubX\n");
118        output.push_str("ScriptType: v4.00+\n\n");
119        output.push_str("[V4+ Styles]\n");
120        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");
121        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");
122        output.push_str("[Events]\n");
123        output.push_str("Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n");
124        for entry in &subtitle.entries {
125            let text = entry.text.replace('\n', "\\N");
126            let start = format_ass_time(entry.start_time);
127            let end = format_ass_time(entry.end_time);
128            output.push_str(&format!(
129                "Dialogue: 0,{},{},Default,,0000,0000,0000,,{}\n",
130                start, end, text
131            ));
132        }
133        Ok(output)
134    }
135
136    fn detect(&self, content: &str) -> bool {
137        content.contains("[Script Info]") || content.contains("Dialogue:")
138    }
139
140    fn format_name(&self) -> &'static str {
141        "ASS"
142    }
143
144    fn file_extensions(&self) -> &'static [&'static str] {
145        &["ass", "ssa"]
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    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";
154
155    #[test]
156    fn test_detect_ass() {
157        let fmt = AssFormat;
158        assert!(fmt.detect(SAMPLE_ASS));
159        assert!(!fmt.detect("Not ASS content"));
160    }
161
162    #[test]
163    fn test_parse_ass_and_serialize() {
164        let fmt = AssFormat;
165        let subtitle = fmt.parse(SAMPLE_ASS).expect("ASS parse failed");
166        assert_eq!(subtitle.entries.len(), 1);
167        assert_eq!(subtitle.entries[0].text, "Hello\nASS");
168        let out = fmt.serialize(&subtitle).expect("ASS serialize failed");
169        assert!(out.contains("Dialogue: 0,0:00:01.00,0:00:02.50"));
170        assert!(out.contains("Hello\\NASS"));
171    }
172}
173
174fn parse_ass_time(time: &str) -> Result<Duration> {
175    let parts: Vec<&str> = time.split(&[':', '.'][..]).collect();
176    if parts.len() != 4 {
177        return Err(SubXError::subtitle_format(
178            "ASS",
179            format!("Invalid time format: {}", time),
180        ));
181    }
182    let hours: u64 = parts[0]
183        .parse()
184        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
185    let minutes: u64 = parts[1]
186        .parse()
187        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
188    let seconds: u64 = parts[2]
189        .parse()
190        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
191    let centi: u64 = parts[3]
192        .parse()
193        .map_err(|e: std::num::ParseIntError| SubXError::subtitle_format("ASS", e.to_string()))?;
194    Ok(Duration::from_millis(
195        hours * 3600 * 1000 + minutes * 60 * 1000 + seconds * 1000 + centi * 10,
196    ))
197}
198
199fn format_ass_time(duration: Duration) -> String {
200    let total_ms = duration.as_millis();
201    let hours = total_ms / 3600000;
202    let minutes = (total_ms % 3600000) / 60000;
203    let seconds = (total_ms % 60000) / 1000;
204    let centi = (total_ms % 1000) / 10;
205    format!("{}:{:02}:{:02}.{:02}", hours, minutes, seconds, centi)
206}