rsubs_lib/
ssa.rs

1//! Implements helpers for `.ass` and `.ssa`.
2//!
3//! It describes the [SSAFile], [SSAEvent] and [SSAStyle] structs and
4//! provides the [parse] function.
5
6use regex::Regex;
7use std::collections::HashMap;
8use std::fmt::Display;
9
10use crate::util::Alignment;
11use serde::{Deserialize, Serialize};
12
13use crate::error;
14use crate::ssa::parse::TIME_FORMAT;
15use crate::util::Color;
16use crate::vtt::VTT;
17use time::Time;
18
19use super::srt::{SRTLine, SRT};
20
21/// [SSAInfo] contains headers and general information about the script.
22#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
23pub struct SSAInfo {
24    /// Description of the script.
25    pub title: Option<String>,
26    /// Original author(s) of the script.
27    pub original_script: Option<String>,
28    /// Original translator of the dialogue.
29    pub original_translation: Option<String>,
30    /// Original script editor(s).
31    pub original_editing: Option<String>,
32    /// Whoever timed the original script
33    pub original_timing: Option<String>,
34    /// Description of where in the video the script should begin playback.
35    pub synch_point: Option<String>,
36    /// Names of any other subtitling groups who edited the original script.
37    pub script_update_by: Option<String>,
38    /// The details of any updates to the original script - made by other subtitling groups
39    pub update_details: Option<String>,
40    /// The SSA script format version.
41    pub script_type: Option<String>,
42    /// Determines how subtitles are moved, when automatically preventing onscreen collisions.
43    /// Allowed values:
44    /// - `Normal`: SSA will attempt to position subtitles in the position specified by the
45    ///   "margins". However, subtitles can be shifted vertically to prevent onscreen collisions.
46    ///   With "normal" collision prevention, the subtitles will "stack up" one above the other -
47    ///   but they will always be positioned as close the vertical (bottom) margin as possible -
48    ///   filling in "gaps" in other subtitles if one large enough is available.
49    /// - `Reverse`: Subtitles will be shifted upwards to make room for subsequent overlapping
50    ///   subtitles. This means the subtitles can nearly always be read top-down - but it also means
51    ///   that the first subtitle can appear halfway up the screen before the subsequent overlapping
52    ///   subtitles appear. It can use a lot of screen area.
53    pub collisions: Option<String>,
54    /// The height of the screen used by the script's author(s) when playing the script.
55    pub play_res_y: Option<u32>,
56    /// The width of the screen used by the script's author(s) when playing the script.
57    pub play_res_x: Option<u32>,
58    /// The color depth used by the script's author(s) when playing the script.
59    pub play_depth: Option<u32>,
60    /// The Timer Speed for the script, as percentage. So `100` == `100%`.
61    pub timer: Option<f32>,
62    /// Defines the default wrapping style.
63    /// Allowed values are:
64    /// - `0`: smart wrapping, lines are evenly broken
65    /// - `1`: end-of-line word wrapping, only \N breaks
66    /// - `2`: no word wrapping, \n \N both breaks
67    /// - `3`: same as 0, but lower line gets wider
68    pub wrap_style: Option<u8>,
69
70    /// Additional fields that aren't covered by the ASS spec.
71    pub additional_fields: HashMap<String, String>,
72}
73impl Eq for SSAInfo {}
74
75/// [SSAStyle] describes each part of the `Format: ` side of a `.ssa` or `.ass` subtitle.
76///
77/// Currently only supports `.ass`, more precisely `ScriptType: V4.00+` and `[V4+ Styles]`
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct SSAStyle {
80    /// Name of the style. Case-sensitive. Cannot include commas.
81    pub name: String,
82    /// Fontname as used by Windows. Case-sensitive.
83    pub fontname: String,
84    /// Fontsize.
85    pub fontsize: f32,
86    /// The color that a subtitle will normally appear in.
87    pub primary_color: Option<Color>,
88    /// This color may be used instead of the Primary colour when a subtitle is automatically
89    /// shifted to prevent an onscreen collision, to distinguish the different subtitles.
90    pub secondary_color: Option<Color>,
91    /// This color may be used instead of the Primary or Secondary colour when a subtitle is
92    /// automatically shifted to prevent an onscreen collision, to distinguish the different
93    /// subtitles.
94    pub outline_color: Option<Color>,
95    /// The color of the subtitle outline or shadow.
96    pub back_color: Option<Color>,
97    /// Defines whether text is bold or not.
98    pub bold: bool,
99    /// Defines whether text is italic or not.
100    pub italic: bool,
101    /// Defines whether text is underlined or not.
102    pub underline: bool,
103    /// Defines whether text is strikeout or not.
104    pub strikeout: bool,
105    /// Modifies the width of the font. Value is percentage.
106    pub scale_x: f32,
107    /// Modifies the height of the font. Value is percentage.
108    pub scale_y: f32,
109    /// Extra space between characters (in pixels).
110    pub spacing: f32,
111    /// Origin of the rotation is defined by the alignment (as degrees).
112    pub angle: f32,
113    /// Border style.
114    /// Allowed values are:
115    /// - `1`: Outline + drop shadow
116    /// - `3`: Opaque box
117    pub border_style: u8,
118    /// If [SSAStyle::border_style] is `1`, then this specifies the width of the outline around the
119    /// text (in pixels).
120    /// Values may be `0`, `1`, `2`, `3` or `4`.
121    pub outline: f32,
122    /// If [SSAStyle::border_style] is `1`, then this specifies the depth of the drop shadow behind
123    /// the text (in pixels). Values may be `0`, `1`, `2`, `3` or `4`. Drop shadow is always used in
124    /// addition to an outline - SSA will force an outline of 1 pixel if no outline width is given.
125    pub shadow: f32,
126    /// Sets how text is "justified" within the Left/Right onscreen margins, and also the vertical
127    /// placing.
128    pub alignment: Alignment,
129    /// Defines the Left Margin in pixels.
130    pub margin_l: f32,
131    /// Defines the Right Margin in pixels.
132    pub margin_r: f32,
133    /// Defines the Vertical Left Margin in pixels.
134    pub margin_v: f32,
135    /// Specifies the font character set or encoding and on multilingual Windows installations it
136    /// provides access to characters used in multiple than one language. It is usually 0 (zero)
137    /// for English (Western, ANSI) Windows.
138    pub encoding: f32,
139}
140impl Eq for SSAStyle {}
141
142impl Default for SSAStyle {
143    fn default() -> Self {
144        SSAStyle {
145            name: "Default".to_string(),
146            fontname: "Trebuchet MS".to_string(),
147            fontsize: 25.5,
148            primary_color: None,
149            secondary_color: None,
150            outline_color: None,
151            back_color: None,
152            bold: false,
153            italic: false,
154            underline: false,
155            strikeout: false,
156            scale_x: 120.0,
157            scale_y: 120.0,
158            spacing: 0.0,
159            angle: 0.0,
160            border_style: 1,
161            outline: 1.0,
162            shadow: 1.0,
163            alignment: Alignment::BottomCenter,
164            margin_l: 0.0,
165            margin_r: 0.0,
166            margin_v: 20.0,
167            encoding: 0.0,
168        }
169    }
170}
171
172#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
173pub enum SSAEventLineType {
174    Dialogue,
175    Comment,
176    Other(String),
177}
178
179/// Describes each individual element of an `Event` line in the `.ass` format
180///
181/// Each element can be individually changed.
182///
183/// Because of its comma separated values in the event line, the timestamp looks like
184/// `00:00:20.00` and it can be represented using [Time::to_ass_string]
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct SSAEvent {
187    /// Subtitles having different layer number will be ignored during the collusion detection.
188    /// Higher numbered layers will be drawn over the lower numbered.
189    pub layer: u32,
190    /// Start time of the line being displayed.
191    pub start: Time,
192    /// End time of the line being displayed
193    pub end: Time,
194    /// String value relating to an [SSAStyle].
195    pub style: String,
196    /// Generally this is used for "speaker name", in most cases it's an unused field.
197    pub name: String,
198    /// SSA/ASS documentation describes the l/r/v margins as being floats so...here goes
199    /// In practice it gets represented as `0020` and similar `{:0>4}` patterns.
200    pub margin_l: f32,
201    /// SSA/ASS documentation describes the l/r/v margins as being floats so...here goes
202    /// In practice it gets represented as `0020` and similar `{:0>4}` patterns.
203    pub margin_r: f32,
204    /// SSA/ASS documentation describes the l/r/v margins as being floats so...here goes
205    /// In practice it gets represented as `0020` and similar `{:0>4}` patterns.
206    pub margin_v: f32,
207    /// SSA Documentation describes it, it's here, no idea what it does, but you can write it if you
208    /// wish.
209    pub effect: String,
210    /// The line's text.
211    pub text: String,
212    pub line_type: SSAEventLineType,
213}
214impl Eq for SSAEvent {}
215
216impl Default for SSAEvent {
217    fn default() -> Self {
218        SSAEvent {
219            layer: 0,
220            start: Time::from_hms(0, 0, 0).unwrap(),
221            end: Time::from_hms(0, 0, 0).unwrap(),
222            style: "Default".to_string(),
223            name: "".to_string(),
224            margin_l: 0.0,
225            margin_r: 0.0,
226            margin_v: 0.0,
227            effect: "".to_string(),
228            text: "".to_string(),
229            line_type: SSAEventLineType::Dialogue,
230        }
231    }
232}
233/// Contains the styles, events and info as well as a format mentioning whether it's `.ass` or `.ssa`
234#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
235pub struct SSA {
236    pub info: SSAInfo,
237    pub styles: Vec<SSAStyle>,
238    pub events: Vec<SSAEvent>,
239    pub fonts: Vec<String>,
240    pub graphics: Vec<String>,
241}
242
243impl SSA {
244    /// Parses the given [String] into [SSA].
245    pub fn parse<S: AsRef<str>>(content: S) -> Result<SSA, SSAError> {
246        let mut line_num = 0;
247
248        let mut blocks = vec![vec![]];
249        for line in content.as_ref().lines() {
250            if line.trim().is_empty() {
251                blocks.push(vec![])
252            } else {
253                blocks.last_mut().unwrap().push(line)
254            }
255        }
256
257        let mut ssa = SSA::default();
258
259        if blocks[0].first().is_some_and(|l| *l == "[Script Info]") {
260            line_num += 1;
261            let mut block = blocks.remove(0);
262            let block_len = block.len();
263            block.remove(0);
264            ssa.info = parse::parse_script_info_block(block.into_iter())
265                .map_err(|e| SSAError::new(e.kind, line_num + e.line))?;
266            line_num += block_len
267        } else {
268            return Err(SSAError::new(SSAErrorKind::Invalid, 1));
269        }
270
271        for mut block in blocks {
272            line_num += 1;
273
274            if block.is_empty() {
275                return Err(SSAError::new(SSAErrorKind::EmptyBlock, line_num));
276            }
277
278            let block_len = block.len();
279
280            match block.remove(0) {
281                "[V4+ Styles]" => {
282                    ssa.styles = parse::parse_style_block(block.into_iter())
283                        .map_err(|e| SSAError::new(e.kind, line_num + e.line))?
284                }
285                "[Events]" => {
286                    ssa.events = parse::parse_events_block(block.into_iter())
287                        .map_err(|e| SSAError::new(e.kind, line_num + e.line))?
288                }
289                "[Fonts]" => {
290                    ssa.fonts = parse::parse_fonts_block(block.into_iter())
291                        .map_err(|e| SSAError::new(e.kind, line_num + e.line))?
292                }
293                "[Graphics]" => {
294                    ssa.graphics = parse::parse_graphics_block(block.into_iter())
295                        .map_err(|e| SSAError::new(e.kind, line_num + e.line))?
296                }
297                _ => continue,
298            }
299
300            line_num += block_len
301        }
302
303        Ok(ssa)
304    }
305
306    /// Converts the SSAFile to a SRTFile. Due to `.srt` being a far less complex
307    /// format, most styles are being ignored.
308    ///
309    /// Styling of the text can happen with `{i1}aaa{i0}` tags where `i` represents
310    ///  the style and `0`/`1` represent the on/off triggers.
311    ///
312    /// `.srt` supports HTML-like tags for `i`,`b`,`u`, representing italic, bold, underline.
313    ///
314    /// If found, ssa specific triggers for those supported tags are replaced with their `.srt` alternatives.
315    ///
316    pub fn to_srt(&self) -> SRT {
317        let style_remove_regex = Regex::new(r"(?m)\{\\.+?}").unwrap();
318
319        let mut lines = vec![];
320
321        for (i, event) in self.events.iter().enumerate() {
322            let mut text = event
323                .text
324                .replace("{\\b1}", "<b>")
325                .replace("{\\b0}", "</b>")
326                .replace("{\\i1}", "<i>")
327                .replace("{\\i0}", "</i>")
328                .replace("{\\u1}", "<u>")
329                .replace("{\\u0}", "</u>")
330                .replace("\\N", "\r\n");
331
332            if !event.style.is_empty() {
333                if let Some(style) = self.styles.iter().find(|s| s.name == event.style) {
334                    if style.bold {
335                        text = format!("<b>{text}</b>")
336                    }
337                    if style.italic {
338                        text = format!("<i>{text}</i>")
339                    }
340                    if style.underline {
341                        text = format!("<u>{text}</u>")
342                    }
343                }
344            }
345
346            lines.push(SRTLine {
347                sequence_number: i as u32 + 1,
348                start: event.start,
349                end: event.end,
350                text: style_remove_regex.replace_all(&text, "").to_string(),
351            })
352        }
353
354        SRT { lines }
355    }
356    /// Converts the SSAFile to a VTTFile.
357    ///
358    /// Styling of the text can happen with `{i1}aaa{i0}` tags where `i` represents
359    ///  the style and `0`/`1` represent the on/off triggers.
360    ///
361    /// `.vtt` supports HTML-like tags for `i`,`b`,`u`, representing italic, bold, underline.
362    ///
363    /// If found, ssa specific triggers for those supported tags are replaced with their `.vtt` alternatives.
364    ///
365    /// In addition, if an SSAEvent has a related SSAStyle, the SSAStyle is converted to a VTTStyle that will be wrapped around the lines indicating it.
366    pub fn to_vtt(self) -> VTT {
367        self.to_srt().to_vtt()
368    }
369}
370
371impl Display for SSA {
372    #[rustfmt::skip]
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        let mut lines = vec![];
375
376        lines.push("[Script Info]".to_string());
377        lines.extend(self.info.title.as_ref().map(|l| format!("Title: {l}")));
378        lines.extend(self.info.original_script.as_ref().map(|l| format!("Original Script: {l}")));
379        lines.extend(self.info.original_translation.as_ref().map(|l| format!("Original Translation: {l}")));
380        lines.extend(self.info.original_editing.as_ref().map(|l| format!("Original Editing: {l}")));
381        lines.extend(self.info.original_timing.as_ref().map(|l| format!("Original Timing: {l}")));
382        lines.extend(self.info.synch_point.as_ref().map(|l| format!("Synch Point: {l}")));
383        lines.extend(self.info.script_update_by.as_ref().map(|l| format!("Script Updated By: {l}")));
384        lines.extend(self.info.update_details.as_ref().map(|l| format!("Update Details: {l}")));
385        lines.extend(self.info.script_type.as_ref().map(|l| format!("Script Type: {l}")));
386        lines.extend(self.info.collisions.as_ref().map(|l| format!("Collisions: {l}")));
387        lines.extend(self.info.play_res_y.map(|l| format!("PlayResY: {l}")));
388        lines.extend(self.info.play_res_x.map(|l| format!("PlayResX: {l}")));
389        lines.extend(self.info.play_depth.map(|l| format!("PlayDepth: {l}")));
390        lines.extend(self.info.timer.map(|l| format!("Timer: {l}")));
391        lines.extend(self.info.wrap_style.map(|l| format!("WrapStyle: {l}")));
392        for (k, v) in &self.info.additional_fields {
393            lines.push(format!("{k}: {v}"))
394        }
395
396        lines.push("".to_string());
397        lines.push("[V4+ Styles]".to_string());
398        lines.push("Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding".to_string());
399        for style in &self.styles {
400            let line = [
401                style.name.to_string(),
402                style.fontname.to_string(),
403                style.fontsize.to_string(),
404                style.primary_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
405                style.secondary_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
406                style.outline_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
407                style.back_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
408                if style.bold { "-1" } else { "0" }.to_string(),
409                if style.italic { "-1" } else { "0" }.to_string(),
410                if style.underline { "-1" } else { "0" }.to_string(),
411                if style.strikeout { "-1" } else { "0" }.to_string(),
412                style.scale_x.to_string(),
413                style.scale_y.to_string(),
414                style.spacing.to_string(),
415                style.angle.to_string(),
416                style.border_style.to_string(),
417                style.outline.to_string(),
418                style.shadow.to_string(),
419                (style.alignment as u8).to_string(),
420                style.margin_l.to_string(),
421                style.margin_r.to_string(),
422                style.margin_v.to_string(),
423                style.encoding.to_string(),
424            ];
425            lines.push(format!("Style: {}", line.join(",")))
426        }
427
428        lines.push("".to_string());
429        lines.push("[Events]".to_string());
430        lines.push("Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text".to_string());
431        for event in &self.events {
432            let line = [
433                event.layer.to_string(),
434                event.start.format(TIME_FORMAT).unwrap(),
435                event.end.format(TIME_FORMAT).unwrap(),
436                event.style.to_string(),
437                event.name.to_string(),
438                event.margin_l.to_string(),
439                event.margin_r.to_string(),
440                event.margin_v.to_string(),
441                event.effect.to_string(),
442                event.text.to_string()
443            ];
444            lines.push(format!("Dialogue: {}", line.join(",")))
445        }
446
447        write!(f, "{}", lines.join("\n"))
448    }
449}
450
451error! {
452    SSAError => SSAErrorKind {
453        Invalid,
454        EmptyBlock,
455        Parse(String),
456        MissingHeader(String),
457    }
458}
459
460mod parse {
461    use super::*;
462    use std::num::{ParseFloatError, ParseIntError};
463    use time::format_description::BorrowedFormatItem;
464    use time::macros::format_description;
465
466    pub(super) struct Error {
467        pub(super) line: usize,
468        pub(super) kind: SSAErrorKind,
469    }
470
471    pub(super) const TIME_FORMAT: &[BorrowedFormatItem] =
472        format_description!("[hour padding:none]:[minute]:[second].[subsecond digits:2]");
473
474    type Result<T> = std::result::Result<T, Error>;
475
476    pub(super) fn parse_script_info_block<'a, I: Iterator<Item = &'a str>>(
477        block_lines: I,
478    ) -> Result<SSAInfo> {
479        let mut info = SSAInfo::default();
480
481        for (i, line) in block_lines.enumerate() {
482            if line.starts_with(';') {
483                continue;
484            }
485
486            let Some((name, mut value)) = line.split_once(':') else {
487                return Err(Error {
488                    line: 1 + i,
489                    kind: SSAErrorKind::Parse("delimiter ':' missing".to_string()),
490                });
491            };
492            value = value.trim();
493
494            if value.is_empty() {
495                continue;
496            }
497
498            match name {
499                "Title" => info.title = Some(value.to_string()),
500                "Original Script" => info.original_script = Some(value.to_string()),
501                "Original Translation" => info.original_translation = Some(value.to_string()),
502                "Original Editing" => info.original_editing = Some(value.to_string()),
503                "Original Timing" => info.original_timing = Some(value.to_string()),
504                "Synch Point" => info.synch_point = Some(value.to_string()),
505                "Script Updated By" => info.script_update_by = Some(value.to_string()),
506                "Update Details" => info.update_details = Some(value.to_string()),
507                "ScriptType" => info.script_type = Some(value.to_string()),
508                "Collisions" => info.collisions = Some(value.to_string()),
509                "PlayResY" => {
510                    info.play_res_y = value.parse::<u32>().map(Some).map_err(|e| Error {
511                        line: 1 + i,
512                        kind: SSAErrorKind::Parse(e.to_string()),
513                    })?
514                }
515                "PlayResX" => {
516                    info.play_res_x = value.parse::<u32>().map(Some).map_err(|e| Error {
517                        line: 1 + i,
518                        kind: SSAErrorKind::Parse(e.to_string()),
519                    })?
520                }
521                "PlayDepth" => {
522                    info.play_depth = value.parse::<u32>().map(Some).map_err(|e| Error {
523                        line: 1 + i,
524                        kind: SSAErrorKind::Parse(e.to_string()),
525                    })?
526                }
527                "Timer" => {
528                    info.timer = value.parse::<f32>().map(Some).map_err(|e| Error {
529                        line: 1 + i,
530                        kind: SSAErrorKind::Parse(e.to_string()),
531                    })?
532                }
533                "WrapStyle" => {
534                    info.wrap_style = value.parse::<u8>().map(Some).map_err(|e| Error {
535                        line: 1 + i,
536                        kind: SSAErrorKind::Parse(e.to_string()),
537                    })?
538                }
539                _ => {
540                    info.additional_fields
541                        .insert(name.to_string(), value.to_string());
542                }
543            }
544        }
545
546        Ok(info)
547    }
548
549    pub(super) fn parse_style_block<'a, I: Iterator<Item = &'a str>>(
550        mut block_lines: I,
551    ) -> Result<Vec<SSAStyle>> {
552        let mut header_line = 1;
553        let header = loop {
554            let Some(line) = block_lines.next() else {
555                return Err(Error {
556                    line: 1,
557                    kind: SSAErrorKind::EmptyBlock,
558                });
559            };
560            if !line.starts_with(';') {
561                break line.to_string();
562            }
563            header_line += 1;
564        };
565        let Some(header) = header.strip_prefix("Format:") else {
566            return Err(Error {
567                line: header_line,
568                kind: SSAErrorKind::Parse("styles header must start with 'Format:'".to_string()),
569            });
570        };
571        let headers = header.trim().split(',').collect();
572
573        let mut styles = vec![];
574
575        for (i, line) in block_lines.enumerate() {
576            if line.starts_with(';') {
577                continue;
578            }
579
580            let Some(line) = line.strip_prefix("Style:") else {
581                return Err(Error {
582                    line: header_line + 1 + i,
583                    kind: SSAErrorKind::Parse("styles line must start with 'Style:'".to_string()),
584                });
585            };
586            let line_list: Vec<&str> = line.trim().split(',').collect();
587
588            styles.push(SSAStyle {
589                name: get_line_value(
590                    &headers,
591                    "Name",
592                    &line_list,
593                    header_line,
594                    header_line + 1 + i,
595                )?
596                .to_string(),
597                fontname: get_line_value(
598                    &headers,
599                    "Fontname",
600                    &line_list,
601                    header_line,
602                    header_line + 1 + i,
603                )?
604                .to_string(),
605                fontsize: get_line_value(
606                    &headers,
607                    "Fontsize",
608                    &line_list,
609                    header_line,
610                    header_line + 1 + i,
611                )?
612                .parse()
613                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
614                primary_color: Color::from_ssa(get_line_value(
615                    &headers,
616                    "PrimaryColour",
617                    &line_list,
618                    header_line,
619                    header_line + 1 + i,
620                )?)
621                .map_err(|e| Error {
622                    line: 2 + i,
623                    kind: SSAErrorKind::Parse(e.to_string()),
624                })?,
625                secondary_color: Color::from_ssa(get_line_value(
626                    &headers,
627                    "SecondaryColour",
628                    &line_list,
629                    header_line,
630                    header_line + 1 + i,
631                )?)
632                .map_err(|e| Error {
633                    line: 2 + i,
634                    kind: SSAErrorKind::Parse(e.to_string()),
635                })?,
636                outline_color: Color::from_ssa(get_line_value(
637                    &headers,
638                    "OutlineColour",
639                    &line_list,
640                    header_line,
641                    header_line + 1 + i,
642                )?)
643                .map_err(|e| Error {
644                    line: 2 + i,
645                    kind: SSAErrorKind::Parse(e.to_string()),
646                })?,
647                back_color: Color::from_ssa(get_line_value(
648                    &headers,
649                    "BackColour",
650                    &line_list,
651                    header_line,
652                    header_line + 1 + i,
653                )?)
654                .map_err(|e| Error {
655                    line: header_line + 1 + i,
656                    kind: SSAErrorKind::Parse(e.to_string()),
657                })?,
658                bold: parse_str_to_bool(
659                    get_line_value(
660                        &headers,
661                        "Bold",
662                        &line_list,
663                        header_line,
664                        header_line + 1 + i,
665                    )?,
666                    header_line + 1 + i,
667                )?,
668                italic: parse_str_to_bool(
669                    get_line_value(
670                        &headers,
671                        "Italic",
672                        &line_list,
673                        header_line,
674                        header_line + 1 + i,
675                    )?,
676                    header_line + 1 + i,
677                )?,
678                underline: parse_str_to_bool(
679                    get_line_value(
680                        &headers,
681                        "Underline",
682                        &line_list,
683                        header_line,
684                        header_line + 1 + i,
685                    )?,
686                    header_line + 1 + i,
687                )?,
688                strikeout: parse_str_to_bool(
689                    get_line_value(
690                        &headers,
691                        "StrikeOut",
692                        &line_list,
693                        header_line,
694                        header_line + 1 + i,
695                    )?,
696                    header_line + 1 + i,
697                )?,
698                scale_x: get_line_value(
699                    &headers,
700                    "ScaleX",
701                    &line_list,
702                    header_line,
703                    header_line + 1 + i,
704                )?
705                .parse()
706                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
707                scale_y: get_line_value(
708                    &headers,
709                    "ScaleY",
710                    &line_list,
711                    header_line,
712                    header_line + 1 + i,
713                )?
714                .parse()
715                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
716                spacing: get_line_value(
717                    &headers,
718                    "Spacing",
719                    &line_list,
720                    header_line,
721                    header_line + 1 + i,
722                )?
723                .parse()
724                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
725                angle: get_line_value(
726                    &headers,
727                    "Angle",
728                    &line_list,
729                    header_line,
730                    header_line + 1 + i,
731                )?
732                .parse()
733                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
734                border_style: get_line_value(
735                    &headers,
736                    "BorderStyle",
737                    &line_list,
738                    header_line,
739                    header_line + 1 + i,
740                )?
741                .parse()
742                .map_err(|e| map_parse_int_err(e, header_line + 1 + i))?,
743                outline: get_line_value(
744                    &headers,
745                    "Outline",
746                    &line_list,
747                    header_line,
748                    header_line + 1 + i,
749                )?
750                .parse()
751                .map(|op: f32| f32::from(op))
752                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
753                shadow: get_line_value(
754                    &headers,
755                    "Shadow",
756                    &line_list,
757                    header_line,
758                    header_line + 1 + i,
759                )?
760                .parse()
761                .map(|op: f32| f32::from(op))
762                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
763                alignment: Alignment::infer_from_str(get_line_value(
764                    &headers,
765                    "Alignment",
766                    &line_list,
767                    header_line,
768                    header_line + 1 + i,
769                )?)
770                .unwrap(),
771                margin_l: get_line_value(
772                    &headers,
773                    "MarginL",
774                    &line_list,
775                    header_line,
776                    header_line + 1 + i,
777                )?
778                .parse()
779                .map(|op: f32| f32::from(op))
780                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
781                margin_r: get_line_value(
782                    &headers,
783                    "MarginR",
784                    &line_list,
785                    header_line,
786                    header_line + 1 + i,
787                )?
788                .parse()
789                .map(|op: f32| f32::from(op))
790                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
791                margin_v: get_line_value(
792                    &headers,
793                    "MarginV",
794                    &line_list,
795                    header_line,
796                    header_line + 1 + i,
797                )?
798                .parse()
799                .map(|op: f32| f32::from(op))
800                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
801                encoding: get_line_value(
802                    &headers,
803                    "Encoding",
804                    &line_list,
805                    header_line,
806                    header_line + 1 + i,
807                )?
808                .parse()
809                .map(|op: f32| f32::from(op))
810                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
811            })
812        }
813
814        Ok(styles)
815    }
816
817    pub(super) fn parse_events_block<'a, I: Iterator<Item = &'a str>>(
818        mut block_lines: I,
819    ) -> Result<Vec<SSAEvent>> {
820        let mut header_line = 1;
821        let header = loop {
822            let Some(line) = block_lines.next() else {
823                return Err(Error {
824                    line: 1,
825                    kind: SSAErrorKind::EmptyBlock,
826                });
827            };
828            if !line.starts_with(';') {
829                break line.to_string();
830            }
831            header_line += 1;
832        };
833        let Some(header) = header.strip_prefix("Format:") else {
834            return Err(Error {
835                line: header_line,
836                kind: SSAErrorKind::Parse("events header must start with 'Format:'".to_string()),
837            });
838        };
839        let headers = header.trim().split(',').collect();
840
841        let mut events = vec![];
842
843        for (i, line) in block_lines.enumerate() {
844            if line.starts_with(';') {
845                continue;
846            }
847
848            let Some((line_type, line)) = line.split_once(':') else {
849                return Err(Error {
850                    line: 2 + i,
851                    kind: SSAErrorKind::Parse("delimiter ':' missing".to_string()),
852                });
853            };
854            let line_list: Vec<&str> = line.trim().splitn(10, ',').collect();
855
856            events.push(SSAEvent {
857                layer: get_line_value(
858                    &headers,
859                    "Layer",
860                    &line_list,
861                    header_line,
862                    header_line + 1 + i,
863                )?
864                .parse()
865                .map_err(|e| map_parse_int_err(e, header_line + 1 + i))?,
866                start: Time::parse(
867                    get_line_value(
868                        &headers,
869                        "Start",
870                        &line_list,
871                        header_line,
872                        header_line + 1 + i,
873                    )?,
874                    TIME_FORMAT,
875                )
876                .map_err(|e| Error {
877                    line: header_line + 1 + i,
878                    kind: SSAErrorKind::Parse(e.to_string()),
879                })?,
880                end: Time::parse(
881                    get_line_value(
882                        &headers,
883                        "End",
884                        &line_list,
885                        header_line,
886                        header_line + 1 + i,
887                    )?,
888                    TIME_FORMAT,
889                )
890                .map_err(|e| Error {
891                    line: header_line + 1 + i,
892                    kind: SSAErrorKind::Parse(e.to_string()),
893                })?,
894                style: get_line_value(
895                    &headers,
896                    "Style",
897                    &line_list,
898                    header_line,
899                    header_line + 1 + i,
900                )?
901                .to_string(),
902                name: get_line_value(
903                    &headers,
904                    "Name",
905                    &line_list,
906                    header_line,
907                    header_line + 1 + i,
908                )?
909                .to_string(),
910                margin_l: get_line_value(
911                    &headers,
912                    "MarginL",
913                    &line_list,
914                    header_line,
915                    header_line + 1 + i,
916                )?
917                .parse()
918                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
919                margin_r: get_line_value(
920                    &headers,
921                    "MarginR",
922                    &line_list,
923                    header_line,
924                    header_line + 1 + i,
925                )?
926                .parse()
927                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
928                margin_v: get_line_value(
929                    &headers,
930                    "MarginV",
931                    &line_list,
932                    header_line,
933                    header_line + 1 + i,
934                )?
935                .parse()
936                .map_err(|e| map_parse_float_err(e, header_line + 1 + i))?,
937                effect: get_line_value(
938                    &headers,
939                    "Effect",
940                    &line_list,
941                    header_line,
942                    header_line + 1 + i,
943                )?
944                .to_string(),
945                text: get_line_value(
946                    &headers,
947                    "Text",
948                    &line_list,
949                    header_line,
950                    header_line + 1 + i,
951                )?
952                .to_string(),
953                line_type: match line_type {
954                    "Dialogue" => SSAEventLineType::Dialogue,
955                    "Comment" => SSAEventLineType::Comment,
956                    _ => SSAEventLineType::Other(line_type.to_string()),
957                },
958            })
959        }
960
961        Ok(events)
962    }
963
964    pub(super) fn parse_fonts_block<'a, I: Iterator<Item = &'a str>>(
965        block_lines: I,
966    ) -> Result<Vec<String>> {
967        let mut fonts = vec![];
968
969        for (i, line) in block_lines.enumerate() {
970            let Some(line) = line.strip_prefix("fontname:") else {
971                return Err(Error {
972                    line: 1 + i,
973                    kind: SSAErrorKind::Parse("fonts line must start with 'fontname:'".to_string()),
974                });
975            };
976            fonts.push(line.trim().to_string())
977        }
978
979        Ok(fonts)
980    }
981
982    pub(super) fn parse_graphics_block<'a, I: Iterator<Item = &'a str>>(
983        block_lines: I,
984    ) -> Result<Vec<String>> {
985        let mut graphics = vec![];
986
987        for (i, line) in block_lines.enumerate() {
988            let Some(line) = line.strip_prefix("filename:") else {
989                return Err(Error {
990                    line: 1 + i,
991                    kind: SSAErrorKind::Parse(
992                        "graphics line must start with 'filename:'".to_string(),
993                    ),
994                });
995            };
996            graphics.push(line.trim().to_string())
997        }
998
999        Ok(graphics)
1000    }
1001
1002    #[allow(clippy::ptr_arg)]
1003    fn get_line_value<'a>(
1004        headers: &Vec<&str>,
1005        name: &str,
1006        list: &'a Vec<&str>,
1007        header_line: usize,
1008        current_line: usize,
1009    ) -> Result<&'a &'a str> {
1010        let pos = headers
1011            .iter()
1012            .position(|h| {
1013                let value: &str = h.trim();
1014
1015                value.to_lowercase() == name.to_lowercase()
1016            })
1017            .ok_or(Error {
1018                line: header_line,
1019                kind: SSAErrorKind::MissingHeader(name.to_string()),
1020            })?;
1021
1022        list.get(pos).ok_or(Error {
1023            line: current_line,
1024            kind: SSAErrorKind::Parse(format!("no value for header '{}'", name)),
1025        })
1026    }
1027    fn parse_str_to_bool(s: &str, line: usize) -> Result<bool> {
1028        match s {
1029            "0" => Ok(false),
1030            "-1" => Ok(true),
1031            _ => Err(Error {
1032                line,
1033                kind: SSAErrorKind::Parse(
1034                    "boolean value must be '-1 (true) or '0' (false)".to_string(),
1035                ),
1036            }),
1037        }
1038    }
1039    fn map_parse_int_err(e: ParseIntError, line: usize) -> Error {
1040        Error {
1041            line,
1042            kind: SSAErrorKind::Parse(e.to_string()),
1043        }
1044    }
1045    fn map_parse_float_err(e: ParseFloatError, line: usize) -> Error {
1046        Error {
1047            line,
1048            kind: SSAErrorKind::Parse(e.to_string()),
1049        }
1050    }
1051}