Skip to main content

subx_cli/core/formats/
transformers.rs

1//! Subtitle transformers for converting between different subtitle formats.
2//!
3//! This module provides utility methods to transform subtitle objects
4//! into various target formats using the `FormatConverter`.
5//!
6//! # Examples
7//!
8//! ```rust,ignore
9//! use subx_cli::core::formats::{FormatConverter, Subtitle};
10//! // Convert a subtitle object to a target format
11//! let converter = FormatConverter::new();
12//! let transformed = converter.transform_subtitle(subtitle.clone(), "ass").unwrap();
13//! ```
14
15use crate::core::formats::converter::FormatConverter;
16use crate::core::formats::{Subtitle, SubtitleFormatType};
17
18impl FormatConverter {
19    /// Transforms a subtitle object into the target format.
20    ///
21    /// # Arguments
22    ///
23    /// * `subtitle` - The subtitle object to transform.
24    /// * `target_format` - The desired subtitle format identifier (e.g., "ass", "srt").
25    ///
26    /// # Returns
27    ///
28    /// A `Result<Subtitle>` containing the transformed subtitle object or an error.
29    pub(crate) fn transform_subtitle(
30        &self,
31        subtitle: Subtitle,
32        target_format: &str,
33    ) -> crate::Result<Subtitle> {
34        match (subtitle.format.as_str(), target_format) {
35            ("srt", "ass") => self.srt_to_ass(subtitle),
36            ("ass", "srt") => self.ass_to_srt(subtitle),
37            ("srt", "vtt") => self.srt_to_vtt(subtitle),
38            ("vtt", "srt") => self.vtt_to_srt(subtitle),
39            ("ass", "vtt") => self.ass_to_vtt(subtitle),
40            ("vtt", "ass") => self.vtt_to_ass(subtitle),
41            (source, target) if source == target => Ok(subtitle),
42            _ => Err(crate::error::SubXError::subtitle_format(
43                subtitle.format.to_string(),
44                format!(
45                    "Unsupported conversion: {} -> {}",
46                    subtitle.format, target_format
47                ),
48            )),
49        }
50    }
51
52    /// SRT to ASS conversion
53    pub(crate) fn srt_to_ass(&self, mut subtitle: Subtitle) -> crate::Result<Subtitle> {
54        let _default_style = crate::core::formats::ass::AssStyle {
55            name: "Default".to_string(),
56            font_name: "Arial".to_string(),
57            font_size: 16,
58            primary_color: crate::core::formats::ass::Color::white(),
59            secondary_color: crate::core::formats::ass::Color::red(),
60            outline_color: crate::core::formats::ass::Color::black(),
61            shadow_color: crate::core::formats::ass::Color::black(),
62            bold: false,
63            italic: false,
64            underline: false,
65            alignment: 2,
66        };
67        for entry in &mut subtitle.entries {
68            if self.config.preserve_styling {
69                entry.styling = Some(self.extract_srt_styling(&entry.text)?);
70            }
71            entry.text = self.convert_srt_tags_to_ass(&entry.text);
72        }
73        subtitle.format = SubtitleFormatType::Ass;
74        subtitle.metadata.original_format = SubtitleFormatType::Srt;
75        Ok(subtitle)
76    }
77
78    /// ASS to SRT conversion
79    pub(crate) fn ass_to_srt(&self, mut subtitle: Subtitle) -> crate::Result<Subtitle> {
80        for entry in &mut subtitle.entries {
81            entry.text = self.strip_ass_tags(&entry.text);
82            if self.config.preserve_styling {
83                entry.text = self.convert_ass_tags_to_srt(&entry.text);
84            }
85            entry.styling = None;
86        }
87        subtitle.format = SubtitleFormatType::Srt;
88        Ok(subtitle)
89    }
90
91    /// SRT to VTT conversion
92    pub(crate) fn srt_to_vtt(&self, mut subtitle: Subtitle) -> crate::Result<Subtitle> {
93        subtitle.metadata.title = Some("WEBVTT".to_string());
94        for entry in &mut subtitle.entries {
95            entry.text = self.convert_srt_tags_to_vtt(&entry.text);
96        }
97        subtitle.format = SubtitleFormatType::Vtt;
98        Ok(subtitle)
99    }
100
101    /// ASS to VTT conversion
102    pub(crate) fn ass_to_vtt(&self, subtitle: Subtitle) -> crate::Result<Subtitle> {
103        // First convert ASS to SRT, then to VTT
104        let subtitle = self.ass_to_srt(subtitle)?;
105        self.srt_to_vtt(subtitle)
106    }
107
108    /// VTT to SRT conversion
109    pub(crate) fn vtt_to_srt(&self, mut subtitle: Subtitle) -> crate::Result<Subtitle> {
110        // VTT can preserve or remove HTML style tags
111        for entry in &mut subtitle.entries {
112            if self.config.preserve_styling {
113                entry.text = self.convert_vtt_tags_to_srt(&entry.text);
114            } else {
115                entry.text = self.strip_vtt_tags(&entry.text);
116            }
117            entry.styling = None;
118        }
119        subtitle.format = SubtitleFormatType::Srt;
120        Ok(subtitle)
121    }
122
123    /// VTT to ASS conversion
124    pub(crate) fn vtt_to_ass(&self, subtitle: Subtitle) -> crate::Result<Subtitle> {
125        // First convert VTT to SRT, then to ASS
126        let subtitle = self.vtt_to_srt(subtitle)?;
127        self.srt_to_ass(subtitle)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::time::Duration;
134
135    use crate::core::formats::converter::{ConversionConfig, FormatConverter};
136    use crate::core::formats::{Subtitle, SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
137
138    fn make_config(preserve_styling: bool) -> ConversionConfig {
139        ConversionConfig {
140            preserve_styling,
141            target_encoding: "UTF-8".to_string(),
142            keep_original: false,
143            validate_output: false,
144        }
145    }
146
147    fn make_converter(preserve_styling: bool) -> FormatConverter {
148        FormatConverter::new(make_config(preserve_styling))
149    }
150
151    fn make_subtitle(format: SubtitleFormatType, entries: Vec<SubtitleEntry>) -> Subtitle {
152        Subtitle {
153            entries,
154            metadata: SubtitleMetadata::new(format.clone()),
155            format,
156        }
157    }
158
159    fn make_entry(index: usize, text: &str) -> SubtitleEntry {
160        SubtitleEntry::new(
161            index,
162            Duration::from_secs((index as u64) * 5),
163            Duration::from_secs((index as u64) * 5 + 3),
164            text.to_string(),
165        )
166    }
167
168    // ── transform_subtitle dispatch ─────────────────────────────────────────
169
170    #[test]
171    fn transform_srt_to_ass() {
172        let conv = make_converter(false);
173        let sub = make_subtitle(SubtitleFormatType::Srt, vec![make_entry(1, "Hello")]);
174        let result = conv.transform_subtitle(sub, "ass").unwrap();
175        assert_eq!(result.format, SubtitleFormatType::Ass);
176    }
177
178    #[test]
179    fn transform_ass_to_srt() {
180        let conv = make_converter(false);
181        let sub = make_subtitle(SubtitleFormatType::Ass, vec![make_entry(1, "Hello")]);
182        let result = conv.transform_subtitle(sub, "srt").unwrap();
183        assert_eq!(result.format, SubtitleFormatType::Srt);
184    }
185
186    #[test]
187    fn transform_srt_to_vtt() {
188        let conv = make_converter(false);
189        let sub = make_subtitle(SubtitleFormatType::Srt, vec![make_entry(1, "Hello")]);
190        let result = conv.transform_subtitle(sub, "vtt").unwrap();
191        assert_eq!(result.format, SubtitleFormatType::Vtt);
192    }
193
194    #[test]
195    fn transform_vtt_to_srt() {
196        let conv = make_converter(false);
197        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![make_entry(1, "Hello")]);
198        let result = conv.transform_subtitle(sub, "srt").unwrap();
199        assert_eq!(result.format, SubtitleFormatType::Srt);
200    }
201
202    #[test]
203    fn transform_ass_to_vtt() {
204        let conv = make_converter(false);
205        let sub = make_subtitle(SubtitleFormatType::Ass, vec![make_entry(1, "Hello")]);
206        let result = conv.transform_subtitle(sub, "vtt").unwrap();
207        assert_eq!(result.format, SubtitleFormatType::Vtt);
208    }
209
210    #[test]
211    fn transform_vtt_to_ass() {
212        let conv = make_converter(false);
213        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![make_entry(1, "Hello")]);
214        let result = conv.transform_subtitle(sub, "ass").unwrap();
215        assert_eq!(result.format, SubtitleFormatType::Ass);
216    }
217
218    #[test]
219    fn transform_same_format_returns_unchanged() {
220        let conv = make_converter(false);
221        let sub = make_subtitle(SubtitleFormatType::Srt, vec![make_entry(1, "Same")]);
222        let result = conv.transform_subtitle(sub, "srt").unwrap();
223        assert_eq!(result.format, SubtitleFormatType::Srt);
224        assert_eq!(result.entries[0].text, "Same");
225    }
226
227    #[test]
228    fn transform_same_format_ass() {
229        let conv = make_converter(false);
230        let sub = make_subtitle(SubtitleFormatType::Ass, vec![make_entry(1, "Same")]);
231        let result = conv.transform_subtitle(sub, "ass").unwrap();
232        assert_eq!(result.format, SubtitleFormatType::Ass);
233    }
234
235    #[test]
236    fn transform_unsupported_format_returns_error() {
237        let conv = make_converter(false);
238        let sub = make_subtitle(SubtitleFormatType::Srt, vec![make_entry(1, "Hello")]);
239        let result = conv.transform_subtitle(sub, "sub");
240        assert!(result.is_err());
241    }
242
243    // ── srt_to_ass ──────────────────────────────────────────────────────────
244
245    #[test]
246    fn srt_to_ass_sets_format() {
247        let conv = make_converter(false);
248        let sub = make_subtitle(SubtitleFormatType::Srt, vec![make_entry(1, "Hello")]);
249        let result = conv.srt_to_ass(sub).unwrap();
250        assert_eq!(result.format, SubtitleFormatType::Ass);
251        assert_eq!(result.metadata.original_format, SubtitleFormatType::Srt);
252    }
253
254    #[test]
255    fn srt_to_ass_converts_bold_tags() {
256        let conv = make_converter(false);
257        let entry = make_entry(1, "<b>bold text</b>");
258        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
259        let result = conv.srt_to_ass(sub).unwrap();
260        assert!(result.entries[0].text.contains("{\\b1}"));
261        assert!(result.entries[0].text.contains("{\\b0}"));
262    }
263
264    #[test]
265    fn srt_to_ass_converts_italic_tags() {
266        let conv = make_converter(false);
267        let entry = make_entry(1, "<i>italic</i>");
268        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
269        let result = conv.srt_to_ass(sub).unwrap();
270        assert!(result.entries[0].text.contains("{\\i1}"));
271        assert!(result.entries[0].text.contains("{\\i0}"));
272    }
273
274    #[test]
275    fn srt_to_ass_converts_underline_tags() {
276        let conv = make_converter(false);
277        let entry = make_entry(1, "<u>underline</u>");
278        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
279        let result = conv.srt_to_ass(sub).unwrap();
280        assert!(result.entries[0].text.contains("{\\u1}"));
281        assert!(result.entries[0].text.contains("{\\u0}"));
282    }
283
284    #[test]
285    fn srt_to_ass_converts_font_color_tag() {
286        let conv = make_converter(false);
287        let entry = make_entry(1, r##"<font color="#FF0000">red</font>"##);
288        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
289        let result = conv.srt_to_ass(sub).unwrap();
290        assert!(result.entries[0].text.contains("{\\c"));
291        assert!(result.entries[0].text.contains("{\\c}"));
292    }
293
294    #[test]
295    fn srt_to_ass_with_preserve_styling_sets_styling() {
296        let conv = make_converter(true);
297        let entry = make_entry(1, "<b>bold</b>");
298        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
299        let result = conv.srt_to_ass(sub).unwrap();
300        assert!(result.entries[0].styling.is_some());
301        let styling = result.entries[0].styling.as_ref().unwrap();
302        assert!(styling.bold);
303    }
304
305    #[test]
306    fn srt_to_ass_with_preserve_styling_italic() {
307        let conv = make_converter(true);
308        let entry = make_entry(1, "<i>italic</i>");
309        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
310        let result = conv.srt_to_ass(sub).unwrap();
311        let styling = result.entries[0].styling.as_ref().unwrap();
312        assert!(styling.italic);
313    }
314
315    #[test]
316    fn srt_to_ass_with_preserve_styling_underline() {
317        let conv = make_converter(true);
318        let entry = make_entry(1, "<u>underlined</u>");
319        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
320        let result = conv.srt_to_ass(sub).unwrap();
321        let styling = result.entries[0].styling.as_ref().unwrap();
322        assert!(styling.underline);
323    }
324
325    #[test]
326    fn srt_to_ass_empty_entries() {
327        let conv = make_converter(false);
328        let sub = make_subtitle(SubtitleFormatType::Srt, vec![]);
329        let result = conv.srt_to_ass(sub).unwrap();
330        assert_eq!(result.format, SubtitleFormatType::Ass);
331        assert!(result.entries.is_empty());
332    }
333
334    // ── ass_to_srt ──────────────────────────────────────────────────────────
335
336    #[test]
337    fn ass_to_srt_sets_format() {
338        let conv = make_converter(false);
339        let sub = make_subtitle(SubtitleFormatType::Ass, vec![make_entry(1, "Hello")]);
340        let result = conv.ass_to_srt(sub).unwrap();
341        assert_eq!(result.format, SubtitleFormatType::Srt);
342    }
343
344    #[test]
345    fn ass_to_srt_strips_ass_tags() {
346        let conv = make_converter(false);
347        let entry = make_entry(1, "{\\an8}Dialogue text");
348        let sub = make_subtitle(SubtitleFormatType::Ass, vec![entry]);
349        let result = conv.ass_to_srt(sub).unwrap();
350        assert_eq!(result.entries[0].text, "Dialogue text");
351    }
352
353    #[test]
354    fn ass_to_srt_clears_styling() {
355        let conv = make_converter(false);
356        let mut entry = make_entry(1, "{\\b1}bold{\\b0}");
357        entry.styling = Some(crate::core::formats::StylingInfo {
358            bold: true,
359            ..Default::default()
360        });
361        let sub = make_subtitle(SubtitleFormatType::Ass, vec![entry]);
362        let result = conv.ass_to_srt(sub).unwrap();
363        assert!(result.entries[0].styling.is_none());
364    }
365
366    #[test]
367    fn ass_to_srt_with_preserve_styling_converts_bold() {
368        let conv = make_converter(true);
369        let entry = make_entry(1, "{\\b1}bold text{\\b0}");
370        let sub = make_subtitle(SubtitleFormatType::Ass, vec![entry]);
371        let result = conv.ass_to_srt(sub).unwrap();
372        // strip_ass_tags runs first, so ASS override blocks are removed before conversion
373        assert!(!result.entries[0].text.contains("{\\b1}"));
374        assert_eq!(result.entries[0].text, "bold text");
375    }
376
377    #[test]
378    fn ass_to_srt_with_preserve_styling_converts_italic() {
379        let conv = make_converter(true);
380        let entry = make_entry(1, "{\\i1}italic{\\i0}");
381        let sub = make_subtitle(SubtitleFormatType::Ass, vec![entry]);
382        let result = conv.ass_to_srt(sub).unwrap();
383        assert!(!result.entries[0].text.contains("{\\i1}"));
384        assert_eq!(result.entries[0].text, "italic");
385    }
386
387    #[test]
388    fn ass_to_srt_with_preserve_styling_converts_underline() {
389        let conv = make_converter(true);
390        let entry = make_entry(1, "{\\u1}underline{\\u0}");
391        let sub = make_subtitle(SubtitleFormatType::Ass, vec![entry]);
392        let result = conv.ass_to_srt(sub).unwrap();
393        assert!(!result.entries[0].text.contains("{\\u1}"));
394        assert_eq!(result.entries[0].text, "underline");
395    }
396
397    #[test]
398    fn ass_to_srt_empty_entries() {
399        let conv = make_converter(false);
400        let sub = make_subtitle(SubtitleFormatType::Ass, vec![]);
401        let result = conv.ass_to_srt(sub).unwrap();
402        assert_eq!(result.format, SubtitleFormatType::Srt);
403        assert!(result.entries.is_empty());
404    }
405
406    // ── srt_to_vtt ──────────────────────────────────────────────────────────
407
408    #[test]
409    fn srt_to_vtt_sets_format_and_title() {
410        let conv = make_converter(false);
411        let sub = make_subtitle(SubtitleFormatType::Srt, vec![make_entry(1, "Hello")]);
412        let result = conv.srt_to_vtt(sub).unwrap();
413        assert_eq!(result.format, SubtitleFormatType::Vtt);
414        assert_eq!(result.metadata.title.as_deref(), Some("WEBVTT"));
415    }
416
417    #[test]
418    fn srt_to_vtt_preserves_text() {
419        let conv = make_converter(false);
420        let entry = make_entry(1, "Plain text");
421        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
422        let result = conv.srt_to_vtt(sub).unwrap();
423        assert_eq!(result.entries[0].text, "Plain text");
424    }
425
426    #[test]
427    fn srt_to_vtt_empty_entries() {
428        let conv = make_converter(false);
429        let sub = make_subtitle(SubtitleFormatType::Srt, vec![]);
430        let result = conv.srt_to_vtt(sub).unwrap();
431        assert_eq!(result.format, SubtitleFormatType::Vtt);
432    }
433
434    // ── ass_to_vtt ──────────────────────────────────────────────────────────
435
436    #[test]
437    fn ass_to_vtt_sets_format() {
438        let conv = make_converter(false);
439        let sub = make_subtitle(SubtitleFormatType::Ass, vec![make_entry(1, "{\\an8}Text")]);
440        let result = conv.ass_to_vtt(sub).unwrap();
441        assert_eq!(result.format, SubtitleFormatType::Vtt);
442    }
443
444    #[test]
445    fn ass_to_vtt_strips_ass_tags() {
446        let conv = make_converter(false);
447        let entry = make_entry(1, "{\\b1}bold{\\b0}");
448        let sub = make_subtitle(SubtitleFormatType::Ass, vec![entry]);
449        let result = conv.ass_to_vtt(sub).unwrap();
450        assert!(!result.entries[0].text.contains("{\\"));
451    }
452
453    // ── vtt_to_srt ──────────────────────────────────────────────────────────
454
455    #[test]
456    fn vtt_to_srt_sets_format() {
457        let conv = make_converter(false);
458        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![make_entry(1, "Hello")]);
459        let result = conv.vtt_to_srt(sub).unwrap();
460        assert_eq!(result.format, SubtitleFormatType::Srt);
461    }
462
463    #[test]
464    fn vtt_to_srt_strips_html_tags_without_preserve_styling() {
465        let conv = make_converter(false);
466        let entry = make_entry(1, "<b>bold</b> text");
467        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![entry]);
468        let result = conv.vtt_to_srt(sub).unwrap();
469        assert!(!result.entries[0].text.contains("<b>"));
470        assert_eq!(result.entries[0].text, "bold text");
471    }
472
473    #[test]
474    fn vtt_to_srt_preserves_tags_with_preserve_styling() {
475        let conv = make_converter(true);
476        let entry = make_entry(1, "<i>italic</i>");
477        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![entry]);
478        let result = conv.vtt_to_srt(sub).unwrap();
479        assert_eq!(result.entries[0].text, "<i>italic</i>");
480    }
481
482    #[test]
483    fn vtt_to_srt_clears_styling() {
484        let conv = make_converter(false);
485        let mut entry = make_entry(1, "text");
486        entry.styling = Some(crate::core::formats::StylingInfo {
487            italic: true,
488            ..Default::default()
489        });
490        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![entry]);
491        let result = conv.vtt_to_srt(sub).unwrap();
492        assert!(result.entries[0].styling.is_none());
493    }
494
495    #[test]
496    fn vtt_to_srt_empty_entries() {
497        let conv = make_converter(false);
498        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![]);
499        let result = conv.vtt_to_srt(sub).unwrap();
500        assert_eq!(result.format, SubtitleFormatType::Srt);
501    }
502
503    // ── vtt_to_ass ──────────────────────────────────────────────────────────
504
505    #[test]
506    fn vtt_to_ass_sets_format() {
507        let conv = make_converter(false);
508        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![make_entry(1, "Hello")]);
509        let result = conv.vtt_to_ass(sub).unwrap();
510        assert_eq!(result.format, SubtitleFormatType::Ass);
511    }
512
513    #[test]
514    fn vtt_to_ass_multiple_entries() {
515        let conv = make_converter(false);
516        let sub = make_subtitle(
517            SubtitleFormatType::Vtt,
518            vec![make_entry(1, "First"), make_entry(2, "Second")],
519        );
520        let result = conv.vtt_to_ass(sub).unwrap();
521        assert_eq!(result.entries.len(), 2);
522    }
523
524    // ── styling helpers (extract_srt_styling) ───────────────────────────────
525
526    #[test]
527    fn extract_srt_styling_detects_bold() {
528        let conv = make_converter(false);
529        let styling = conv.extract_srt_styling("<b>text</b>").unwrap();
530        assert!(styling.bold);
531    }
532
533    #[test]
534    fn extract_srt_styling_detects_bold_uppercase() {
535        let conv = make_converter(false);
536        let styling = conv.extract_srt_styling("<B>text</B>").unwrap();
537        assert!(styling.bold);
538    }
539
540    #[test]
541    fn extract_srt_styling_detects_italic() {
542        let conv = make_converter(false);
543        let styling = conv.extract_srt_styling("<i>text</i>").unwrap();
544        assert!(styling.italic);
545    }
546
547    #[test]
548    fn extract_srt_styling_detects_italic_uppercase() {
549        let conv = make_converter(false);
550        let styling = conv.extract_srt_styling("<I>text</I>").unwrap();
551        assert!(styling.italic);
552    }
553
554    #[test]
555    fn extract_srt_styling_detects_underline() {
556        let conv = make_converter(false);
557        let styling = conv.extract_srt_styling("<u>text</u>").unwrap();
558        assert!(styling.underline);
559    }
560
561    #[test]
562    fn extract_srt_styling_detects_underline_uppercase() {
563        let conv = make_converter(false);
564        let styling = conv.extract_srt_styling("<U>text</U>").unwrap();
565        assert!(styling.underline);
566    }
567
568    #[test]
569    fn extract_srt_styling_plain_text_no_flags() {
570        let conv = make_converter(false);
571        let styling = conv.extract_srt_styling("plain text").unwrap();
572        assert!(!styling.bold);
573        assert!(!styling.italic);
574        assert!(!styling.underline);
575        assert!(styling.color.is_none());
576    }
577
578    #[test]
579    fn extract_srt_styling_all_flags() {
580        let conv = make_converter(false);
581        let styling = conv
582            .extract_srt_styling("<b><i><u>all</u></i></b>")
583            .unwrap();
584        assert!(styling.bold);
585        assert!(styling.italic);
586        assert!(styling.underline);
587    }
588
589    // ── convert_srt_tags_to_ass ─────────────────────────────────────────────
590
591    #[test]
592    fn convert_srt_tags_to_ass_bold() {
593        let conv = make_converter(false);
594        let result = conv.convert_srt_tags_to_ass("<b>text</b>");
595        assert_eq!(result, "{\\b1}text{\\b0}");
596    }
597
598    #[test]
599    fn convert_srt_tags_to_ass_italic() {
600        let conv = make_converter(false);
601        let result = conv.convert_srt_tags_to_ass("<i>text</i>");
602        assert_eq!(result, "{\\i1}text{\\i0}");
603    }
604
605    #[test]
606    fn convert_srt_tags_to_ass_underline() {
607        let conv = make_converter(false);
608        let result = conv.convert_srt_tags_to_ass("<u>text</u>");
609        assert_eq!(result, "{\\u1}text{\\u0}");
610    }
611
612    #[test]
613    fn convert_srt_tags_to_ass_font_color_hex() {
614        let conv = make_converter(false);
615        let result = conv.convert_srt_tags_to_ass(r##"<font color="#FF0000">red</font>"##);
616        assert!(result.contains("{\\c&H"));
617        assert!(result.contains("{\\c}"));
618    }
619
620    #[test]
621    fn convert_srt_tags_to_ass_no_tags() {
622        let conv = make_converter(false);
623        let result = conv.convert_srt_tags_to_ass("plain text");
624        assert_eq!(result, "plain text");
625    }
626
627    // ── strip_ass_tags ──────────────────────────────────────────────────────
628
629    #[test]
630    fn strip_ass_tags_removes_override_blocks() {
631        let conv = make_converter(false);
632        let result = conv.strip_ass_tags("{\\an8}{\\b1}text{\\b0}");
633        assert_eq!(result, "text");
634    }
635
636    #[test]
637    fn strip_ass_tags_plain_text_unchanged() {
638        let conv = make_converter(false);
639        let result = conv.strip_ass_tags("plain text");
640        assert_eq!(result, "plain text");
641    }
642
643    #[test]
644    fn strip_ass_tags_empty_string() {
645        let conv = make_converter(false);
646        let result = conv.strip_ass_tags("");
647        assert_eq!(result, "");
648    }
649
650    // ── convert_ass_tags_to_srt ─────────────────────────────────────────────
651
652    #[test]
653    fn convert_ass_tags_to_srt_bold() {
654        let conv = make_converter(false);
655        let result = conv.convert_ass_tags_to_srt("{\\b1}text{\\b0}");
656        assert_eq!(result, "<b>text</b>");
657    }
658
659    #[test]
660    fn convert_ass_tags_to_srt_italic() {
661        let conv = make_converter(false);
662        let result = conv.convert_ass_tags_to_srt("{\\i1}text{\\i0}");
663        assert_eq!(result, "<i>text</i>");
664    }
665
666    #[test]
667    fn convert_ass_tags_to_srt_underline() {
668        let conv = make_converter(false);
669        let result = conv.convert_ass_tags_to_srt("{\\u1}text{\\u0}");
670        assert_eq!(result, "<u>text</u>");
671    }
672
673    #[test]
674    fn convert_ass_tags_to_srt_no_tags() {
675        let conv = make_converter(false);
676        let result = conv.convert_ass_tags_to_srt("no tags here");
677        assert_eq!(result, "no tags here");
678    }
679
680    // ── extract_color_from_tags ─────────────────────────────────────────────
681
682    #[test]
683    fn extract_color_from_tags_returns_none() {
684        let conv = make_converter(false);
685        let result = conv.extract_color_from_tags("<font color=\"red\">text</font>");
686        assert!(result.is_none());
687    }
688
689    // ── convert_color_to_ass ────────────────────────────────────────────────
690
691    #[test]
692    fn convert_color_to_ass_strips_hash() {
693        let conv = make_converter(false);
694        let result = conv.convert_color_to_ass("#FF0000");
695        assert_eq!(result, "FF0000");
696    }
697
698    #[test]
699    fn convert_color_to_ass_no_hash() {
700        let conv = make_converter(false);
701        let result = conv.convert_color_to_ass("00FF00");
702        assert_eq!(result, "00FF00");
703    }
704
705    // ── convert_srt_tags_to_vtt ─────────────────────────────────────────────
706
707    #[test]
708    fn convert_srt_tags_to_vtt_passthrough() {
709        let conv = make_converter(false);
710        let result = conv.convert_srt_tags_to_vtt("<b>text</b>");
711        assert_eq!(result, "<b>text</b>");
712    }
713
714    // ── convert_vtt_tags_to_srt ─────────────────────────────────────────────
715
716    #[test]
717    fn convert_vtt_tags_to_srt_passthrough() {
718        let conv = make_converter(false);
719        let result = conv.convert_vtt_tags_to_srt("<i>text</i>");
720        assert_eq!(result, "<i>text</i>");
721    }
722
723    // ── strip_vtt_tags ──────────────────────────────────────────────────────
724
725    #[test]
726    fn strip_vtt_tags_removes_html_tags() {
727        let conv = make_converter(false);
728        let result = conv.strip_vtt_tags("<b>bold</b> and <i>italic</i>");
729        assert_eq!(result, "bold and italic");
730    }
731
732    #[test]
733    fn strip_vtt_tags_plain_text_unchanged() {
734        let conv = make_converter(false);
735        let result = conv.strip_vtt_tags("plain text");
736        assert_eq!(result, "plain text");
737    }
738
739    #[test]
740    fn strip_vtt_tags_empty_string() {
741        let conv = make_converter(false);
742        let result = conv.strip_vtt_tags("");
743        assert_eq!(result, "");
744    }
745
746    #[test]
747    fn strip_vtt_tags_self_closing() {
748        let conv = make_converter(false);
749        let result = conv.strip_vtt_tags("line1<br/>line2");
750        assert_eq!(result, "line1line2");
751    }
752
753    // ── edge cases ──────────────────────────────────────────────────────────
754
755    #[test]
756    fn srt_to_ass_special_characters() {
757        let conv = make_converter(false);
758        let entry = make_entry(1, "Special: <>&\"'");
759        let sub = make_subtitle(SubtitleFormatType::Srt, vec![entry]);
760        let result = conv.srt_to_ass(sub).unwrap();
761        assert!(result.entries[0].text.contains("Special:"));
762    }
763
764    #[test]
765    fn srt_to_ass_multiple_entries_converted() {
766        let conv = make_converter(false);
767        let sub = make_subtitle(
768            SubtitleFormatType::Srt,
769            vec![
770                make_entry(1, "<b>First</b>"),
771                make_entry(2, "<i>Second</i>"),
772                make_entry(3, "Third"),
773            ],
774        );
775        let result = conv.srt_to_ass(sub).unwrap();
776        assert_eq!(result.entries.len(), 3);
777        assert!(result.entries[0].text.contains("{\\b1}"));
778        assert!(result.entries[1].text.contains("{\\i1}"));
779        assert_eq!(result.entries[2].text, "Third");
780    }
781
782    #[test]
783    fn ass_to_srt_multiple_entries() {
784        let conv = make_converter(true);
785        let sub = make_subtitle(
786            SubtitleFormatType::Ass,
787            vec![
788                make_entry(1, "{\\b1}one{\\b0}"),
789                make_entry(2, "{\\i1}two{\\i0}"),
790            ],
791        );
792        let result = conv.ass_to_srt(sub).unwrap();
793        assert_eq!(result.entries.len(), 2);
794        // strip_ass_tags runs before convert, so all override blocks are removed
795        assert_eq!(result.entries[0].text, "one");
796        assert_eq!(result.entries[1].text, "two");
797    }
798
799    #[test]
800    fn vtt_to_srt_strips_multiple_tags() {
801        let conv = make_converter(false);
802        let entry = make_entry(1, "<v Speaker>text</v>");
803        let sub = make_subtitle(SubtitleFormatType::Vtt, vec![entry]);
804        let result = conv.vtt_to_srt(sub).unwrap();
805        assert!(!result.entries[0].text.contains('<'));
806    }
807}