Skip to main content

subx_cli/core/formats/
styling.rs

1//! Styling adjustments for subtitle formats.
2//!
3//! This module provides functions to manipulate styling (e.g., fonts,
4//! colors) for subtitle entries.
5//!
6//! # Examples
7//!
8//! ```rust,ignore
9//! use subx_cli::core::formats::styling::apply_styling;
10//! // ... apply styling adjustments to entries
11//! ```
12
13use regex::Regex;
14
15use crate::core::formats::StylingInfo;
16use crate::core::formats::converter::FormatConverter;
17
18impl FormatConverter {
19    /// Extract styling information from SRT tags
20    pub(crate) fn extract_srt_styling(&self, text: &str) -> crate::Result<StylingInfo> {
21        let mut styling = StylingInfo::default();
22        if text.contains("<b>") || text.contains("<B>") {
23            styling.bold = true;
24        }
25        if text.contains("<i>") || text.contains("<I>") {
26            styling.italic = true;
27        }
28        if text.contains("<u>") || text.contains("<U>") {
29            styling.underline = true;
30        }
31        if let Some(color) = self.extract_color_from_tags(text) {
32            styling.color = Some(color);
33        }
34        Ok(styling)
35    }
36
37    /// Convert SRT tags to ASS tags
38    pub(crate) fn convert_srt_tags_to_ass(&self, text: &str) -> String {
39        let mut result = text.to_string();
40        result = result.replace("<b>", "{\\b1}").replace("</b>", "{\\b0}");
41        result = result.replace("<i>", "{\\i1}").replace("</i>", "{\\i0}");
42        result = result.replace("<u>", "{\\u1}").replace("</u>", "{\\u0}");
43        let color_regex = Regex::new(r#"<font color=\"([^\"]+)\">"#).unwrap();
44        result = color_regex
45            .replace_all(&result, |caps: &regex::Captures| {
46                let color = &caps[1];
47                format!("{{\\c&H{}&}}", self.convert_color_to_ass(color))
48            })
49            .to_string();
50        result = result.replace("</font>", "{\\c}");
51        result
52    }
53
54    /// Remove ASS tags
55    pub(crate) fn strip_ass_tags(&self, text: &str) -> String {
56        let tag_regex = Regex::new(r"\{[^}]*\}").unwrap();
57        tag_regex.replace_all(text, "").to_string()
58    }
59
60    /// Convert ASS tags to SRT tags
61    pub(crate) fn convert_ass_tags_to_srt(&self, text: &str) -> String {
62        let mut result = text.to_string();
63        let bold_regex = Regex::new(r"\{\\b1\}([^\{]*)\{\\b0\}").unwrap();
64        result = bold_regex.replace_all(&result, "<b>$1</b>").to_string();
65        let italic_regex = Regex::new(r"\{\\i1\}([^\{]*)\{\\i0\}").unwrap();
66        result = italic_regex.replace_all(&result, "<i>$1</i>").to_string();
67        let underline_regex = Regex::new(r"\{\\u1\}([^\{]*)\{\\u0\}").unwrap();
68        result = underline_regex
69            .replace_all(&result, "<u>$1</u>")
70            .to_string();
71        result
72    }
73
74    /// Extract color from tags (simple implementation)
75    pub(crate) fn extract_color_from_tags(&self, _text: &str) -> Option<String> {
76        None
77    }
78
79    /// Convert color string to ASS color code
80    pub(crate) fn convert_color_to_ass(&self, color: &str) -> String {
81        color.trim_start_matches('#').to_string()
82    }
83
84    /// Convert SRT tags to VTT tags (simple implementation)
85    pub(crate) fn convert_srt_tags_to_vtt(&self, text: &str) -> String {
86        text.to_string()
87    }
88    /// Convert VTT tags to SRT tags (simple implementation)
89    pub(crate) fn convert_vtt_tags_to_srt(&self, text: &str) -> String {
90        // VTT uses HTML-like tags, SRT also supports basic tags, default preserve
91        text.to_string()
92    }
93    /// Remove VTT tags (simple implementation)
94    pub(crate) fn strip_vtt_tags(&self, text: &str) -> String {
95        let tag_regex = Regex::new(r"</?[^>]+>").unwrap();
96        tag_regex.replace_all(text, "").to_string()
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use crate::core::formats::converter::{ConversionConfig, FormatConverter};
103
104    fn make_converter() -> FormatConverter {
105        FormatConverter::new(ConversionConfig {
106            preserve_styling: true,
107            target_encoding: "UTF-8".to_string(),
108            keep_original: false,
109            validate_output: false,
110        })
111    }
112
113    // --- extract_srt_styling ---
114
115    #[test]
116    fn test_extract_srt_styling_empty() {
117        let c = make_converter();
118        let s = c.extract_srt_styling("").unwrap();
119        assert!(!s.bold);
120        assert!(!s.italic);
121        assert!(!s.underline);
122        assert!(s.color.is_none());
123    }
124
125    #[test]
126    fn test_extract_srt_styling_bold_lowercase() {
127        let c = make_converter();
128        let s = c.extract_srt_styling("<b>Hello</b>").unwrap();
129        assert!(s.bold);
130        assert!(!s.italic);
131        assert!(!s.underline);
132    }
133
134    #[test]
135    fn test_extract_srt_styling_bold_uppercase() {
136        let c = make_converter();
137        let s = c.extract_srt_styling("<B>Hello</B>").unwrap();
138        assert!(s.bold);
139    }
140
141    #[test]
142    fn test_extract_srt_styling_italic_lowercase() {
143        let c = make_converter();
144        let s = c.extract_srt_styling("<i>Hello</i>").unwrap();
145        assert!(s.italic);
146        assert!(!s.bold);
147        assert!(!s.underline);
148    }
149
150    #[test]
151    fn test_extract_srt_styling_italic_uppercase() {
152        let c = make_converter();
153        let s = c.extract_srt_styling("<I>text</I>").unwrap();
154        assert!(s.italic);
155    }
156
157    #[test]
158    fn test_extract_srt_styling_underline_lowercase() {
159        let c = make_converter();
160        let s = c.extract_srt_styling("<u>text</u>").unwrap();
161        assert!(s.underline);
162        assert!(!s.bold);
163        assert!(!s.italic);
164    }
165
166    #[test]
167    fn test_extract_srt_styling_underline_uppercase() {
168        let c = make_converter();
169        let s = c.extract_srt_styling("<U>text</U>").unwrap();
170        assert!(s.underline);
171    }
172
173    #[test]
174    fn test_extract_srt_styling_all_tags() {
175        let c = make_converter();
176        let s = c
177            .extract_srt_styling("<b><i><u>styled</u></i></b>")
178            .unwrap();
179        assert!(s.bold);
180        assert!(s.italic);
181        assert!(s.underline);
182    }
183
184    #[test]
185    fn test_extract_srt_styling_mixed_case_independent() {
186        let c = make_converter();
187        let s = c.extract_srt_styling("<B>bold</B> <i>italic</i>").unwrap();
188        assert!(s.bold);
189        assert!(s.italic);
190        assert!(!s.underline);
191    }
192
193    #[test]
194    fn test_extract_srt_styling_no_tags() {
195        let c = make_converter();
196        let s = c.extract_srt_styling("plain text").unwrap();
197        assert!(!s.bold);
198        assert!(!s.italic);
199        assert!(!s.underline);
200        assert!(s.color.is_none());
201    }
202
203    // --- extract_color_from_tags ---
204
205    #[test]
206    fn test_extract_color_from_tags_always_none() {
207        let c = make_converter();
208        assert!(
209            c.extract_color_from_tags(r##"<font color="#FF0000">text</font>"##)
210                .is_none()
211        );
212        assert!(c.extract_color_from_tags("").is_none());
213        assert!(c.extract_color_from_tags("anything").is_none());
214    }
215
216    // --- convert_color_to_ass ---
217
218    #[test]
219    fn test_convert_color_to_ass_strips_hash() {
220        let c = make_converter();
221        assert_eq!(c.convert_color_to_ass("#FF0000"), "FF0000");
222    }
223
224    #[test]
225    fn test_convert_color_to_ass_no_hash() {
226        let c = make_converter();
227        assert_eq!(c.convert_color_to_ass("FF0000"), "FF0000");
228    }
229
230    #[test]
231    fn test_convert_color_to_ass_empty() {
232        let c = make_converter();
233        assert_eq!(c.convert_color_to_ass(""), "");
234    }
235
236    #[test]
237    fn test_convert_color_to_ass_named_color() {
238        let c = make_converter();
239        assert_eq!(c.convert_color_to_ass("red"), "red");
240    }
241
242    // --- convert_srt_tags_to_ass ---
243
244    #[test]
245    fn test_convert_srt_to_ass_bold() {
246        let c = make_converter();
247        assert_eq!(
248            c.convert_srt_tags_to_ass("<b>Hello</b>"),
249            "{\\b1}Hello{\\b0}"
250        );
251    }
252
253    #[test]
254    fn test_convert_srt_to_ass_italic() {
255        let c = make_converter();
256        assert_eq!(
257            c.convert_srt_tags_to_ass("<i>Hello</i>"),
258            "{\\i1}Hello{\\i0}"
259        );
260    }
261
262    #[test]
263    fn test_convert_srt_to_ass_underline() {
264        let c = make_converter();
265        assert_eq!(
266            c.convert_srt_tags_to_ass("<u>Hello</u>"),
267            "{\\u1}Hello{\\u0}"
268        );
269    }
270
271    #[test]
272    fn test_convert_srt_to_ass_font_color_with_hash() {
273        let c = make_converter();
274        let result = c.convert_srt_tags_to_ass(r##"<font color="#FF0000">red</font>"##);
275        assert!(result.contains("FF0000"), "got: {result}");
276        assert!(result.contains("{\\c}"), "got: {result}");
277    }
278
279    #[test]
280    fn test_convert_srt_to_ass_font_color_without_hash() {
281        let c = make_converter();
282        let result = c.convert_srt_tags_to_ass(r##"<font color="AABBCC">text</font>"##);
283        assert!(result.contains("AABBCC"), "got: {result}");
284    }
285
286    #[test]
287    fn test_convert_srt_to_ass_empty() {
288        let c = make_converter();
289        assert_eq!(c.convert_srt_tags_to_ass(""), "");
290    }
291
292    #[test]
293    fn test_convert_srt_to_ass_no_tags() {
294        let c = make_converter();
295        assert_eq!(c.convert_srt_tags_to_ass("plain"), "plain");
296    }
297
298    #[test]
299    fn test_convert_srt_to_ass_combined() {
300        let c = make_converter();
301        let result = c.convert_srt_tags_to_ass("<b><i>bold italic</i></b>");
302        assert!(result.contains("{\\b1}"), "got: {result}");
303        assert!(result.contains("{\\b0}"), "got: {result}");
304        assert!(result.contains("{\\i1}"), "got: {result}");
305        assert!(result.contains("{\\i0}"), "got: {result}");
306    }
307
308    #[test]
309    fn test_convert_srt_to_ass_font_close_tag() {
310        let c = make_converter();
311        let result = c.convert_srt_tags_to_ass("</font>");
312        assert_eq!(result, "{\\c}");
313    }
314
315    // --- strip_ass_tags ---
316
317    #[test]
318    fn test_strip_ass_tags_basic() {
319        let c = make_converter();
320        assert_eq!(c.strip_ass_tags("{\\b1}Hello{\\b0}"), "Hello");
321    }
322
323    #[test]
324    fn test_strip_ass_tags_multiple() {
325        let c = make_converter();
326        assert_eq!(
327            c.strip_ass_tags("{\\i1}italic{\\i0} and {\\b1}bold{\\b0}"),
328            "italic and bold"
329        );
330    }
331
332    #[test]
333    fn test_strip_ass_tags_empty() {
334        let c = make_converter();
335        assert_eq!(c.strip_ass_tags(""), "");
336    }
337
338    #[test]
339    fn test_strip_ass_tags_no_tags() {
340        let c = make_converter();
341        assert_eq!(c.strip_ass_tags("plain text"), "plain text");
342    }
343
344    #[test]
345    fn test_strip_ass_tags_only_tag() {
346        let c = make_converter();
347        assert_eq!(c.strip_ass_tags("{\\pos(320,240)}"), "");
348    }
349
350    #[test]
351    fn test_strip_ass_tags_color_tag() {
352        let c = make_converter();
353        let result = c.strip_ass_tags("{\\c&HFF0000&}colored text{\\c}");
354        assert_eq!(result, "colored text");
355    }
356
357    // --- convert_ass_tags_to_srt ---
358
359    #[test]
360    fn test_convert_ass_to_srt_bold() {
361        let c = make_converter();
362        assert_eq!(
363            c.convert_ass_tags_to_srt("{\\b1}Hello{\\b0}"),
364            "<b>Hello</b>"
365        );
366    }
367
368    #[test]
369    fn test_convert_ass_to_srt_italic() {
370        let c = make_converter();
371        assert_eq!(
372            c.convert_ass_tags_to_srt("{\\i1}Hello{\\i0}"),
373            "<i>Hello</i>"
374        );
375    }
376
377    #[test]
378    fn test_convert_ass_to_srt_underline() {
379        let c = make_converter();
380        assert_eq!(
381            c.convert_ass_tags_to_srt("{\\u1}Hello{\\u0}"),
382            "<u>Hello</u>"
383        );
384    }
385
386    #[test]
387    fn test_convert_ass_to_srt_empty() {
388        let c = make_converter();
389        assert_eq!(c.convert_ass_tags_to_srt(""), "");
390    }
391
392    #[test]
393    fn test_convert_ass_to_srt_no_tags() {
394        let c = make_converter();
395        assert_eq!(c.convert_ass_tags_to_srt("plain"), "plain");
396    }
397
398    #[test]
399    fn test_convert_ass_to_srt_multiple() {
400        let c = make_converter();
401        let result = c.convert_ass_tags_to_srt("{\\b1}bold{\\b0} and {\\i1}italic{\\i0}");
402        assert_eq!(result, "<b>bold</b> and <i>italic</i>");
403    }
404
405    // --- convert_srt_tags_to_vtt ---
406
407    #[test]
408    fn test_convert_srt_to_vtt_passthrough() {
409        let c = make_converter();
410        assert_eq!(c.convert_srt_tags_to_vtt("Hello"), "Hello");
411        assert_eq!(c.convert_srt_tags_to_vtt("<b>bold</b>"), "<b>bold</b>");
412        assert_eq!(c.convert_srt_tags_to_vtt(""), "");
413    }
414
415    // --- convert_vtt_tags_to_srt ---
416
417    #[test]
418    fn test_convert_vtt_to_srt_passthrough() {
419        let c = make_converter();
420        assert_eq!(c.convert_vtt_tags_to_srt("Hello"), "Hello");
421        assert_eq!(c.convert_vtt_tags_to_srt("<b>bold</b>"), "<b>bold</b>");
422        assert_eq!(c.convert_vtt_tags_to_srt(""), "");
423    }
424
425    // --- strip_vtt_tags ---
426
427    #[test]
428    fn test_strip_vtt_tags_basic() {
429        let c = make_converter();
430        assert_eq!(c.strip_vtt_tags("<b>Hello</b>"), "Hello");
431    }
432
433    #[test]
434    fn test_strip_vtt_tags_multiple() {
435        let c = make_converter();
436        assert_eq!(
437            c.strip_vtt_tags("<i>italic</i> and <b>bold</b>"),
438            "italic and bold"
439        );
440    }
441
442    #[test]
443    fn test_strip_vtt_tags_empty() {
444        let c = make_converter();
445        assert_eq!(c.strip_vtt_tags(""), "");
446    }
447
448    #[test]
449    fn test_strip_vtt_tags_no_tags() {
450        let c = make_converter();
451        assert_eq!(c.strip_vtt_tags("plain text"), "plain text");
452    }
453
454    #[test]
455    fn test_strip_vtt_tags_nested() {
456        let c = make_converter();
457        assert_eq!(c.strip_vtt_tags("<b><i>nested</i></b>"), "nested");
458    }
459
460    #[test]
461    fn test_strip_vtt_tags_with_attributes() {
462        let c = make_converter();
463        assert_eq!(
464            c.strip_vtt_tags(r##"<font color="red">text</font>"##),
465            "text"
466        );
467    }
468
469    #[test]
470    fn test_strip_vtt_tags_self_closing_like() {
471        let c = make_converter();
472        assert_eq!(c.strip_vtt_tags("<br>text"), "text");
473    }
474
475    // --- round-trip: SRT -> ASS -> strip ---
476
477    #[test]
478    fn test_roundtrip_srt_to_ass_strip() {
479        let c = make_converter();
480        let srt = "<b>Hello World</b>";
481        let ass = c.convert_srt_tags_to_ass(srt);
482        let stripped = c.strip_ass_tags(&ass);
483        assert_eq!(stripped, "Hello World");
484    }
485
486    #[test]
487    fn test_roundtrip_srt_to_ass_to_srt() {
488        let c = make_converter();
489        let original = "<b>text</b>";
490        let ass = c.convert_srt_tags_to_ass(original);
491        let back = c.convert_ass_tags_to_srt(&ass);
492        assert_eq!(back, original);
493    }
494
495    #[test]
496    fn test_roundtrip_italic_srt_ass_srt() {
497        let c = make_converter();
498        let original = "<i>text</i>";
499        let ass = c.convert_srt_tags_to_ass(original);
500        let back = c.convert_ass_tags_to_srt(&ass);
501        assert_eq!(back, original);
502    }
503
504    #[test]
505    fn test_roundtrip_underline_srt_ass_srt() {
506        let c = make_converter();
507        let original = "<u>text</u>";
508        let ass = c.convert_srt_tags_to_ass(original);
509        let back = c.convert_ass_tags_to_srt(&ass);
510        assert_eq!(back, original);
511    }
512}