Skip to main content

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