ass_editor/formats/ass/
mod.rs

1//! ASS (Advanced SubStation Alpha) format support.
2//!
3//! This module provides import/export functionality for ASS files,
4//! leveraging ass-core's native parsing and serialization capabilities.
5
6use crate::core::{EditorDocument, EditorError};
7use crate::formats::{
8    Format, FormatExporter, FormatImporter, FormatInfo, FormatOptions, FormatResult,
9};
10use ass_core::parser::Script;
11use std::io::{Read, Write};
12
13/// ASS format handler that reuses ass-core functionality
14#[derive(Debug)]
15pub struct AssFormat {
16    info: FormatInfo,
17}
18
19impl AssFormat {
20    /// Create a new ASS format handler
21    pub fn new() -> Self {
22        Self {
23            info: FormatInfo {
24                name: "ASS".to_string(),
25                extensions: vec!["ass".to_string()],
26                mime_type: "text/x-ass".to_string(),
27                description: "Advanced SubStation Alpha subtitle format".to_string(),
28                supports_styling: true,
29                supports_positioning: true,
30            },
31        }
32    }
33}
34
35impl Default for AssFormat {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl FormatImporter for AssFormat {
42    fn format_info(&self) -> &FormatInfo {
43        &self.info
44    }
45
46    fn import_from_reader(
47        &self,
48        reader: &mut dyn Read,
49        _options: &FormatOptions,
50    ) -> Result<(EditorDocument, FormatResult), EditorError> {
51        // Read the entire content
52        let mut content = String::new();
53        reader
54            .read_to_string(&mut content)
55            .map_err(|e| EditorError::IoError(format!("Failed to read content: {e}")))?;
56
57        // Validate that it's parseable by ass-core
58        let script = Script::parse(&content)?;
59
60        // Count lines for result metadata
61        let line_count = content.lines().count();
62
63        // Create EditorDocument from the content
64        let document = EditorDocument::from_content(&content)?;
65
66        // Gather metadata from the parsed script
67        let mut result = FormatResult::success(line_count);
68
69        // Add script info as metadata
70        if let Some(ass_core::parser::ast::Section::ScriptInfo(script_info)) =
71            script.find_section(ass_core::parser::ast::SectionType::ScriptInfo)
72        {
73            if let Some(title) = script_info.get_field("Title") {
74                result = result.with_metadata("title".to_string(), title.to_string());
75            }
76            if let Some(script_type) = script_info.get_field("ScriptType") {
77                result = result.with_metadata("script_type".to_string(), script_type.to_string());
78            }
79        }
80
81        // Count sections for additional metadata
82        let section_count = script.sections().len();
83
84        result = result.with_metadata("sections".to_string(), section_count.to_string());
85
86        Ok((document, result))
87    }
88}
89
90impl FormatExporter for AssFormat {
91    fn format_info(&self) -> &FormatInfo {
92        &self.info
93    }
94
95    fn export_to_writer(
96        &self,
97        document: &EditorDocument,
98        writer: &mut dyn Write,
99        options: &FormatOptions,
100    ) -> Result<FormatResult, EditorError> {
101        let content = if options.preserve_formatting {
102            // Use the raw content to preserve exact formatting
103            document.text()
104        } else {
105            // Use ass-core's serialization for normalized output
106            document.parse_script_with(|script| script.to_ass_string())?
107        };
108
109        // Count lines before using content
110        let line_count = content.lines().count();
111
112        // Write content with proper encoding
113        let bytes = if options.encoding.eq_ignore_ascii_case("UTF-8") {
114            content.into_bytes()
115        } else {
116            // For non-UTF-8 encodings, we'd need additional encoding support
117            // For now, default to UTF-8 with a warning
118            let mut warnings = Vec::new();
119            if !options.encoding.eq_ignore_ascii_case("UTF-8") {
120                warnings.push(format!(
121                    "Encoding '{}' not supported, using UTF-8 instead",
122                    options.encoding
123                ));
124            }
125
126            let result = FormatResult::success(line_count).with_warnings(warnings);
127
128            writer
129                .write_all(&content.into_bytes())
130                .map_err(|e| EditorError::IoError(format!("Failed to write content: {e}")))?;
131
132            return Ok(result);
133        };
134
135        writer
136            .write_all(&bytes)
137            .map_err(|e| EditorError::IoError(format!("Failed to write content: {e}")))?;
138
139        Ok(FormatResult::success(line_count))
140    }
141}
142
143impl Format for AssFormat {
144    fn as_importer(&self) -> &dyn FormatImporter {
145        self
146    }
147
148    fn as_exporter(&self) -> &dyn FormatExporter {
149        self
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    #[cfg(not(feature = "std"))]
157    use alloc::string::ToString;
158    #[cfg(not(feature = "std"))]
159    use alloc::{format, string::String, vec};
160    use std::io::Cursor;
161
162    const SAMPLE_ASS: &str = r#"[Script Info]
163Title: Test Script
164ScriptType: v4.00+
165
166[V4+ Styles]
167Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
168Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
169
170[Events]
171Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
172Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World!
173"#;
174
175    #[test]
176    fn test_ass_format_creation() {
177        let format = AssFormat::new();
178        let info = FormatImporter::format_info(&format);
179        assert_eq!(info.name, "ASS");
180        assert!(info.supports_styling);
181        assert!(info.supports_positioning);
182        assert!(format.can_import("ass"));
183        assert!(format.can_export("ass"));
184    }
185
186    #[test]
187    fn test_ass_import_from_string() {
188        let format = AssFormat::new();
189        let options = FormatOptions::default();
190
191        let result = format.import_from_string(SAMPLE_ASS, &options);
192        assert!(result.is_ok());
193
194        let (document, format_result) = result.unwrap();
195        assert!(format_result.success);
196        assert!(format_result.lines_processed > 0);
197        assert!(document.text().contains("Hello World!"));
198    }
199
200    #[test]
201    fn test_ass_export_to_string() {
202        let format = AssFormat::new();
203        let options = FormatOptions::default();
204
205        // First import
206        let (document, _) = format.import_from_string(SAMPLE_ASS, &options).unwrap();
207
208        // Then export
209        let result = format.export_to_string(&document, &options);
210        assert!(result.is_ok());
211
212        let (exported_content, format_result) = result.unwrap();
213        assert!(format_result.success);
214        assert!(exported_content.contains("Hello World!"));
215        assert!(exported_content.contains("[Script Info]"));
216    }
217
218    #[test]
219    fn test_ass_roundtrip() {
220        let format = AssFormat::new();
221        let options = FormatOptions::default();
222
223        // Import -> Export -> Import
224        let (document1, _) = format.import_from_string(SAMPLE_ASS, &options).unwrap();
225        let (exported_content, _) = format.export_to_string(&document1, &options).unwrap();
226        let (document2, _) = format
227            .import_from_string(&exported_content, &options)
228            .unwrap();
229
230        // Should be equivalent
231        assert_eq!(document1.text().trim(), document2.text().trim());
232    }
233
234    #[test]
235    fn test_ass_import_with_reader() {
236        let format = AssFormat::new();
237        let options = FormatOptions::default();
238        let mut cursor = Cursor::new(SAMPLE_ASS.as_bytes());
239
240        let result = format.import_from_reader(&mut cursor, &options);
241        assert!(result.is_ok());
242
243        let (document, format_result) = result.unwrap();
244        assert!(format_result.success);
245        assert!(document.text().contains("Test Script"));
246    }
247
248    #[test]
249    fn test_ass_export_with_writer() {
250        let format = AssFormat::new();
251        let options = FormatOptions::default();
252
253        let (document, _) = format.import_from_string(SAMPLE_ASS, &options).unwrap();
254
255        let mut buffer = Vec::new();
256        let result = format.export_to_writer(&document, &mut buffer, &options);
257        assert!(result.is_ok());
258
259        let exported_content = String::from_utf8(buffer).unwrap();
260        assert!(exported_content.contains("Hello World!"));
261    }
262
263    #[test]
264    fn test_ass_export_preserve_formatting() {
265        let format = AssFormat::new();
266        let options = FormatOptions {
267            preserve_formatting: true,
268            ..FormatOptions::default()
269        };
270
271        let (document, _) = format.import_from_string(SAMPLE_ASS, &options).unwrap();
272        let (exported_content, _) = format.export_to_string(&document, &options).unwrap();
273
274        // Should preserve original formatting
275        assert_eq!(exported_content.trim(), SAMPLE_ASS.trim());
276    }
277
278    #[test]
279    fn test_ass_export_normalized() {
280        let format = AssFormat::new();
281        let options = FormatOptions {
282            preserve_formatting: false,
283            ..FormatOptions::default()
284        };
285
286        // Import with some non-standard formatting
287        let messy_ass = r#"[Script Info]
288
289Title: Test Script
290ScriptType: v4.00+
291
292
293[V4+ Styles]
294Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
295Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
296
297[Events]
298Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
299Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World!
300"#;
301
302        let (document, _) = format.import_from_string(messy_ass, &options).unwrap();
303        let (exported_content, _) = format.export_to_string(&document, &options).unwrap();
304
305        // Should be normalized (no extra blank lines)
306        assert!(exported_content.contains("[Script Info]\nTitle: Test Script"));
307        assert!(!exported_content.contains("\n\n\n")); // No triple newlines
308        assert!(exported_content.contains("Hello World!"));
309    }
310
311    #[test]
312    fn test_ass_metadata_extraction() {
313        let format = AssFormat::new();
314        let options = FormatOptions::default();
315
316        let (_, format_result) = format.import_from_string(SAMPLE_ASS, &options).unwrap();
317
318        assert_eq!(
319            format_result.metadata.get("title"),
320            Some(&"Test Script".to_string())
321        );
322        assert_eq!(
323            format_result.metadata.get("script_type"),
324            Some(&"v4.00+".to_string())
325        );
326        assert!(format_result.metadata.contains_key("sections"));
327    }
328
329    #[test]
330    fn test_ass_export_normalized_format_lines() {
331        let format = AssFormat::new();
332        let options = FormatOptions {
333            preserve_formatting: false,
334            ..FormatOptions::default()
335        };
336
337        // ASS without format lines - Script::to_ass_string() should add default ones
338        let minimal_ass = r#"[Script Info]
339Title: Test Script
340
341[V4+ Styles]
342Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
343
344[Events]
345Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World!
346"#;
347
348        let (document, _) = format.import_from_string(minimal_ass, &options).unwrap();
349        let (exported_content, _) = format.export_to_string(&document, &options).unwrap();
350
351        // The normalized output should NOT include format lines if the parser
352        // didn't preserve them (ass-core's default behavior)
353        assert!(exported_content.contains("[V4+ Styles]\n"));
354        assert!(exported_content.contains("Style: Default,Arial,20"));
355        assert!(exported_content.contains("[Events]\n"));
356        assert!(exported_content.contains("Dialogue: 0,0:00:00.00,0:00:05.00"));
357    }
358}