use regex::Regex;
use std::collections::HashMap;
use std::fmt::Display;
use crate::util::Alignment;
use serde::{Deserialize, Serialize};
use crate::error;
use crate::ssa::parse::TIME_FORMAT;
use crate::util::Color;
use crate::vtt::VTT;
use time::Time;
use super::srt::{SRTLine, SRT};
use super::strip_bom;
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
pub struct SSAInfo {
pub title: Option<String>,
pub original_script: Option<String>,
pub original_translation: Option<String>,
pub original_editing: Option<String>,
pub original_timing: Option<String>,
pub synch_point: Option<String>,
pub script_update_by: Option<String>,
pub update_details: Option<String>,
pub script_type: Option<String>,
pub collisions: Option<String>,
pub play_res_y: Option<u32>,
pub play_res_x: Option<u32>,
pub play_depth: Option<u32>,
pub timer: Option<f32>,
pub wrap_style: Option<u8>,
pub additional_fields: HashMap<String, String>,
}
impl Eq for SSAInfo {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SSAStyle {
pub name: String,
pub fontname: String,
pub fontsize: f32,
pub primary_color: Option<Color>,
pub secondary_color: Option<Color>,
pub outline_color: Option<Color>,
pub back_color: Option<Color>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikeout: bool,
pub scale_x: f32,
pub scale_y: f32,
pub spacing: f32,
pub angle: f32,
pub border_style: u8,
pub outline: f32,
pub shadow: f32,
pub alignment: Alignment,
pub margin_l: f32,
pub margin_r: f32,
pub margin_v: f32,
pub encoding: f32,
}
impl Eq for SSAStyle {}
impl Default for SSAStyle {
fn default() -> Self {
SSAStyle {
name: "Default".to_string(),
fontname: "Trebuchet MS".to_string(),
fontsize: 25.5,
primary_color: None,
secondary_color: None,
outline_color: None,
back_color: None,
bold: false,
italic: false,
underline: false,
strikeout: false,
scale_x: 120.0,
scale_y: 120.0,
spacing: 0.0,
angle: 0.0,
border_style: 1,
outline: 1.0,
shadow: 1.0,
alignment: Alignment::BottomCenter,
margin_l: 0.0,
margin_r: 0.0,
margin_v: 20.0,
encoding: 0.0,
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub enum SSAEventLineType {
Dialogue,
Comment,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SSAEvent {
pub layer: u32,
pub start: Time,
pub end: Time,
pub style: String,
pub name: String,
pub margin_l: f32,
pub margin_r: f32,
pub margin_v: f32,
pub effect: String,
pub text: String,
pub line_type: SSAEventLineType,
}
impl Eq for SSAEvent {}
impl Default for SSAEvent {
fn default() -> Self {
SSAEvent {
layer: 0,
start: Time::from_hms(0, 0, 0).unwrap(),
end: Time::from_hms(0, 0, 0).unwrap(),
style: "Default".to_string(),
name: "".to_string(),
margin_l: 0.0,
margin_r: 0.0,
margin_v: 0.0,
effect: "".to_string(),
text: "".to_string(),
line_type: SSAEventLineType::Dialogue,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct SSA {
pub info: SSAInfo,
pub styles: Vec<SSAStyle>,
pub events: Vec<SSAEvent>,
pub fonts: Vec<String>,
pub graphics: Vec<String>,
}
impl SSA {
pub fn parse<S: AsRef<str>>(content: S) -> Result<SSA, SSAError> {
let mut blocks = Vec::new();
for (i, line) in (1..).zip(strip_bom(&content).lines()) {
match line.trim() {
l if l.is_empty() || l.starts_with(&[';', '#']) => continue,
l if l.starts_with('[') => blocks.push(vec![(i, line)]),
_ => {
if let Some(b) = blocks.last_mut() {
b.push((i, line))
}
}
}
}
if !blocks
.first()
.map(|b| &b[0])
.is_some_and(|l| l.1 == "[Script Info]")
{
return Err(SSAError::new(SSAErrorKind::Invalid, 1));
}
let mut ssa = SSA::default();
for block in blocks {
let mut iter = block.into_iter();
let (i, line) = iter.next().unwrap(); match line {
"[Script Info]" => ssa.info = parse::parse_script_info_block(iter)?,
"[V4+ Styles]" => ssa.styles = parse::parse_style_block(i, iter)?,
"[Events]" => ssa.events = parse::parse_events_block(i, iter)?,
"[Fonts]" => ssa.fonts = parse::parse_fonts_block(iter)?,
"[Graphics]" => ssa.graphics = parse::parse_graphics_block(iter)?,
_ => continue,
}
}
Ok(ssa)
}
pub fn to_srt(&self) -> SRT {
let style_remove_regex = Regex::new(r"(?m)\{\\.+?}").unwrap();
let mut lines = vec![];
for (i, event) in self.events.iter().enumerate() {
let mut text = event
.text
.replace("{\\b1}", "<b>")
.replace("{\\b0}", "</b>")
.replace("{\\i1}", "<i>")
.replace("{\\i0}", "</i>")
.replace("{\\u1}", "<u>")
.replace("{\\u0}", "</u>")
.replace("\\N", "\r\n");
if !event.style.is_empty() {
if let Some(style) = self.styles.iter().find(|s| s.name == event.style) {
if style.bold {
text = format!("<b>{text}</b>")
}
if style.italic {
text = format!("<i>{text}</i>")
}
if style.underline {
text = format!("<u>{text}</u>")
}
}
}
lines.push(SRTLine {
sequence_number: i as u32 + 1,
start: event.start,
end: event.end,
text: style_remove_regex.replace_all(&text, "").to_string(),
})
}
SRT { lines }
}
pub fn to_vtt(self) -> VTT {
self.to_srt().to_vtt()
}
}
impl Display for SSA {
#[rustfmt::skip]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut lines = vec![];
lines.push("[Script Info]".to_string());
lines.extend(self.info.title.as_ref().map(|l| format!("Title: {l}")));
lines.extend(self.info.original_script.as_ref().map(|l| format!("Original Script: {l}")));
lines.extend(self.info.original_translation.as_ref().map(|l| format!("Original Translation: {l}")));
lines.extend(self.info.original_editing.as_ref().map(|l| format!("Original Editing: {l}")));
lines.extend(self.info.original_timing.as_ref().map(|l| format!("Original Timing: {l}")));
lines.extend(self.info.synch_point.as_ref().map(|l| format!("Synch Point: {l}")));
lines.extend(self.info.script_update_by.as_ref().map(|l| format!("Script Updated By: {l}")));
lines.extend(self.info.update_details.as_ref().map(|l| format!("Update Details: {l}")));
lines.extend(self.info.script_type.as_ref().map(|l| format!("Script Type: {l}")));
lines.extend(self.info.collisions.as_ref().map(|l| format!("Collisions: {l}")));
lines.extend(self.info.play_res_y.map(|l| format!("PlayResY: {l}")));
lines.extend(self.info.play_res_x.map(|l| format!("PlayResX: {l}")));
lines.extend(self.info.play_depth.map(|l| format!("PlayDepth: {l}")));
lines.extend(self.info.timer.map(|l| format!("Timer: {l}")));
lines.extend(self.info.wrap_style.map(|l| format!("WrapStyle: {l}")));
for (k, v) in &self.info.additional_fields {
lines.push(format!("{k}: {v}"))
}
lines.push("".to_string());
lines.push("[V4+ Styles]".to_string());
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());
for style in &self.styles {
let line = [
style.name.to_string(),
style.fontname.to_string(),
style.fontsize.to_string(),
style.primary_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
style.secondary_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
style.outline_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
style.back_color.map(|c| c.to_ssa_string()).unwrap_or_default(),
if style.bold { "-1" } else { "0" }.to_string(),
if style.italic { "-1" } else { "0" }.to_string(),
if style.underline { "-1" } else { "0" }.to_string(),
if style.strikeout { "-1" } else { "0" }.to_string(),
style.scale_x.to_string(),
style.scale_y.to_string(),
style.spacing.to_string(),
style.angle.to_string(),
style.border_style.to_string(),
style.outline.to_string(),
style.shadow.to_string(),
(style.alignment as u8).to_string(),
style.margin_l.to_string(),
style.margin_r.to_string(),
style.margin_v.to_string(),
style.encoding.to_string(),
];
lines.push(format!("Style: {}", line.join(",")))
}
lines.push("".to_string());
lines.push("[Events]".to_string());
lines.push("Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text".to_string());
for event in &self.events {
let line = [
event.layer.to_string(),
event.start.format(TIME_FORMAT).unwrap(),
event.end.format(TIME_FORMAT).unwrap(),
event.style.to_string(),
event.name.to_string(),
event.margin_l.to_string(),
event.margin_r.to_string(),
event.margin_v.to_string(),
event.effect.to_string(),
event.text.to_string()
];
lines.push(format!("Dialogue: {}", line.join(",")))
}
write!(f, "{}", lines.join("\n"))
}
}
error! {
SSAError => SSAErrorKind {
Invalid,
EmptyBlock,
Parse(String),
MissingHeader(String),
}
}
mod parse {
use super::*;
use std::num::{ParseFloatError, ParseIntError};
use time::format_description::BorrowedFormatItem;
use time::macros::format_description;
pub(super) struct Error {
pub(super) line: usize,
pub(super) kind: SSAErrorKind,
}
impl From<Error> for SSAError {
fn from(e: Error) -> SSAError {
SSAError::new(e.kind, e.line)
}
}
pub(super) const TIME_FORMAT: &[BorrowedFormatItem] =
format_description!("[hour padding:none]:[minute]:[second].[subsecond digits:2]");
type Result<T> = std::result::Result<T, Error>;
pub(super) fn parse_script_info_block<'a, I: Iterator<Item = (usize, &'a str)>>(
block_lines: I,
) -> Result<SSAInfo> {
let mut info = SSAInfo::default();
for (i, line) in block_lines {
let Some((name, mut value)) = line.split_once(':') else {
return Err(Error {
line: i,
kind: SSAErrorKind::Parse("delimiter ':' missing".to_string()),
});
};
value = value.trim();
if value.is_empty() {
continue;
}
match name {
"Title" => info.title = Some(value.to_string()),
"Original Script" => info.original_script = Some(value.to_string()),
"Original Translation" => info.original_translation = Some(value.to_string()),
"Original Editing" => info.original_editing = Some(value.to_string()),
"Original Timing" => info.original_timing = Some(value.to_string()),
"Synch Point" => info.synch_point = Some(value.to_string()),
"Script Updated By" => info.script_update_by = Some(value.to_string()),
"Update Details" => info.update_details = Some(value.to_string()),
"ScriptType" => info.script_type = Some(value.to_string()),
"Collisions" => info.collisions = Some(value.to_string()),
"PlayResY" => {
info.play_res_y = value.parse::<u32>().map(Some).map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?
}
"PlayResX" => {
info.play_res_x = value.parse::<u32>().map(Some).map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?
}
"PlayDepth" => {
info.play_depth = value.parse::<u32>().map(Some).map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?
}
"Timer" => {
info.timer = value.parse::<f32>().map(Some).map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?
}
"WrapStyle" => {
info.wrap_style = value.parse::<u8>().map(Some).map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?
}
_ => {
info.additional_fields
.insert(name.to_string(), value.to_string());
}
}
}
Ok(info)
}
fn parse_block_header<'a, I: Iterator<Item = (usize, &'a str)>>(
header_line: usize,
mut block_lines: I,
) -> Result<(usize, Vec<&'a str>)> {
let (i, line) = block_lines.next().ok_or_else(|| Error {
line: header_line,
kind: SSAErrorKind::EmptyBlock,
})?;
let header = line.strip_prefix("Format:").ok_or_else(|| Error {
line: i,
kind: SSAErrorKind::Parse("header must start with 'Format:'".to_string()),
})?;
Ok((i, header.trim().split(',').collect()))
}
pub(super) fn parse_style_block<'a, I: Iterator<Item = (usize, &'a str)>>(
header_line: usize,
mut block_lines: I,
) -> Result<Vec<SSAStyle>> {
let (header_line, headers) = parse_block_header(header_line, &mut block_lines)?;
let mut styles = vec![];
for (i, line) in block_lines {
let Some(line) = line.strip_prefix("Style:") else {
return Err(Error {
line: i,
kind: SSAErrorKind::Parse("styles line must start with 'Style:'".to_string()),
});
};
let line_list: Vec<&str> = line.trim().split(',').collect();
styles.push(SSAStyle {
name: get_line_value(&headers, "Name", &line_list, header_line, i)?.to_string(),
fontname: get_line_value(&headers, "Fontname", &line_list, header_line, i)?
.to_string(),
fontsize: get_line_value(&headers, "Fontsize", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
primary_color: Color::from_ssa(get_line_value(
&headers,
"PrimaryColour",
&line_list,
header_line,
i,
)?)
.map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?,
secondary_color: Color::from_ssa(get_line_value(
&headers,
"SecondaryColour",
&line_list,
header_line,
i,
)?)
.map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?,
outline_color: Color::from_ssa(get_line_value(
&headers,
"OutlineColour",
&line_list,
header_line,
i,
)?)
.map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?,
back_color: Color::from_ssa(get_line_value(
&headers,
"BackColour",
&line_list,
header_line,
i,
)?)
.map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?,
bold: parse_str_to_bool(
get_line_value(&headers, "Bold", &line_list, header_line, i)?,
i,
)?,
italic: parse_str_to_bool(
get_line_value(&headers, "Italic", &line_list, header_line, i)?,
i,
)?,
underline: parse_str_to_bool(
get_line_value(&headers, "Underline", &line_list, header_line, i)?,
i,
)?,
strikeout: parse_str_to_bool(
get_line_value(&headers, "StrikeOut", &line_list, header_line, i)?,
i,
)?,
scale_x: get_line_value(&headers, "ScaleX", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
scale_y: get_line_value(&headers, "ScaleY", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
spacing: get_line_value(&headers, "Spacing", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
angle: get_line_value(&headers, "Angle", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
border_style: get_line_value(&headers, "BorderStyle", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_int_err(e, i))?,
outline: get_line_value(&headers, "Outline", &line_list, header_line, i)?
.parse()
.map(|op: f32| f32::from(op))
.map_err(|e| map_parse_float_err(e, i))?,
shadow: get_line_value(&headers, "Shadow", &line_list, header_line, i)?
.parse()
.map(|op: f32| f32::from(op))
.map_err(|e| map_parse_float_err(e, i))?,
alignment: Alignment::infer_from_str(get_line_value(
&headers,
"Alignment",
&line_list,
header_line,
i,
)?)
.unwrap(),
margin_l: get_line_value(&headers, "MarginL", &line_list, header_line, i)?
.parse()
.map(|op: f32| f32::from(op))
.map_err(|e| map_parse_float_err(e, i))?,
margin_r: get_line_value(&headers, "MarginR", &line_list, header_line, i)?
.parse()
.map(|op: f32| f32::from(op))
.map_err(|e| map_parse_float_err(e, i))?,
margin_v: get_line_value(&headers, "MarginV", &line_list, header_line, i)?
.parse()
.map(|op: f32| f32::from(op))
.map_err(|e| map_parse_float_err(e, i))?,
encoding: get_line_value(&headers, "Encoding", &line_list, header_line, i)?
.parse()
.map(|op: f32| f32::from(op))
.map_err(|e| map_parse_float_err(e, i))?,
})
}
Ok(styles)
}
pub(super) fn parse_events_block<'a, I: Iterator<Item = (usize, &'a str)>>(
header_line: usize,
mut block_lines: I,
) -> Result<Vec<SSAEvent>> {
let (header_line, headers) = parse_block_header(header_line, &mut block_lines)?;
let mut events = vec![];
for (i, line) in block_lines {
let Some((line_type, line)) = line.split_once(':') else {
return Err(Error {
line: i,
kind: SSAErrorKind::Parse("delimiter ':' missing".to_string()),
});
};
let line_list: Vec<&str> = line.trim().splitn(10, ',').collect();
events.push(SSAEvent {
layer: get_line_value(&headers, "Layer", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_int_err(e, i))?,
start: Time::parse(
get_line_value(&headers, "Start", &line_list, header_line, i)?,
TIME_FORMAT,
)
.map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?,
end: Time::parse(
get_line_value(&headers, "End", &line_list, header_line, i)?,
TIME_FORMAT,
)
.map_err(|e| Error {
line: i,
kind: SSAErrorKind::Parse(e.to_string()),
})?,
style: get_line_value(&headers, "Style", &line_list, header_line, i)?.to_string(),
name: get_line_value(&headers, "Name", &line_list, header_line, i)?.to_string(),
margin_l: get_line_value(&headers, "MarginL", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
margin_r: get_line_value(&headers, "MarginR", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
margin_v: get_line_value(&headers, "MarginV", &line_list, header_line, i)?
.parse()
.map_err(|e| map_parse_float_err(e, i))?,
effect: get_line_value(&headers, "Effect", &line_list, header_line, i)?.to_string(),
text: get_line_value(&headers, "Text", &line_list, header_line, i)?.to_string(),
line_type: match line_type {
"Dialogue" => SSAEventLineType::Dialogue,
"Comment" => SSAEventLineType::Comment,
_ => SSAEventLineType::Other(line_type.to_string()),
},
})
}
Ok(events)
}
pub(super) fn parse_fonts_block<'a, I: Iterator<Item = (usize, &'a str)>>(
block_lines: I,
) -> Result<Vec<String>> {
let mut fonts = vec![];
for (i, line) in block_lines {
let Some(line) = line.strip_prefix("fontname:") else {
return Err(Error {
line: i,
kind: SSAErrorKind::Parse("fonts line must start with 'fontname:'".to_string()),
});
};
fonts.push(line.trim().to_string())
}
Ok(fonts)
}
pub(super) fn parse_graphics_block<'a, I: Iterator<Item = (usize, &'a str)>>(
block_lines: I,
) -> Result<Vec<String>> {
let mut graphics = vec![];
for (i, line) in block_lines {
let Some(line) = line.strip_prefix("filename:") else {
return Err(Error {
line: i,
kind: SSAErrorKind::Parse(
"graphics line must start with 'filename:'".to_string(),
),
});
};
graphics.push(line.trim().to_string())
}
Ok(graphics)
}
#[allow(clippy::ptr_arg)]
fn get_line_value<'a>(
headers: &Vec<&str>,
name: &str,
list: &'a Vec<&str>,
header_line: usize,
current_line: usize,
) -> Result<&'a str> {
let pos = headers
.iter()
.position(|h| {
let value: &str = h.trim();
value.to_lowercase() == name.to_lowercase()
})
.ok_or(Error {
line: header_line,
kind: SSAErrorKind::MissingHeader(name.to_string()),
})?;
list.get(pos).map(|l| l.trim()).ok_or(Error {
line: current_line,
kind: SSAErrorKind::Parse(format!("no value for header '{}'", name)),
})
}
fn parse_str_to_bool(s: &str, line: usize) -> Result<bool> {
match s {
"0" => Ok(false),
"-1" => Ok(true),
_ => Err(Error {
line,
kind: SSAErrorKind::Parse(
"boolean value must be '-1 (true) or '0' (false)".to_string(),
),
}),
}
}
fn map_parse_int_err(e: ParseIntError, line: usize) -> Error {
Error {
line,
kind: SSAErrorKind::Parse(e.to_string()),
}
}
fn map_parse_float_err(e: ParseFloatError, line: usize) -> Error {
Error {
line,
kind: SSAErrorKind::Parse(e.to_string()),
}
}
}