ass_editor/utils/
formats.rs

1//! Format conversion utilities for importing/exporting subtitles
2//!
3//! Provides conversion between ASS and other subtitle formats like SRT and WebVTT.
4//! Supports both import and export operations with format auto-detection.
5
6use crate::core::errors::EditorError;
7use crate::core::{EditorDocument, Result};
8use ass_core::parser::ast::EventType;
9
10#[cfg(not(feature = "std"))]
11use alloc::{
12    format,
13    string::{String, ToString},
14    vec::Vec,
15};
16
17/// Supported subtitle formats
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SubtitleFormat {
20    /// Advanced SubStation Alpha (.ass)
21    ASS,
22    /// SubStation Alpha (.ssa)
23    SSA,
24    /// SubRip Text (.srt)
25    SRT,
26    /// WebVTT (.vtt)
27    WebVTT,
28    /// Plain text
29    PlainText,
30}
31
32impl SubtitleFormat {
33    /// Detect format from file extension
34    pub fn from_extension(ext: &str) -> Option<Self> {
35        match ext.to_lowercase().as_str() {
36            "ass" => Some(Self::ASS),
37            "ssa" => Some(Self::SSA),
38            "srt" => Some(Self::SRT),
39            "vtt" | "webvtt" => Some(Self::WebVTT),
40            "txt" => Some(Self::PlainText),
41            _ => None,
42        }
43    }
44
45    /// Detect format from content
46    pub fn from_content(content: &str) -> Self {
47        if content.contains("[Script Info]") || content.contains("[Events]") {
48            Self::ASS
49        } else if content.starts_with("WEBVTT") {
50            Self::WebVTT
51        } else if content.contains("-->") && !content.starts_with("WEBVTT") {
52            Self::SRT
53        } else {
54            Self::PlainText
55        }
56    }
57
58    /// Get the standard file extension for this format
59    pub const fn extension(&self) -> &'static str {
60        match self {
61            Self::ASS => "ass",
62            Self::SSA => "ssa",
63            Self::SRT => "srt",
64            Self::WebVTT => "vtt",
65            Self::PlainText => "txt",
66        }
67    }
68}
69
70/// Options for format conversion
71#[derive(Debug, Clone)]
72pub struct ConversionOptions {
73    /// Preserve styling information when possible
74    pub preserve_styling: bool,
75
76    /// Preserve positioning information when possible
77    pub preserve_positioning: bool,
78
79    /// Convert karaoke timing to inline format
80    pub inline_karaoke: bool,
81
82    /// Strip all formatting tags
83    pub strip_formatting: bool,
84
85    /// Target format-specific options
86    pub format_options: FormatOptions,
87}
88
89impl Default for ConversionOptions {
90    fn default() -> Self {
91        Self {
92            preserve_styling: true,
93            preserve_positioning: true,
94            inline_karaoke: false,
95            strip_formatting: false,
96            format_options: FormatOptions::default(),
97        }
98    }
99}
100
101/// Format-specific conversion options
102#[derive(Debug, Clone)]
103pub enum FormatOptions {
104    /// No format-specific options
105    None,
106
107    /// SRT-specific options
108    SRT {
109        /// Include sequential numbering
110        include_numbers: bool,
111        /// Use millisecond precision (3 digits)
112        millisecond_precision: bool,
113    },
114
115    /// WebVTT-specific options
116    WebVTT {
117        /// Include STYLE block for CSS
118        include_style_block: bool,
119        /// Include NOTE comments
120        include_notes: bool,
121        /// Use cue settings for positioning
122        use_cue_settings: bool,
123    },
124}
125
126impl Default for FormatOptions {
127    fn default() -> Self {
128        Self::None
129    }
130}
131
132/// Format converter for subtitle import/export
133pub struct FormatConverter;
134
135impl FormatConverter {
136    /// Import subtitle content from various formats into ASS
137    pub fn import(content: &str, format: Option<SubtitleFormat>) -> Result<String> {
138        let detected_format = format.unwrap_or_else(|| SubtitleFormat::from_content(content));
139
140        match detected_format {
141            SubtitleFormat::ASS | SubtitleFormat::SSA => {
142                // Already in ASS/SSA format, just return
143                Ok(content.to_string())
144            }
145            SubtitleFormat::SRT => Self::import_srt(content),
146            SubtitleFormat::WebVTT => Self::import_webvtt(content),
147            SubtitleFormat::PlainText => Self::import_plain_text(content),
148        }
149    }
150
151    /// Export ASS content to another subtitle format
152    pub fn export(
153        document: &EditorDocument,
154        format: SubtitleFormat,
155        options: &ConversionOptions,
156    ) -> Result<String> {
157        match format {
158            SubtitleFormat::ASS => Ok(document.text()),
159            SubtitleFormat::SSA => Self::export_ssa(document, options),
160            SubtitleFormat::SRT => Self::export_srt(document, options),
161            SubtitleFormat::WebVTT => Self::export_webvtt(document, options),
162            SubtitleFormat::PlainText => Self::export_plain_text(document, options),
163        }
164    }
165
166    /// Import SRT format
167    fn import_srt(content: &str) -> Result<String> {
168        let mut output = String::new();
169
170        // Add ASS header
171        output.push_str("[Script Info]\n");
172        output.push_str("Title: Imported from SRT\n");
173        output.push_str("ScriptType: v4.00+\n");
174        output.push_str("WrapStyle: 0\n");
175        output.push_str("PlayResX: 640\n");
176        output.push_str("PlayResY: 480\n");
177        output.push_str("ScaledBorderAndShadow: yes\n\n");
178
179        // Add default style
180        output.push_str("[V4+ Styles]\n");
181        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");
182        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");
183
184        // Add events section
185        output.push_str("[Events]\n");
186        output.push_str(
187            "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
188        );
189
190        // Parse SRT entries
191        let entries = Self::parse_srt_entries(content)?;
192        for entry in entries {
193            output.push_str(&format!(
194                "Dialogue: 0,{},{},Default,,0,0,0,,{}\n",
195                entry.start, entry.end, entry.text
196            ));
197        }
198
199        Ok(output)
200    }
201
202    /// Parse SRT entries
203    fn parse_srt_entries(content: &str) -> Result<Vec<SrtEntry>> {
204        let mut entries = Vec::new();
205        let mut current_entry: Option<SrtEntry> = None;
206        let mut in_text = false;
207
208        for line in content.lines() {
209            let line = line.trim();
210
211            if line.is_empty() {
212                if let Some(entry) = current_entry.take() {
213                    entries.push(entry);
214                }
215                in_text = false;
216                continue;
217            }
218
219            // Check if it's a number (subtitle index)
220            if line.chars().all(|c| c.is_ascii_digit()) && !in_text {
221                // Start new entry
222                current_entry = Some(SrtEntry::default());
223                continue;
224            }
225
226            // Check if it's a timestamp line
227            if line.contains("-->") {
228                if let Some(ref mut entry) = current_entry {
229                    let parts: Vec<&str> = line.split("-->").collect();
230                    if parts.len() == 2 {
231                        entry.start = Self::parse_srt_time(parts[0].trim())?;
232                        entry.end = Self::parse_srt_time(parts[1].trim())?;
233                        in_text = true;
234                    }
235                }
236                continue;
237            }
238
239            // Otherwise it's subtitle text
240            if in_text {
241                if let Some(ref mut entry) = current_entry {
242                    if !entry.text.is_empty() {
243                        entry.text.push_str("\\N");
244                    }
245                    // Convert basic HTML-like tags to ASS
246                    let converted_text = Self::convert_srt_formatting(line);
247                    entry.text.push_str(&converted_text);
248                }
249            }
250        }
251
252        // Don't forget the last entry
253        if let Some(entry) = current_entry {
254            entries.push(entry);
255        }
256
257        Ok(entries)
258    }
259
260    /// Parse SRT timestamp to ASS format
261    fn parse_srt_time(time: &str) -> Result<String> {
262        // SRT format: 00:00:00,000
263        // ASS format: 0:00:00.00
264
265        let time = time.replace(',', ".");
266        let parts: Vec<&str> = time.split(':').collect();
267
268        if parts.len() != 3 {
269            return Err(EditorError::ValidationError {
270                message: format!("Invalid SRT timestamp: {time}"),
271            });
272        }
273
274        let hours: u32 = parts[0].parse().map_err(|_| EditorError::ValidationError {
275            message: format!("Invalid hours in timestamp: {}", parts[0]),
276        })?;
277
278        let minutes: u32 = parts[1].parse().map_err(|_| EditorError::ValidationError {
279            message: format!("Invalid minutes in timestamp: {}", parts[1]),
280        })?;
281
282        let seconds_parts: Vec<&str> = parts[2].split('.').collect();
283        let seconds: u32 = seconds_parts[0]
284            .parse()
285            .map_err(|_| EditorError::ValidationError {
286                message: format!("Invalid seconds in timestamp: {}", seconds_parts[0]),
287            })?;
288
289        let centiseconds = if seconds_parts.len() > 1 {
290            // Convert milliseconds to centiseconds
291            let millis: u32 = seconds_parts[1].parse().unwrap_or(0);
292            millis / 10
293        } else {
294            0
295        };
296
297        Ok(format!(
298            "{hours}:{minutes:02}:{seconds:02}.{centiseconds:02}"
299        ))
300    }
301
302    /// Convert SRT formatting to ASS
303    fn convert_srt_formatting(text: &str) -> String {
304        let mut result = text.to_string();
305
306        // Convert basic HTML-like tags
307        result = result.replace("<i>", "{\\i1}");
308        result = result.replace("</i>", "{\\i0}");
309        result = result.replace("<b>", "{\\b1}");
310        result = result.replace("</b>", "{\\b0}");
311        result = result.replace("<u>", "{\\u1}");
312        result = result.replace("</u>", "{\\u0}");
313
314        // Remove any other HTML tags
315        #[cfg(feature = "formats")]
316        {
317            result = regex::Regex::new(r"<[^>]+>")
318                .unwrap()
319                .replace_all(&result, "")
320                .to_string();
321        }
322
323        result
324    }
325
326    /// Import WebVTT format
327    fn import_webvtt(content: &str) -> Result<String> {
328        let mut output = String::new();
329
330        // Add ASS header
331        output.push_str("[Script Info]\n");
332        output.push_str("Title: Imported from WebVTT\n");
333        output.push_str("ScriptType: v4.00+\n");
334        output.push_str("WrapStyle: 0\n");
335        output.push_str("PlayResX: 640\n");
336        output.push_str("PlayResY: 480\n");
337        output.push_str("ScaledBorderAndShadow: yes\n\n");
338
339        // Add default style
340        output.push_str("[V4+ Styles]\n");
341        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");
342        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");
343
344        // Add events section
345        output.push_str("[Events]\n");
346        output.push_str(
347            "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
348        );
349
350        // Parse WebVTT cues
351        let cues = Self::parse_webvtt_cues(content)?;
352        for cue in cues {
353            output.push_str(&format!(
354                "Dialogue: 0,{},{},Default,,0,0,0,,{}\n",
355                cue.start, cue.end, cue.text
356            ));
357        }
358
359        Ok(output)
360    }
361
362    /// Parse WebVTT cues
363    fn parse_webvtt_cues(content: &str) -> Result<Vec<WebVttCue>> {
364        let mut cues = Vec::new();
365        let mut current_cue: Option<WebVttCue> = None;
366        let mut in_cue = false;
367
368        for line in content.lines() {
369            let line = line.trim();
370
371            // Skip WEBVTT header and empty lines
372            if line.starts_with("WEBVTT") || line.starts_with("NOTE") || line.is_empty() {
373                if let Some(cue) = current_cue.take() {
374                    cues.push(cue);
375                }
376                in_cue = false;
377                continue;
378            }
379
380            // Check if it's a timestamp line
381            if line.contains("-->") {
382                current_cue = Some(WebVttCue::default());
383                if let Some(ref mut cue) = current_cue {
384                    let parts: Vec<&str> = line.split("-->").collect();
385                    if parts.len() >= 2 {
386                        cue.start = Self::parse_webvtt_time(parts[0].trim())?;
387                        cue.end = Self::parse_webvtt_time(parts[1].trim())?;
388                        in_cue = true;
389                    }
390                }
391                continue;
392            }
393
394            // Otherwise it's cue text
395            if in_cue {
396                if let Some(ref mut cue) = current_cue {
397                    if !cue.text.is_empty() {
398                        cue.text.push_str("\\N");
399                    }
400                    let converted_text = Self::convert_webvtt_formatting(line);
401                    cue.text.push_str(&converted_text);
402                }
403            }
404        }
405
406        // Don't forget the last cue
407        if let Some(cue) = current_cue {
408            cues.push(cue);
409        }
410
411        Ok(cues)
412    }
413
414    /// Parse WebVTT timestamp to ASS format
415    fn parse_webvtt_time(time: &str) -> Result<String> {
416        // WebVTT format: 00:00:00.000 or 00:00.000
417        // ASS format: 0:00:00.00
418
419        let parts: Vec<&str> = time.split(':').collect();
420
421        let (hours, minutes, seconds_str) = if parts.len() == 3 {
422            // HH:MM:SS.mmm
423            (parts[0].parse::<u32>().unwrap_or(0), parts[1], parts[2])
424        } else if parts.len() == 2 {
425            // MM:SS.mmm
426            (0, parts[0], parts[1])
427        } else {
428            return Err(EditorError::ValidationError {
429                message: format!("Invalid WebVTT timestamp: {time}"),
430            });
431        };
432
433        let minutes: u32 = minutes.parse().map_err(|_| EditorError::ValidationError {
434            message: format!("Invalid minutes in timestamp: {minutes}"),
435        })?;
436
437        let seconds_parts: Vec<&str> = seconds_str.split('.').collect();
438        let seconds: u32 = seconds_parts[0]
439            .parse()
440            .map_err(|_| EditorError::ValidationError {
441                message: format!("Invalid seconds in timestamp: {}", seconds_parts[0]),
442            })?;
443
444        let centiseconds = if seconds_parts.len() > 1 {
445            // Convert milliseconds to centiseconds
446            let millis: u32 = seconds_parts[1].parse().unwrap_or(0);
447            millis / 10
448        } else {
449            0
450        };
451
452        Ok(format!(
453            "{hours}:{minutes:02}:{seconds:02}.{centiseconds:02}"
454        ))
455    }
456
457    /// Convert WebVTT formatting to ASS
458    fn convert_webvtt_formatting(text: &str) -> String {
459        let mut result = text.to_string();
460
461        // Convert WebVTT tags
462        result = result.replace("<i>", "{\\i1}");
463        result = result.replace("</i>", "{\\i0}");
464        result = result.replace("<b>", "{\\b1}");
465        result = result.replace("</b>", "{\\b0}");
466        result = result.replace("<u>", "{\\u1}");
467        result = result.replace("</u>", "{\\u0}");
468
469        // Convert voice spans
470        result = regex::Regex::new(r"<v\s+([^>]+)>")
471            .unwrap()
472            .replace_all(&result, "")
473            .to_string();
474        result = result.replace("</v>", "");
475
476        // Remove any other tags
477        result = regex::Regex::new(r"<[^>]+>")
478            .unwrap()
479            .replace_all(&result, "")
480            .to_string();
481
482        result
483    }
484
485    /// Import plain text
486    fn import_plain_text(content: &str) -> Result<String> {
487        let mut output = String::new();
488
489        // Add minimal ASS header
490        output.push_str("[Script Info]\n");
491        output.push_str("Title: Imported from Plain Text\n");
492        output.push_str("ScriptType: v4.00+\n\n");
493
494        output.push_str("[V4+ Styles]\n");
495        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");
496        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");
497
498        output.push_str("[Events]\n");
499        output.push_str(
500            "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
501        );
502
503        // Create a single dialogue line with all text
504        let text = content.lines().collect::<Vec<_>>().join("\\N");
505        output.push_str(&format!(
506            "Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{text}\n"
507        ));
508
509        Ok(output)
510    }
511
512    /// Export to SSA format
513    fn export_ssa(document: &EditorDocument, _options: &ConversionOptions) -> Result<String> {
514        // SSA is very similar to ASS, just with slightly different headers
515        let content = document.text();
516        let mut output = content.replace("[V4+ Styles]", "[V4 Styles]");
517        output = output.replace("ScriptType: v4.00+", "ScriptType: v4.00");
518        Ok(output)
519    }
520
521    /// Export to SRT format
522    fn export_srt(document: &EditorDocument, options: &ConversionOptions) -> Result<String> {
523        let mut output = String::new();
524        let mut index = 1;
525
526        document.parse_script_with(|script| {
527            for section in script.sections() {
528                if let ass_core::parser::ast::Section::Events(events) = section {
529                    for event in events {
530                        if event.event_type == EventType::Dialogue {
531                            // Add index
532                            output.push_str(&format!("{index}\n"));
533                            index += 1;
534
535                            // Add timestamps
536                            let start = Self::ass_time_to_srt(event.start);
537                            let end = Self::ass_time_to_srt(event.end);
538                            output.push_str(&format!("{start} --> {end}\n"));
539
540                            // Add text
541                            let text = if options.strip_formatting {
542                                Self::strip_ass_tags(event.text)
543                            } else {
544                                Self::convert_ass_to_srt_formatting(event.text)
545                            };
546                            output.push_str(&text.replace("\\N", "\n"));
547                            output.push_str("\n\n");
548                        }
549                    }
550                }
551            }
552        })?;
553
554        Ok(output)
555    }
556
557    /// Convert ASS time to SRT format
558    fn ass_time_to_srt(time: &str) -> String {
559        // ASS format: 0:00:00.00
560        // SRT format: 00:00:00,000
561
562        let parts: Vec<&str> = time.split(':').collect();
563        if parts.len() != 3 {
564            return time.to_string();
565        }
566
567        let hours = format!("{:02}", parts[0].parse::<u32>().unwrap_or(0));
568        let minutes = parts[1];
569
570        let seconds_parts: Vec<&str> = parts[2].split('.').collect();
571        let seconds = seconds_parts[0];
572        let centiseconds = seconds_parts.get(1).unwrap_or(&"00");
573
574        // Convert centiseconds to milliseconds
575        let millis = centiseconds.parse::<u32>().unwrap_or(0) * 10;
576
577        format!("{hours}:{minutes}:{seconds},{millis:03}")
578    }
579
580    /// Convert ASS formatting to SRT
581    fn convert_ass_to_srt_formatting(text: &str) -> String {
582        let mut result = text.to_string();
583
584        // Convert basic formatting
585        result = result.replace("{\\i1}", "<i>");
586        result = result.replace("{\\i0}", "</i>");
587        result = result.replace("{\\b1}", "<b>");
588        result = result.replace("{\\b0}", "</b>");
589        result = result.replace("{\\u1}", "<u>");
590        result = result.replace("{\\u0}", "</u>");
591
592        // Remove all other ASS tags
593        while let Some(start) = result.find('{') {
594            if let Some(end) = result[start..].find('}') {
595                result.replace_range(start..start + end + 1, "");
596            } else {
597                break;
598            }
599        }
600
601        result
602    }
603
604    /// Strip all ASS tags from text
605    fn strip_ass_tags(text: &str) -> String {
606        let mut result = text.to_string();
607        while let Some(start) = result.find('{') {
608            if let Some(end) = result[start..].find('}') {
609                result.replace_range(start..start + end + 1, "");
610            } else {
611                break;
612            }
613        }
614        result
615    }
616
617    /// Export to WebVTT format
618    fn export_webvtt(document: &EditorDocument, options: &ConversionOptions) -> Result<String> {
619        let mut output = String::new();
620
621        // Add WebVTT header
622        output.push_str("WEBVTT\n\n");
623
624        // Add style block if requested
625        if let FormatOptions::WebVTT {
626            include_style_block: true,
627            ..
628        } = &options.format_options
629        {
630            output.push_str("STYLE\n");
631            output.push_str("::cue {\n");
632            output
633                .push_str("  background-image: linear-gradient(to bottom, dimgray, lightgray);\n");
634            output.push_str("  color: papayawhip;\n");
635            output.push_str("}\n\n");
636        }
637
638        document.parse_script_with(|script| {
639            for section in script.sections() {
640                if let ass_core::parser::ast::Section::Events(events) = section {
641                    for event in events {
642                        if event.event_type == EventType::Dialogue {
643                            // Add timestamps
644                            let start = Self::ass_time_to_webvtt(event.start);
645                            let end = Self::ass_time_to_webvtt(event.end);
646                            output.push_str(&format!("{start} --> {end}"));
647
648                            // Add cue settings if requested
649                            if let FormatOptions::WebVTT {
650                                use_cue_settings: true,
651                                ..
652                            } = &options.format_options
653                            {
654                                // Parse margins as integers for positioning
655                                let margin_v: i32 = event.margin_v.parse().unwrap_or(0);
656                                if margin_v != 0 {
657                                    output.push_str(&format!(" line:{}", 100 - margin_v));
658                                }
659                            }
660
661                            output.push('\n');
662
663                            // Add text
664                            let text = if options.strip_formatting {
665                                Self::strip_ass_tags(event.text)
666                            } else {
667                                Self::convert_ass_to_webvtt_formatting(event.text)
668                            };
669                            output.push_str(&text.replace("\\N", "\n"));
670                            output.push_str("\n\n");
671                        }
672                    }
673                }
674            }
675        })?;
676
677        Ok(output)
678    }
679
680    /// Convert ASS time to WebVTT format
681    fn ass_time_to_webvtt(time: &str) -> String {
682        // ASS format: 0:00:00.00
683        // WebVTT format: 00:00:00.000
684
685        let parts: Vec<&str> = time.split(':').collect();
686        if parts.len() != 3 {
687            return time.to_string();
688        }
689
690        let hours = format!("{:02}", parts[0].parse::<u32>().unwrap_or(0));
691        let minutes = parts[1];
692
693        let seconds_parts: Vec<&str> = parts[2].split('.').collect();
694        let seconds = seconds_parts[0];
695        let centiseconds = seconds_parts.get(1).unwrap_or(&"00");
696
697        // Convert centiseconds to milliseconds
698        let millis = centiseconds.parse::<u32>().unwrap_or(0) * 10;
699
700        format!("{hours}:{minutes}:{seconds}.{millis:03}")
701    }
702
703    /// Convert ASS formatting to WebVTT
704    fn convert_ass_to_webvtt_formatting(text: &str) -> String {
705        let mut result = text.to_string();
706
707        // Convert basic formatting
708        result = result.replace("{\\i1}", "<i>");
709        result = result.replace("{\\i0}", "</i>");
710        result = result.replace("{\\b1}", "<b>");
711        result = result.replace("{\\b0}", "</b>");
712        result = result.replace("{\\u1}", "<u>");
713        result = result.replace("{\\u0}", "</u>");
714
715        // Remove all other ASS tags
716        while let Some(start) = result.find('{') {
717            if let Some(end) = result[start..].find('}') {
718                result.replace_range(start..start + end + 1, "");
719            } else {
720                break;
721            }
722        }
723
724        result
725    }
726
727    /// Export to plain text
728    fn export_plain_text(document: &EditorDocument, options: &ConversionOptions) -> Result<String> {
729        let mut output = String::new();
730
731        document.parse_script_with(|script| {
732            for section in script.sections() {
733                if let ass_core::parser::ast::Section::Events(events) = section {
734                    for event in events {
735                        if event.event_type == EventType::Dialogue {
736                            let text = if options.strip_formatting {
737                                Self::strip_ass_tags(event.text)
738                            } else {
739                                event.text.to_string()
740                            };
741                            output.push_str(&text.replace("\\N", "\n"));
742                            output.push('\n');
743                        }
744                    }
745                }
746            }
747        })?;
748
749        Ok(output)
750    }
751}
752
753/// Helper struct for SRT entries
754#[derive(Default)]
755struct SrtEntry {
756    start: String,
757    end: String,
758    text: String,
759}
760
761/// Helper struct for WebVTT cues
762#[derive(Default)]
763struct WebVttCue {
764    start: String,
765    end: String,
766    text: String,
767}
768
769/// Import content from a file path
770#[cfg(feature = "std")]
771pub fn import_from_file(path: &str) -> Result<EditorDocument> {
772    use std::fs;
773
774    let content = fs::read_to_string(path).map_err(|e| EditorError::IoError(e.to_string()))?;
775
776    let format = path
777        .rfind('.')
778        .and_then(|pos| SubtitleFormat::from_extension(&path[pos + 1..]));
779
780    let ass_content = FormatConverter::import(&content, format)?;
781    EditorDocument::from_content(&ass_content)
782}
783
784/// Export document to a file
785#[cfg(feature = "std")]
786pub fn export_to_file(
787    document: &EditorDocument,
788    path: &str,
789    format: Option<SubtitleFormat>,
790    options: &ConversionOptions,
791) -> Result<()> {
792    use std::fs;
793
794    let detected_format = format
795        .or_else(|| {
796            path.rfind('.')
797                .and_then(|pos| SubtitleFormat::from_extension(&path[pos + 1..]))
798        })
799        .unwrap_or(SubtitleFormat::ASS);
800
801    let content = FormatConverter::export(document, detected_format, options)?;
802
803    fs::write(path, content).map_err(|e| EditorError::IoError(e.to_string()))?;
804
805    Ok(())
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    #[cfg(not(feature = "std"))]
812    use alloc::string::ToString;
813    #[cfg(not(feature = "std"))]
814    use alloc::{format, string::String};
815
816    #[test]
817    fn test_format_detection() {
818        assert_eq!(
819            SubtitleFormat::from_extension("ass"),
820            Some(SubtitleFormat::ASS)
821        );
822        assert_eq!(
823            SubtitleFormat::from_extension("srt"),
824            Some(SubtitleFormat::SRT)
825        );
826        assert_eq!(
827            SubtitleFormat::from_extension("vtt"),
828            Some(SubtitleFormat::WebVTT)
829        );
830        assert_eq!(SubtitleFormat::from_extension("unknown"), None);
831
832        assert_eq!(
833            SubtitleFormat::from_content("[Script Info]\nTitle: Test"),
834            SubtitleFormat::ASS
835        );
836        assert_eq!(
837            SubtitleFormat::from_content("WEBVTT\n\n00:00.000 --> 00:05.000"),
838            SubtitleFormat::WebVTT
839        );
840        assert_eq!(
841            SubtitleFormat::from_content("1\n00:00:00,000 --> 00:00:05,000\nHello"),
842            SubtitleFormat::SRT
843        );
844    }
845
846    #[test]
847    fn test_srt_import() {
848        let srt_content = r#"1
84900:00:00,000 --> 00:00:05,000
850Hello <i>world</i>!
851
8522
85300:00:05,000 --> 00:00:10,000
854This is a <b>test</b>."#;
855
856        let result = FormatConverter::import(srt_content, Some(SubtitleFormat::SRT)).unwrap();
857
858        assert!(result.contains("[Script Info]"));
859        assert!(result.contains("[Events]"));
860        assert!(result.contains("Hello {\\i1}world{\\i0}!"));
861        assert!(result.contains("This is a {\\b1}test{\\b0}."));
862    }
863
864    #[test]
865    fn test_webvtt_import() {
866        let webvtt_content = r#"WEBVTT
867
86800:00:00.000 --> 00:00:05.000
869Hello <i>world</i>!
870
87100:00:05.000 --> 00:00:10.000
872This is a test."#;
873
874        let result = FormatConverter::import(webvtt_content, Some(SubtitleFormat::WebVTT)).unwrap();
875
876        assert!(result.contains("[Script Info]"));
877        assert!(result.contains("[Events]"));
878        assert!(result.contains("Hello {\\i1}world{\\i0}!"));
879    }
880
881    #[test]
882    fn test_export_srt() {
883        let doc = EditorDocument::from_content(
884            r#"[Script Info]
885Title: Test
886
887[V4+ Styles]
888Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
889Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1
890
891[Events]
892Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
893Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello {\i1}world{\i0}!
894Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Test line\NSecond line"#
895        ).unwrap();
896
897        let options = ConversionOptions::default();
898        let result = FormatConverter::export(&doc, SubtitleFormat::SRT, &options).unwrap();
899
900        assert!(result.contains("1\n00:00:00,000 --> 00:00:05,000"));
901        assert!(result.contains("Hello <i>world</i>!"));
902        assert!(result.contains("Test line\nSecond line"));
903    }
904
905    #[test]
906    fn test_export_webvtt() {
907        let doc = EditorDocument::from_content(
908            r#"[Script Info]
909Title: Test
910
911[Events]
912Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
913Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello world!"#,
914        )
915        .unwrap();
916
917        let options = ConversionOptions::default();
918        let result = FormatConverter::export(&doc, SubtitleFormat::WebVTT, &options).unwrap();
919
920        assert!(result.starts_with("WEBVTT"));
921        assert!(result.contains("00:00:00.000 --> 00:00:05.000"));
922        assert!(result.contains("Hello world!"));
923    }
924
925    #[test]
926    fn test_strip_formatting() {
927        let doc = EditorDocument::from_content(
928            r#"[Events]
929Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
930Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{\i1}Hello{\i0} {\b1}world{\b0}!"#,
931        )
932        .unwrap();
933
934        let options = ConversionOptions {
935            strip_formatting: true,
936            ..Default::default()
937        };
938
939        let result = FormatConverter::export(&doc, SubtitleFormat::SRT, &options).unwrap();
940        assert!(result.contains("Hello world!"));
941        assert!(!result.contains("<i>"));
942        assert!(!result.contains("<b>"));
943    }
944}