aspasia/substation/ass/
data.rs

1use std::{borrow::Cow, fmt::Display, fs::File, io::BufReader, path::Path, str::FromStr};
2
3use buildstructor::Builder;
4use encoding_rs::Encoding;
5use encoding_rs_io::DecodeReaderBytesBuilder;
6
7use crate::{
8    encoding::detect_file_encoding,
9    errors::Error,
10    plain::PlainSubtitle,
11    subrip::convert::srt_to_ass_formatting,
12    substation::common::data::{SubStationEventKind, SubStationFont, SubStationGraphic},
13    traits::TimedSubtitle,
14    webvtt::convert::vtt_to_ass_formatting,
15    Moment, SsaSubtitle, SubRipSubtitle, Subtitle, TextEvent, TextEventInterface, TextSubtitle,
16    TimedEvent, TimedEventInterface, TimedMicroDvdSubtitle, TimedSubtitleFile, WebVttSubtitle,
17};
18
19use super::{convert::strip_formatting_tags, parse::parse_ass};
20
21/// Advanced SubStation Alpha v4+ (.ass) subtitle
22#[derive(Debug, Builder)]
23pub struct AssSubtitle {
24    /// Script info
25    script_info: AssScriptInfo,
26    // Store different event types separately so that we can return dialogue only without having to filter
27    /// Dialogue events
28    dialogue: Vec<AssEvent>,
29    /// Picture events
30    pictures: Vec<AssEvent>,
31    /// Sound events
32    sounds: Vec<AssEvent>,
33    /// Movie events
34    movies: Vec<AssEvent>,
35    /// Command events
36    commands: Vec<AssEvent>,
37    /// Styles
38    styles: Vec<AssStyle>,
39    /// Embedded font data
40    fonts: Vec<SubStationFont>,
41    /// Embedded graphics data
42    graphics: Vec<SubStationGraphic>,
43}
44
45/// Advanced Substation Alpha (.ass) event
46#[derive(Debug)]
47pub struct AssEvent {
48    /// Kind of event, for example dialogue
49    pub kind: SubStationEventKind,
50    /// Events with higher layers will be displayed over those on lower layers.
51    /// Events that share the same layer will use different rules to resolve collisions.
52    pub layer: i64,
53    /// Start time of event
54    pub start: Moment,
55    /// End time of event
56    pub end: Moment,
57    /// Style name for event
58    pub style: Option<String>,
59    /// Name of speaker, if relevant
60    pub name: Option<String>,
61    /// Left margin
62    pub margin_l: i64,
63    /// Right margin
64    pub margin_r: i64,
65    /// Vertical margin
66    pub margin_v: i64,
67    /// Effect
68    pub effect: Option<String>,
69    /// Associated text. For dialogue events, this is the text that is shown on screen.
70    /// For other events, this is the path to a media file or a command to run.
71    pub text: String,
72}
73
74/// Information for the `[ScriptInfo]` section of an Advanced SubStation Alpha (.ass) subtitle.
75///
76/// It should always be the first thing shown in an .ass format subtitle.
77#[derive(Debug, Builder)]
78pub struct AssScriptInfo {
79    /// Title/description for the subtitle
80    pub title: Option<String>,
81    /// Original author of subtitle
82    pub original_script: Option<String>,
83    /// Original translator of subtitle
84    pub original_translation: Option<String>,
85    /// Original editor of subtitle
86    pub original_editing: Option<String>,
87    /// Describes who originally timed the subtitle
88    pub original_timing: Option<String>,
89    /// Where subtitle should start from
90    pub synch_point: Option<String>,
91    /// Describes who updated the subtitle apart from the original creator(s)
92    pub script_updated_by: Option<String>,
93    /// Used to describe the details of what was updated, if the subtitle was updated
94    pub update_details: Option<String>,
95    /// Version of SubStation Alpha subtitle.
96    /// For Advanced SubStation Alpha (.ass) format subtitles, the value should be V4.00+
97    pub script_type: Option<String>,
98    /// Rules for resolving collision issues between on-screen content
99    pub collisions: Option<String>,
100    /// Height of screen
101    pub play_res_y: Option<String>,
102    /// Width of screen
103    pub play_res_x: Option<String>,
104    /// Colour depth
105    pub play_depth: Option<String>,
106    /// Time scale, with 100 representing the original speed
107    pub timer: Option<String>,
108    /// Defines wrapping rules for text
109    pub wrap_style: Option<String>,
110}
111
112/// Style in a .ass file
113#[derive(Debug)]
114pub struct AssStyle {
115    /// Name of style
116    pub name: String,
117    /// Name of font used to display text
118    pub fontname: String,
119    /// Font size of text
120    pub fontsize: i64,
121    /// Colour that text will be rendered as
122    pub primary_colour: String,
123    /// Colour that text will be rendered as in the case it is moved due to a collision
124    pub secondary_colour: String,
125    /// Colour that text will be rendered as in the case it is moved due to a collision with text that appears in the secondary colour
126    pub outline_colour: String,
127    /// Colour of text shadow or text outline
128    pub back_colour: String,
129    /// Whether text is bolded
130    pub bold: bool,
131    /// Whether text is italicised
132    pub italic: bool,
133    /// Whether text is underlined
134    pub underline: bool,
135    /// Whether text is stricken out
136    pub strike_out: bool,
137    /// Width scale of text
138    pub scale_x: i64,
139    /// Height scale of text
140    pub scale_y: i64,
141    /// Font spacing of text
142    pub spacing: i64,
143    /// Number of degrees to rotate text by
144    pub angle: f64,
145    /// Style of text border
146    pub border_style: i64,
147    /// Width of text outline
148    pub outline: i64,
149    /// Depth of text shadow
150    pub shadow: i64,
151    /// Alignment of text on screen
152    pub alignment: i64,
153    /// Left margin in pixels
154    pub margin_l: i64,
155    /// Right margin in pixels
156    pub margin_r: i64,
157    /// Vertical margin in pixels
158    pub margin_v: i64,
159    /// Encoding of text represented as a number
160    pub encoding: i64,
161}
162
163impl AssSubtitle {
164    /// Get list of picture events as a slice
165    #[must_use]
166    pub fn pictures(&self) -> &[AssEvent] {
167        self.pictures.as_slice()
168    }
169
170    /// Get list of picture events as a mutable slice
171    pub fn pictures_mut(&mut self) -> &mut [AssEvent] {
172        self.pictures.as_mut_slice()
173    }
174
175    /// Get picture event at given index
176    #[must_use]
177    pub fn picture(&self, index: usize) -> Option<&AssEvent> {
178        self.pictures.get(index)
179    }
180
181    /// Get mutable picture event at given index
182    pub fn picture_mut(&mut self, index: usize) -> Option<&mut AssEvent> {
183        self.pictures.get_mut(index)
184    }
185
186    /// Get list of sound events as a slice
187    #[must_use]
188    pub fn sounds(&self) -> &[AssEvent] {
189        self.sounds.as_slice()
190    }
191
192    /// Get list of sound events as a mutable slice
193    pub fn sounds_mut(&mut self) -> &mut [AssEvent] {
194        self.sounds.as_mut_slice()
195    }
196
197    /// Get sound event at specified index
198    #[must_use]
199    pub fn sound(&self, index: usize) -> Option<&AssEvent> {
200        self.sounds.get(index)
201    }
202
203    /// Get mutable sound event at specified index
204    pub fn sound_mut(&mut self, index: usize) -> Option<&mut AssEvent> {
205        self.sounds.get_mut(index)
206    }
207
208    /// Get list of movie events as a slice
209    #[must_use]
210    pub fn movies(&self) -> &[AssEvent] {
211        self.movies.as_slice()
212    }
213
214    /// Get list of movie events as a mutable slice
215    pub fn movies_mut(&mut self) -> &mut [AssEvent] {
216        self.movies.as_mut_slice()
217    }
218
219    /// Get movie event at specified index
220    #[must_use]
221    pub fn movie(&self, index: usize) -> Option<&AssEvent> {
222        self.movies.get(index)
223    }
224
225    /// Get mutable movie event at specified index
226    pub fn movie_mut(&mut self, index: usize) -> Option<&mut AssEvent> {
227        self.movies.get_mut(index)
228    }
229
230    /// Get list of command events as a slice
231    #[must_use]
232    pub fn commands(&self) -> &[AssEvent] {
233        self.commands.as_slice()
234    }
235
236    /// Get list of command events as a mutable slice
237    pub fn commands_mut(&mut self) -> &mut [AssEvent] {
238        self.commands.as_mut_slice()
239    }
240
241    /// Get command event at given index
242    #[must_use]
243    pub fn command(&self, index: usize) -> Option<&AssEvent> {
244        self.commands.get(index)
245    }
246
247    /// Get mutable command event at given index
248    pub fn command_mut(&mut self, index: usize) -> Option<&mut AssEvent> {
249        self.commands.get_mut(index)
250    }
251
252    /// Get script info struct
253    #[must_use]
254    pub fn script_info(&self) -> &AssScriptInfo {
255        &self.script_info
256    }
257
258    /// Get mutable script info struct
259    pub fn script_info_mut(&mut self) -> &mut AssScriptInfo {
260        &mut self.script_info
261    }
262
263    /// Get list of styles as a slice
264    #[must_use]
265    pub fn styles(&self) -> &[AssStyle] {
266        self.styles.as_slice()
267    }
268
269    /// Get list of styles as a mutable slice
270    pub fn styles_mut(&mut self) -> &mut [AssStyle] {
271        self.styles.as_mut_slice()
272    }
273
274    /// Get list of fonts as a slice
275    #[must_use]
276    pub fn fonts(&self) -> &[SubStationFont] {
277        self.fonts.as_slice()
278    }
279
280    /// Get list of fonts as a mutable slice
281    pub fn fonts_mut(&mut self) -> &mut [SubStationFont] {
282        self.fonts.as_mut_slice()
283    }
284
285    /// Get list of graphics as a slice
286    #[must_use]
287    pub fn graphics(&self) -> &[SubStationGraphic] {
288        self.graphics.as_slice()
289    }
290
291    /// Get list of graphics as a mutable slice
292    pub fn graphics_mut(&mut self) -> &mut [SubStationGraphic] {
293        self.graphics.as_mut_slice()
294    }
295
296    fn open_file_with_encoding(
297        path: &Path,
298        encoding: Option<&'static Encoding>,
299    ) -> Result<Self, Error> {
300        let file = File::open(path)?;
301        let transcoded = DecodeReaderBytesBuilder::new()
302            .encoding(encoding)
303            .build(file);
304        let reader = BufReader::new(transcoded);
305
306        Ok(parse_ass(reader))
307    }
308}
309
310impl Subtitle for AssSubtitle {
311    type Event = AssEvent;
312
313    fn from_path_with_encoding(
314        path: impl AsRef<Path>,
315        encoding: Option<&'static Encoding>,
316    ) -> Result<Self, Error> {
317        let mut enc = encoding.or_else(|| detect_file_encoding(path.as_ref(), Some(40)).ok());
318        let mut result = Self::open_file_with_encoding(path.as_ref(), enc);
319
320        if encoding.is_none() && result.is_err() {
321            enc = encoding.or_else(|| detect_file_encoding(path.as_ref(), None).ok());
322            result = Self::open_file_with_encoding(path.as_ref(), enc);
323        }
324
325        result
326    }
327
328    fn events(&self) -> &[AssEvent] {
329        self.dialogue.as_slice()
330    }
331
332    fn events_mut(&mut self) -> &mut [AssEvent] {
333        self.dialogue.as_mut_slice()
334    }
335}
336
337impl TextSubtitle for AssSubtitle {
338    /// Strip formatting tags from lines in addition to deleting styles
339    fn strip_formatting(&mut self) {
340        for event in self.events_mut() {
341            event.strip_formatting();
342        }
343        self.styles.clear();
344    }
345}
346
347impl TimedSubtitle for AssSubtitle {}
348
349impl Display for AssSubtitle {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        writeln!(f, "{}", self.script_info)?;
352        writeln!(f)?;
353        if !self.styles.is_empty() {
354            writeln!(f, "[V4+ Styles]")?;
355            writeln!(f, "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding")?;
356            for style in &self.styles {
357                writeln!(f, "{style}")?;
358            }
359            writeln!(f)?;
360        }
361        if !self.fonts.is_empty() {
362            writeln!(f)?;
363            for font in &self.fonts {
364                writeln!(f, "{font}")?;
365            }
366        }
367        if !self.graphics.is_empty() {
368            writeln!(f)?;
369            for graphic in &self.graphics {
370                writeln!(f, "{graphic}")?;
371            }
372        }
373        if !self.dialogue.is_empty() {
374            writeln!(f, "[Events]")?;
375            writeln!(
376                f,
377                "Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text"
378            )?;
379            for event in &self.dialogue {
380                writeln!(f, "{event}")?;
381            }
382            for event in &self.pictures {
383                writeln!(f, "{event}")?;
384            }
385            for event in &self.sounds {
386                writeln!(f, "{event}")?;
387            }
388            for event in &self.movies {
389                writeln!(f, "{event}")?;
390            }
391            for event in &self.commands {
392                writeln!(f, "{event}")?;
393            }
394        }
395
396        Ok(())
397    }
398}
399
400impl FromStr for AssSubtitle {
401    type Err = Error;
402
403    fn from_str(s: &str) -> Result<Self, Self::Err> {
404        let reader = BufReader::new(s.as_bytes());
405
406        Ok(parse_ass(reader))
407    }
408}
409
410impl Default for AssSubtitle {
411    fn default() -> Self {
412        Self::builder()
413            .script_info(AssScriptInfo::default())
414            .build()
415    }
416}
417
418impl From<&TimedMicroDvdSubtitle> for AssSubtitle {
419    fn from(value: &TimedMicroDvdSubtitle) -> Self {
420        AssSubtitle::builder()
421            .script_info(AssScriptInfo::default())
422            .dialogue(
423                value
424                    .events()
425                    .iter()
426                    .map(|line| AssEvent {
427                        kind: SubStationEventKind::Dialogue,
428                        layer: 0,
429                        start: line.start,
430                        end: line.end,
431                        style: None,
432                        name: None,
433                        margin_l: 0,
434                        margin_r: 0,
435                        margin_v: 0,
436                        effect: None,
437                        text: line.text.replace('|', "\\N"),
438                    })
439                    .collect(),
440            )
441            .build()
442    }
443}
444
445// TODO: convert styles, etc
446impl From<&SsaSubtitle> for AssSubtitle {
447    fn from(value: &SsaSubtitle) -> Self {
448        AssSubtitle::builder()
449            .script_info(AssScriptInfo::default())
450            .dialogue(
451                value
452                    .events()
453                    .iter()
454                    .map(|event| AssEvent {
455                        kind: SubStationEventKind::Dialogue,
456                        layer: 0,
457                        start: event.start,
458                        end: event.end,
459                        style: event.style.clone(),
460                        name: event.name.clone(),
461                        margin_l: event.margin_l,
462                        margin_r: event.margin_r,
463                        margin_v: event.margin_v,
464                        effect: event.effect.clone(),
465                        text: event.text.clone(),
466                    })
467                    .collect(),
468            )
469            .build()
470    }
471}
472
473impl From<&SubRipSubtitle> for AssSubtitle {
474    /// Convert SubRip (.srt) subtitle to .ass format.
475    ///
476    /// This will replace newlines and convert HTML formatting tags (`<b>`, `<i>`, etc.) into .ass style formatting tags
477    fn from(value: &SubRipSubtitle) -> Self {
478        AssSubtitle::builder()
479            .script_info(AssScriptInfo::default())
480            .dialogue(
481                value
482                    .events()
483                    .iter()
484                    .map(|line| {
485                        let mut text = line.text.replace('\n', "\\N");
486                        if let Ok((_, converted)) = srt_to_ass_formatting(text.as_str()) {
487                            text = converted;
488                        }
489                        AssEvent {
490                            kind: SubStationEventKind::Dialogue,
491                            layer: 0,
492                            start: line.start,
493                            end: line.end,
494                            style: None,
495                            name: None,
496                            margin_l: 0,
497                            margin_r: 0,
498                            margin_v: 0,
499                            effect: None,
500                            text,
501                        }
502                    })
503                    .collect(),
504            )
505            .build()
506    }
507}
508
509impl From<&WebVttSubtitle> for AssSubtitle {
510    /// Convert WebVTT (.vtt) format subtitle to .ass format.
511    ///
512    /// For each line, this will convert newlines into the appropriate representation,
513    /// and it will also convert the basic HTML formatting tags (`<b>`, `<i>`, and `<u>`).
514    ///
515    /// All other tags and styles are discarded.
516    fn from(value: &WebVttSubtitle) -> Self {
517        AssSubtitle::builder()
518            .script_info(AssScriptInfo::builder().and_title(value.header()).build())
519            .dialogue(
520                value
521                    .events()
522                    .iter()
523                    .map(|cue| {
524                        let mut text = cue.text.replace('\n', "\\N");
525                        if let Ok((_, converted)) = vtt_to_ass_formatting(text.as_str()) {
526                            text = converted;
527                        }
528                        AssEvent {
529                            kind: SubStationEventKind::Dialogue,
530                            layer: 0,
531                            start: cue.start,
532                            end: cue.end,
533                            style: None,
534                            name: None,
535                            margin_l: 0,
536                            margin_r: 0,
537                            margin_v: 0,
538                            effect: None,
539                            text,
540                        }
541                    })
542                    .collect(),
543            )
544            .build()
545    }
546}
547
548impl From<TimedMicroDvdSubtitle> for AssSubtitle {
549    fn from(value: TimedMicroDvdSubtitle) -> Self {
550        Self::from(&value)
551    }
552}
553
554impl From<SsaSubtitle> for AssSubtitle {
555    fn from(value: SsaSubtitle) -> Self {
556        Self::from(&value)
557    }
558}
559
560impl From<SubRipSubtitle> for AssSubtitle {
561    fn from(value: SubRipSubtitle) -> Self {
562        Self::from(&value)
563    }
564}
565
566impl From<WebVttSubtitle> for AssSubtitle {
567    fn from(value: WebVttSubtitle) -> Self {
568        Self::from(&value)
569    }
570}
571
572impl From<TimedSubtitleFile> for AssSubtitle {
573    fn from(value: TimedSubtitleFile) -> Self {
574        match value {
575            TimedSubtitleFile::Ass(data) => data,
576            TimedSubtitleFile::MicroDvd(data) => data.into(),
577            TimedSubtitleFile::Ssa(data) => data.into(),
578            TimedSubtitleFile::SubRip(data) => data.into(),
579            TimedSubtitleFile::WebVtt(data) => data.into(),
580        }
581    }
582}
583
584impl From<PlainSubtitle> for AssSubtitle {
585    fn from(value: PlainSubtitle) -> Self {
586        AssSubtitle::builder()
587            .script_info(AssScriptInfo::default())
588            .dialogue(
589                value
590                    .events()
591                    .iter()
592                    .map(|event| AssEvent {
593                        kind: SubStationEventKind::Dialogue,
594                        layer: 0,
595                        start: event.start,
596                        end: event.end,
597                        style: None,
598                        name: None,
599                        margin_l: 0,
600                        margin_r: 0,
601                        margin_v: 0,
602                        effect: None,
603                        text: event.text.replace('\n', "\\N"),
604                    })
605                    .collect(),
606            )
607            .build()
608    }
609}
610
611impl TextEvent for AssEvent {
612    fn unformatted_text(&self) -> Cow<'_, String> {
613        let Ok((_, stripped)) = strip_formatting_tags(self.text.as_str()) else {
614            return Cow::Borrowed(&self.text);
615        };
616
617        Cow::Owned(stripped)
618    }
619
620    fn as_plaintext(&self) -> Cow<'_, String> {
621        Cow::Owned(self.unformatted_text().replace("\\N", "\n"))
622    }
623}
624
625impl TimedEvent for AssEvent {}
626
627impl TextEventInterface for AssEvent {
628    fn text(&self) -> String {
629        self.text.clone()
630    }
631
632    fn set_text(&mut self, text: String) {
633        self.text = text;
634    }
635}
636
637impl TimedEventInterface for AssEvent {
638    fn start(&self) -> Moment {
639        self.start
640    }
641
642    fn end(&self) -> Moment {
643        self.end
644    }
645
646    fn set_start(&mut self, moment: Moment) {
647        self.start = moment;
648    }
649
650    fn set_end(&mut self, moment: Moment) {
651        self.end = moment;
652    }
653}
654
655impl Display for AssEvent {
656    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657        write!(
658            f,
659            "{}: {},{},{},{},{},{},{},{},{},{}",
660            self.kind,
661            self.layer,
662            self.start.as_substation_timestamp(),
663            self.end.as_substation_timestamp(),
664            self.style.as_deref().unwrap_or_default(),
665            self.name.as_deref().unwrap_or_default(),
666            self.margin_l,
667            self.margin_r,
668            self.margin_v,
669            self.effect.as_deref().unwrap_or_default(),
670            self.text
671        )
672    }
673}
674
675impl Display for AssScriptInfo {
676    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677        write!(f, "[Script Info]")?;
678        if let Some(title) = &self.title {
679            write!(f, "\nTitle: {title}")?;
680        }
681        if let Some(original_script) = &self.original_script {
682            write!(f, "\nOriginal Script: {original_script}")?;
683        }
684        if let Some(original_translation) = &self.original_translation {
685            write!(f, "\nOriginal Translation: {original_translation}")?;
686        }
687        if let Some(original_editing) = &self.original_editing {
688            write!(f, "\nOriginal Editing: {original_editing}")?;
689        }
690        if let Some(original_timing) = &self.original_timing {
691            write!(f, "\nOriginal Timing: {original_timing}")?;
692        }
693        if let Some(synch_point) = &self.synch_point {
694            write!(f, "\nSynch Point: {synch_point}")?;
695        }
696        if let Some(script_updated_by) = &self.script_updated_by {
697            write!(f, "\nScript Updated By: {script_updated_by}")?;
698        }
699        if let Some(update_details) = &self.update_details {
700            write!(f, "\nUpdate Details: {update_details}")?;
701        }
702        if let Some(script_type) = &self.script_type {
703            write!(f, "\nScript Type: {script_type}")?;
704        }
705        if let Some(collisions) = &self.collisions {
706            write!(f, "\nCollisions: {collisions}")?;
707        }
708        if let Some(play_res_y) = &self.play_res_y {
709            write!(f, "\nPlayResY: {play_res_y}")?;
710        }
711        if let Some(play_res_x) = &self.play_res_x {
712            write!(f, "\nPlayResX: {play_res_x}")?;
713        }
714        if let Some(play_depth) = &self.play_depth {
715            write!(f, "\nPlayDepth: {play_depth}")?;
716        }
717        if let Some(timer) = &self.timer {
718            write!(f, "\nTimer: {timer}")?;
719        }
720        if let Some(wrap_style) = &self.wrap_style {
721            write!(f, "\nWrapStyle: {wrap_style}")?;
722        }
723
724        Ok(())
725    }
726}
727
728impl Default for AssScriptInfo {
729    fn default() -> Self {
730        AssScriptInfo::builder().script_type("v4.00+").build()
731    }
732}
733
734impl Display for AssStyle {
735    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
736        write!(
737            f,
738            "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
739            self.name,
740            self.fontname,
741            self.fontsize,
742            self.primary_colour,
743            self.secondary_colour,
744            self.outline_colour,
745            self.back_colour,
746            self.bold,
747            self.italic,
748            self.underline,
749            self.strike_out,
750            self.scale_x,
751            self.scale_y,
752            self.spacing,
753            self.angle,
754            self.border_style,
755            self.outline,
756            self.shadow,
757            self.alignment,
758            self.margin_l,
759            self.margin_r,
760            self.margin_v,
761            self.encoding
762        )
763    }
764}