ass_editor/formats/webvtt/
mod.rs

1//! WebVTT format support with style preservation.
2//!
3//! This module provides import/export functionality for WebVTT files,
4//! with comprehensive style preservation and positioning support.
5
6use crate::core::{EditorDocument, EditorError};
7use crate::formats::{
8    Format, FormatExporter, FormatImporter, FormatInfo, FormatOptions, FormatResult,
9};
10use ass_core::parser::Script;
11use std::collections::HashMap;
12use std::io::{Read, Write};
13
14/// WebVTT format handler with style and positioning preservation
15#[derive(Debug)]
16pub struct WebVttFormat {
17    info: FormatInfo,
18}
19
20impl WebVttFormat {
21    /// Create a new WebVTT format handler
22    pub fn new() -> Self {
23        Self {
24            info: FormatInfo {
25                name: "WebVTT".to_string(),
26                extensions: vec!["vtt".to_string(), "webvtt".to_string()],
27                mime_type: "text/vtt".to_string(),
28                description: "WebVTT subtitle format with full style and positioning preservation"
29                    .to_string(),
30                supports_styling: true,
31                supports_positioning: true,
32            },
33        }
34    }
35
36    /// Parse WebVTT timestamp (HH:MM:SS.mmm or MM:SS.mmm)
37    fn parse_vtt_time(time_str: &str) -> Result<String, EditorError> {
38        let time_str = time_str.trim();
39
40        // WebVTT supports both HH:MM:SS.mmm and MM:SS.mmm formats
41        let parts: Vec<&str> = time_str.split('.').collect();
42        if parts.len() != 2 {
43            return Err(EditorError::InvalidFormat(format!(
44                "Invalid WebVTT time format: {time_str}"
45            )));
46        }
47
48        let time_part = parts[0];
49        let ms_part = parts[1];
50
51        // Parse milliseconds and convert to centiseconds
52        let ms: u32 = ms_part
53            .parse()
54            .map_err(|_| EditorError::InvalidFormat(format!("Invalid milliseconds: {ms_part}")))?;
55        let cs = ms / 10; // Convert to centiseconds
56
57        // Handle both MM:SS and HH:MM:SS formats
58        let time_components: Vec<&str> = time_part.split(':').collect();
59        let ass_time = match time_components.len() {
60            2 => {
61                // MM:SS format - prepend 0 hours
62                format!("0:{time_part}.{cs:02}")
63            }
64            3 => {
65                // HH:MM:SS format - remove leading zero from hours if present
66                let hours = time_components[0];
67                let hours = if hours.starts_with('0') && hours.len() > 1 {
68                    &hours[1..]
69                } else {
70                    hours
71                };
72                format!(
73                    "{hours}:{}:{}.{cs:02}",
74                    time_components[1], time_components[2]
75                )
76            }
77            _ => {
78                return Err(EditorError::InvalidFormat(format!(
79                    "Invalid WebVTT time format: {time_str}"
80                )));
81            }
82        };
83
84        Ok(ass_time)
85    }
86
87    /// Convert ASS timestamp to WebVTT format
88    fn format_vtt_time(ass_time: &str) -> Result<String, EditorError> {
89        let ass_time = ass_time.trim();
90
91        // Convert ASS time format (H:MM:SS.cc) to WebVTT format (HH:MM:SS.mmm)
92        if let Some(dot_pos) = ass_time.find('.') {
93            let (time_part, cs_part) = ass_time.split_at(dot_pos);
94            let cs_part = &cs_part[1..]; // Remove dot
95
96            // Parse centiseconds and convert to milliseconds
97            let cs: u32 = cs_part.parse().map_err(|_| {
98                EditorError::InvalidFormat(format!("Invalid centiseconds: {cs_part}"))
99            })?;
100            let ms = cs * 10; // Convert to milliseconds
101
102            // Ensure hours are zero-padded for WebVTT format
103            let parts: Vec<&str> = time_part.split(':').collect();
104            if parts.len() == 3 {
105                let hours: u32 = parts[0].parse().map_err(|_| {
106                    EditorError::InvalidFormat(format!("Invalid hours: {}", parts[0]))
107                })?;
108                Ok(format!("{hours:02}:{}:{}.{ms:03}", parts[1], parts[2]))
109            } else {
110                Err(EditorError::InvalidFormat(format!(
111                    "Invalid ASS time format: {ass_time}"
112                )))
113            }
114        } else {
115            Err(EditorError::InvalidFormat(format!(
116                "Invalid ASS time format: {ass_time}"
117            )))
118        }
119    }
120
121    /// Convert WebVTT styling to ASS override tags
122    fn convert_vtt_to_ass_styling(text: &str) -> String {
123        let mut result = text.to_string();
124
125        // Convert WebVTT tags to ASS override tags
126        result = result.replace("<b>", r"{\b1}");
127        result = result.replace("</b>", r"{\b0}");
128        result = result.replace("<i>", r"{\i1}");
129        result = result.replace("</i>", r"{\i0}");
130        result = result.replace("<u>", r"{\u1}");
131        result = result.replace("</u>", r"{\u0}");
132
133        // Handle WebVTT class-based styling
134        #[cfg(feature = "formats")]
135        {
136            let class_regex = regex::Regex::new(r#"<c\.([^>]+)>([^<]*)</c>"#).unwrap();
137            result = class_regex
138                .replace_all(&result, r"{\c&H$1&}$2{\c}")
139                .to_string();
140
141            // Handle voice tags
142            let voice_regex = regex::Regex::new(r#"<v\s+([^>]+)>([^<]*)</v>"#).unwrap();
143            result = voice_regex
144                .replace_all(&result, r"{\fn$1}$2{\fn}")
145                .to_string();
146
147            // Handle ruby text (convert to simple parentheses)
148            let ruby_regex = regex::Regex::new(r#"<ruby>([^<]*)<rt>([^<]*)</rt></ruby>"#).unwrap();
149            result = ruby_regex.replace_all(&result, "$1($2)").to_string();
150
151            // Handle timestamp tags (cue settings)
152            let timestamp_regex = regex::Regex::new(r#"<([0-9:.,]+)>"#).unwrap();
153            result = timestamp_regex.replace_all(&result, "").to_string();
154        }
155
156        result
157    }
158
159    /// Convert ASS override tags to WebVTT styling
160    fn convert_ass_to_vtt_styling(text: &str) -> String {
161        let mut result = text.to_string();
162
163        // Convert ASS override tags to WebVTT tags
164        result = result.replace(r"{\b1}", "<b>");
165        result = result.replace(r"{\b0}", "</b>");
166        result = result.replace(r"{\i1}", "<i>");
167        result = result.replace(r"{\i0}", "</i>");
168        result = result.replace(r"{\u1}", "<u>");
169        result = result.replace(r"{\u0}", "</u>");
170
171        #[cfg(feature = "formats")]
172        {
173            // Handle color tags
174            let color_regex = regex::Regex::new(r"\\c&H([0-9A-Fa-f]{6})&").unwrap();
175            result = color_regex.replace_all(&result, r#"<c.$1>"#).to_string();
176            result = result.replace(r"{\c}", "</c>");
177
178            // Handle font name tags
179            let font_regex = regex::Regex::new(r"\\fn([^}]+)").unwrap();
180            result = font_regex.replace_all(&result, r#"<v $1>"#).to_string();
181            result = result.replace(r"{\fn}", "</v>");
182
183            // Handle positioning tags (convert to WebVTT cue settings)
184            let pos_regex = regex::Regex::new(r"\\pos\(([^,]+),([^)]+)\)").unwrap();
185            result = pos_regex.replace_all(&result, "").to_string(); // Will be handled as cue settings
186
187            // Remove any remaining ASS tags
188            let cleanup_regex = regex::Regex::new(r"\{[^}]*\}").unwrap();
189            result = cleanup_regex.replace_all(&result, "").to_string();
190        }
191
192        result
193    }
194
195    /// Parse WebVTT cue settings for positioning
196    fn parse_cue_settings(settings: &str) -> HashMap<String, String> {
197        let mut cue_settings = HashMap::new();
198
199        for setting in settings.split_whitespace() {
200            if let Some(colon_pos) = setting.find(':') {
201                let (key, value) = setting.split_at(colon_pos);
202                let value = &value[1..]; // Remove colon
203                cue_settings.insert(key.to_string(), value.to_string());
204            }
205        }
206
207        cue_settings
208    }
209
210    /// Convert cue settings to ASS positioning tags
211    fn cue_settings_to_ass_positioning(settings: &HashMap<String, String>) -> String {
212        let mut ass_tags = String::new();
213
214        // Handle position settings
215        if let (Some(line), Some(position)) = (settings.get("line"), settings.get("position")) {
216            // Convert WebVTT positioning to approximate ASS positioning
217            if let (Ok(line_val), Ok(pos_val)) = (line.parse::<f32>(), position.parse::<f32>()) {
218                let x = (pos_val * 640.0) as u32; // Assume 640x480 resolution
219                let y = (line_val * 480.0) as u32;
220                ass_tags.push_str(&format!(r"\pos({x},{y})"));
221            }
222        }
223
224        // Handle alignment
225        if let Some(align) = settings.get("align") {
226            let alignment = match align.as_str() {
227                "start" | "left" => 1,
228                "center" | "middle" => 2,
229                "end" | "right" => 3,
230                _ => 2, // default to center
231            };
232            ass_tags.push_str(&format!(r"\an{alignment}"));
233        }
234
235        if !ass_tags.is_empty() {
236            format!("{{{ass_tags}}}")
237        } else {
238            String::new()
239        }
240    }
241
242    /// Parse WebVTT cue
243    fn parse_vtt_cue(lines: &[String], start_idx: usize) -> Result<(usize, String), EditorError> {
244        if start_idx >= lines.len() {
245            return Err(EditorError::InvalidFormat(
246                "Unexpected end of file".to_string(),
247            ));
248        }
249
250        let mut idx = start_idx;
251
252        // Skip empty lines and NOTE blocks
253        while idx < lines.len() {
254            let line = lines[idx].trim();
255            if line.is_empty() || line.starts_with("NOTE") {
256                idx += 1;
257                continue;
258            }
259            break;
260        }
261
262        if idx >= lines.len() {
263            return Err(EditorError::InvalidFormat(
264                "Unexpected end of file".to_string(),
265            ));
266        }
267
268        let current_line = &lines[idx];
269
270        // Check if this line contains timestamp (cue timing)
271        if current_line.contains("-->") {
272            // Parse timestamp line directly
273            let timestamp_line = current_line;
274            let parts: Vec<&str> = timestamp_line.split("-->").collect();
275            if parts.len() < 2 {
276                return Err(EditorError::InvalidFormat(format!(
277                    "Invalid timestamp format: {timestamp_line}"
278                )));
279            }
280
281            let start_time = Self::parse_vtt_time(parts[0])?;
282            let end_time_and_settings: Vec<&str> = parts[1].split_whitespace().collect();
283            let end_time = Self::parse_vtt_time(end_time_and_settings[0])?;
284
285            // Parse cue settings if present
286            let cue_settings = if end_time_and_settings.len() > 1 {
287                let settings_str = end_time_and_settings[1..].join(" ");
288                Self::parse_cue_settings(&settings_str)
289            } else {
290                HashMap::new()
291            };
292
293            idx += 1;
294
295            // Collect cue text
296            let mut text_lines = Vec::new();
297            while idx < lines.len() && !lines[idx].trim().is_empty() {
298                let styled_text = Self::convert_vtt_to_ass_styling(&lines[idx]);
299                text_lines.push(styled_text);
300                idx += 1;
301            }
302
303            if text_lines.is_empty() {
304                return Err(EditorError::InvalidFormat("Empty cue text".to_string()));
305            }
306
307            let text = text_lines.join("\\N"); // ASS line break
308            let positioning = Self::cue_settings_to_ass_positioning(&cue_settings);
309            let dialogue_line =
310                format!("Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{positioning}{text}");
311
312            Ok((idx, dialogue_line))
313        } else {
314            // This might be a cue identifier, skip to next line for timestamp
315            idx += 1;
316            if idx < lines.len() && lines[idx].contains("-->") {
317                Self::parse_vtt_cue(lines, idx)
318            } else {
319                Err(EditorError::InvalidFormat(format!(
320                    "Expected timestamp line after cue identifier: {current_line}"
321                )))
322            }
323        }
324    }
325}
326
327impl Default for WebVttFormat {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333impl FormatImporter for WebVttFormat {
334    fn format_info(&self) -> &FormatInfo {
335        &self.info
336    }
337
338    fn import_from_reader(
339        &self,
340        reader: &mut dyn Read,
341        options: &FormatOptions,
342    ) -> Result<(EditorDocument, FormatResult), EditorError> {
343        // Read the entire content
344        let mut content = String::new();
345        reader
346            .read_to_string(&mut content)
347            .map_err(|e| EditorError::IoError(format!("Failed to read WebVTT content: {e}")))?;
348
349        let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
350        let mut warnings = Vec::new();
351        let mut dialogues = Vec::new();
352        let mut idx = 0;
353        let mut cue_count = 0;
354
355        // Check for WebVTT header
356        if lines.is_empty() || !lines[0].trim().starts_with("WEBVTT") {
357            warnings.push("Missing or invalid WebVTT header".to_string());
358        } else {
359            idx = 1; // Skip header
360        }
361
362        // Parse all WebVTT cues
363        while idx < lines.len() {
364            match Self::parse_vtt_cue(&lines, idx) {
365                Ok((next_idx, dialogue)) => {
366                    dialogues.push(dialogue);
367                    idx = next_idx;
368                    cue_count += 1;
369                }
370                Err(e) => {
371                    if idx < lines.len() {
372                        warnings.push(format!("Skipping invalid cue at line {}: {e}", idx + 1));
373                        idx += 1;
374                    } else {
375                        break;
376                    }
377                }
378            }
379        }
380
381        // Build ASS script content
382        let mut ass_content = String::new();
383
384        // Add script info section
385        ass_content.push_str("[Script Info]\n");
386        ass_content.push_str("Title: Converted from WebVTT\n");
387        ass_content.push_str("ScriptType: v4.00+\n");
388        ass_content.push_str("Collisions: Normal\n");
389        ass_content.push_str("PlayDepth: 0\n");
390        ass_content.push_str("Timer: 100.0000\n");
391        ass_content.push_str("Video Aspect Ratio: 0\n");
392        ass_content.push_str("Video Zoom: 6\n");
393        ass_content.push_str("Video Position: 0\n");
394        ass_content.push_str("PlayResX: 640\n");
395        ass_content.push_str("PlayResY: 480\n\n");
396
397        // Add styles section with basic default style
398        ass_content.push_str("[V4+ Styles]\n");
399        ass_content.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");
400        ass_content.push_str("Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1\n\n");
401
402        // Add events section
403        ass_content.push_str("[Events]\n");
404        ass_content.push_str(
405            "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
406        );
407
408        for dialogue in dialogues {
409            ass_content.push_str(&dialogue);
410            ass_content.push('\n');
411        }
412
413        // Validate the generated ASS content
414        let _script = Script::parse(&ass_content)?;
415
416        // Create EditorDocument
417        let document = EditorDocument::from_content(&ass_content)?;
418
419        // Create result with metadata
420        let mut result = FormatResult::success(cue_count)
421            .with_metadata("original_format".to_string(), "WebVTT".to_string())
422            .with_metadata("cues_count".to_string(), cue_count.to_string())
423            .with_metadata("encoding".to_string(), options.encoding.clone());
424
425        if !warnings.is_empty() {
426            result = result.with_warnings(warnings);
427        }
428
429        Ok((document, result))
430    }
431}
432
433impl FormatExporter for WebVttFormat {
434    fn format_info(&self) -> &FormatInfo {
435        &self.info
436    }
437
438    fn export_to_writer(
439        &self,
440        document: &EditorDocument,
441        writer: &mut dyn Write,
442        options: &FormatOptions,
443    ) -> Result<FormatResult, EditorError> {
444        // Parse the ASS content to extract events
445        let events = document.parse_script_with(|script| {
446            // Find events section and collect owned data
447            if let Some(ass_core::parser::ast::Section::Events(events)) =
448                script.find_section(ass_core::parser::ast::SectionType::Events)
449            {
450                // Convert to owned data to avoid lifetime issues
451                events
452                    .iter()
453                    .map(|event| {
454                        (
455                            event.event_type,
456                            event.start.to_string(),
457                            event.end.to_string(),
458                            event.text.to_string(),
459                        )
460                    })
461                    .collect::<Vec<_>>()
462            } else {
463                Vec::new()
464            }
465        })?;
466
467        let mut vtt_content = String::new();
468        let mut cue_num = 1;
469        let mut warnings = Vec::new();
470
471        // Add WebVTT header
472        vtt_content.push_str("WEBVTT\n\n");
473
474        for (event_type, start, end, text) in &events {
475            // Only export dialogue events
476            if event_type.as_str() != "Dialogue" {
477                continue;
478            }
479
480            // Parse start and end times
481            let start_time = match Self::format_vtt_time(start) {
482                Ok(time) => time,
483                Err(e) => {
484                    warnings.push(format!("Invalid start time for cue {cue_num}: {e}"));
485                    continue;
486                }
487            };
488
489            let end_time = match Self::format_vtt_time(end) {
490                Ok(time) => time,
491                Err(e) => {
492                    warnings.push(format!("Invalid end time for cue {cue_num}: {e}"));
493                    continue;
494                }
495            };
496
497            // Convert ASS text to WebVTT format
498            let mut text = text.clone();
499
500            // Convert ASS line breaks to actual line breaks
501            text = text.replace("\\N", "\n");
502            text = text.replace("\\n", "\n");
503
504            // Convert ASS styling to WebVTT styling
505            text = Self::convert_ass_to_vtt_styling(&text);
506
507            // Write WebVTT cue
508            vtt_content.push_str(&format!("{cue_num}\n"));
509            vtt_content.push_str(&format!("{start_time} --> {end_time}\n"));
510            vtt_content.push_str(&text);
511            vtt_content.push_str("\n\n");
512
513            cue_num += 1;
514        }
515
516        // Write content with proper encoding
517        let bytes = if options.encoding.eq_ignore_ascii_case("UTF-8") {
518            vtt_content.into_bytes()
519        } else {
520            warnings.push(format!(
521                "Encoding '{}' not supported, using UTF-8 instead",
522                options.encoding
523            ));
524            vtt_content.into_bytes()
525        };
526
527        writer
528            .write_all(&bytes)
529            .map_err(|e| EditorError::IoError(format!("Failed to write WebVTT content: {e}")))?;
530
531        let mut result = FormatResult::success(cue_num - 1)
532            .with_metadata("exported_format".to_string(), "WebVTT".to_string())
533            .with_metadata("cues_exported".to_string(), (cue_num - 1).to_string());
534
535        if !warnings.is_empty() {
536            result = result.with_warnings(warnings);
537        }
538
539        Ok(result)
540    }
541}
542
543impl Format for WebVttFormat {
544    fn as_importer(&self) -> &dyn FormatImporter {
545        self
546    }
547
548    fn as_exporter(&self) -> &dyn FormatExporter {
549        self
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    #[cfg(not(feature = "std"))]
557    use alloc::string::ToString;
558    #[cfg(not(feature = "std"))]
559    use alloc::{format, string::String, vec};
560
561    const SAMPLE_WEBVTT: &str = r#"WEBVTT
562
5631
56400:00:00.000 --> 00:00:05.000
565<b>Hello</b> <i>World</i>!
566
5672
56800:00:06.000 --> 00:00:10.000 align:center
569This is a <u>subtitle</u> with <c.red>red text</c>.
570
5713
57200:12:30.500 --> 00:15:45.750 line:20% position:50%
573<v Speaker>Multiple</v>
574lines with positioning
575
576"#;
577
578    #[test]
579    fn test_webvtt_format_creation() {
580        let format = WebVttFormat::new();
581        let info = FormatImporter::format_info(&format);
582        assert_eq!(info.name, "WebVTT");
583        assert!(info.supports_styling);
584        assert!(info.supports_positioning);
585        assert!(format.can_import("vtt"));
586        assert!(format.can_import("webvtt"));
587        assert!(format.can_export("vtt"));
588    }
589
590    #[test]
591    fn test_parse_vtt_time() {
592        assert_eq!(
593            WebVttFormat::parse_vtt_time("00:01:23.456").unwrap(),
594            "0:01:23.45"
595        );
596        assert_eq!(
597            WebVttFormat::parse_vtt_time("01:00:00.000").unwrap(),
598            "1:00:00.00"
599        );
600        assert_eq!(
601            WebVttFormat::parse_vtt_time("30:45.123").unwrap(),
602            "0:30:45.12"
603        );
604
605        assert!(WebVttFormat::parse_vtt_time("invalid").is_err());
606        assert!(WebVttFormat::parse_vtt_time("00:01:23").is_err());
607    }
608
609    #[test]
610    fn test_format_vtt_time() {
611        assert_eq!(
612            WebVttFormat::format_vtt_time("0:01:23.45").unwrap(),
613            "00:01:23.450"
614        );
615        assert_eq!(
616            WebVttFormat::format_vtt_time("1:00:00.00").unwrap(),
617            "01:00:00.000"
618        );
619        assert_eq!(
620            WebVttFormat::format_vtt_time("10:30:45.12").unwrap(),
621            "10:30:45.120"
622        );
623
624        assert!(WebVttFormat::format_vtt_time("invalid").is_err());
625        assert!(WebVttFormat::format_vtt_time("00:01:23").is_err());
626    }
627
628    #[test]
629    fn test_convert_vtt_to_ass_styling() {
630        assert_eq!(
631            WebVttFormat::convert_vtt_to_ass_styling("<b>Bold</b> text"),
632            r"{\b1}Bold{\b0} text"
633        );
634        assert_eq!(
635            WebVttFormat::convert_vtt_to_ass_styling("<i>Italic</i> and <u>underlined</u>"),
636            r"{\i1}Italic{\i0} and {\u1}underlined{\u0}"
637        );
638    }
639
640    #[test]
641    fn test_convert_ass_to_vtt_styling() {
642        assert_eq!(
643            WebVttFormat::convert_ass_to_vtt_styling(r"{\b1}Bold{\b0} text"),
644            "<b>Bold</b> text"
645        );
646        assert_eq!(
647            WebVttFormat::convert_ass_to_vtt_styling(r"{\i1}Italic{\i0} and {\u1}underlined{\u0}"),
648            "<i>Italic</i> and <u>underlined</u>"
649        );
650    }
651
652    #[test]
653    fn test_parse_cue_settings() {
654        let settings = WebVttFormat::parse_cue_settings("align:center line:20% position:50%");
655        assert_eq!(settings.get("align"), Some(&"center".to_string()));
656        assert_eq!(settings.get("line"), Some(&"20%".to_string()));
657        assert_eq!(settings.get("position"), Some(&"50%".to_string()));
658    }
659
660    #[test]
661    fn test_webvtt_import_from_string() {
662        let format = WebVttFormat::new();
663        let options = FormatOptions::default();
664
665        let result = format.import_from_string(SAMPLE_WEBVTT, &options);
666        assert!(result.is_ok());
667
668        let (document, format_result) = result.unwrap();
669        assert!(format_result.success);
670        assert_eq!(format_result.lines_processed, 3); // 3 cues
671        assert!(document.text().contains("Hello"));
672        assert!(document.text().contains("World"));
673        assert!(document.text().contains(r"{\b1}"));
674        assert!(document.text().contains(r"{\i1}"));
675    }
676
677    #[test]
678    fn test_webvtt_export_to_string() {
679        let format = WebVttFormat::new();
680        let options = FormatOptions::default();
681
682        // First import
683        let (document, _) = format.import_from_string(SAMPLE_WEBVTT, &options).unwrap();
684
685        // Then export
686        let result = format.export_to_string(&document, &options);
687        assert!(result.is_ok());
688
689        let (exported_content, format_result) = result.unwrap();
690        assert!(format_result.success);
691        assert!(exported_content.contains("WEBVTT"));
692        assert!(exported_content.contains("Hello"));
693        assert!(exported_content.contains("<b>"));
694        assert!(exported_content.contains("<i>"));
695        assert!(exported_content.contains("00:00:00.000 --> 00:00:05.000"));
696    }
697
698    #[test]
699    fn test_webvtt_roundtrip_basic() {
700        let format = WebVttFormat::new();
701        let options = FormatOptions::default();
702
703        let simple_vtt = "WEBVTT\n\n1\n00:00:01.000 --> 00:00:03.000\nHello World\n\n";
704
705        // Import -> Export -> Import
706        let (document1, _) = format.import_from_string(simple_vtt, &options).unwrap();
707        let (exported_content, _) = format.export_to_string(&document1, &options).unwrap();
708
709        // Verify basic structure is preserved
710        assert!(exported_content.contains("WEBVTT"));
711        assert!(exported_content.contains("Hello World"));
712        assert!(exported_content.contains("00:00:01.000 --> 00:00:03.000"));
713    }
714
715    #[test]
716    fn test_webvtt_style_preservation() {
717        let format = WebVttFormat::new();
718        let options = FormatOptions::default();
719
720        let styled_vtt =
721            "WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.000\n<b>Bold</b> and <i>italic</i> text\n\n";
722
723        let (document, _) = format.import_from_string(styled_vtt, &options).unwrap();
724        let (exported_content, _) = format.export_to_string(&document, &options).unwrap();
725
726        // Verify styles are preserved
727        assert!(exported_content.contains("<b>Bold</b>"));
728        assert!(exported_content.contains("<i>italic</i>"));
729    }
730
731    #[test]
732    fn test_webvtt_positioning_support() {
733        let format = WebVttFormat::new();
734        let options = FormatOptions::default();
735
736        let positioned_vtt =
737            "WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.000 line:20% position:50%\nPositioned text\n\n";
738
739        let (document, _) = format.import_from_string(positioned_vtt, &options).unwrap();
740
741        // Should parse without errors and preserve positioning info in ASS format
742        assert!(document.text().contains("Positioned text"));
743    }
744
745    #[test]
746    fn test_webvtt_multiline_handling() {
747        let format = WebVttFormat::new();
748        let options = FormatOptions::default();
749
750        let multiline_vtt =
751            "WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.000\nLine one\nLine two\nLine three\n\n";
752
753        let (document, _) = format.import_from_string(multiline_vtt, &options).unwrap();
754        let (exported_content, _) = format.export_to_string(&document, &options).unwrap();
755
756        // Verify multiline content is preserved
757        assert!(exported_content.contains("Line one"));
758        assert!(exported_content.contains("Line two"));
759        assert!(exported_content.contains("Line three"));
760    }
761
762    #[test]
763    fn test_webvtt_error_handling() {
764        let format = WebVttFormat::new();
765        let options = FormatOptions::default();
766
767        let invalid_vtt = "Invalid WebVTT content";
768        let result = format.import_from_string(invalid_vtt, &options);
769
770        // Should handle gracefully and return warnings
771        if let Ok((_, format_result)) = result {
772            assert!(!format_result.warnings.is_empty());
773        }
774    }
775
776    #[test]
777    fn test_webvtt_metadata_extraction() {
778        let format = WebVttFormat::new();
779        let options = FormatOptions::default();
780
781        let (_, format_result) = format.import_from_string(SAMPLE_WEBVTT, &options).unwrap();
782
783        assert_eq!(
784            format_result.metadata.get("original_format"),
785            Some(&"WebVTT".to_string())
786        );
787        assert_eq!(
788            format_result.metadata.get("cues_count"),
789            Some(&"3".to_string())
790        );
791        assert_eq!(
792            format_result.metadata.get("encoding"),
793            Some(&"UTF-8".to_string())
794        );
795    }
796}