use super::fields::{
format_line_compare, parse_event_tail, process_style_line, Cursor, ASS_EVENT_FORMAT,
ASS_STYLE_FORMAT, SSA_EVENT_FORMAT, SSA_STYLE_FORMAT,
};
use super::types::{EmbeddedFont, Script, TrackType};
use super::value::{
parse_bool, parse_f64, parse_i32_saturating, parse_int, parse_ycbcr_matrix, ASS_SPACES,
};
const SINFO_PLAYRESX: u32 = 1 << 0;
const SINFO_PLAYRESY: u32 = 1 << 1;
const SINFO_TIMER: u32 = 1 << 2;
const SINFO_WRAPSTYLE: u32 = 1 << 3;
const SINFO_SCALEDBORDER: u32 = 1 << 4;
const SINFO_COLOURMATRIX: u32 = 1 << 5;
const SINFO_KERNING: u32 = 1 << 6;
const SINFO_SCRIPTTYPE: u32 = 1 << 7;
const SINFO_LANGUAGE: u32 = 1 << 8;
const SINFO_LAYOUTRESX: u32 = 1 << 9;
const SINFO_LAYOUTRESY: u32 = 1 << 10;
const GENBY_FFMPEG: u32 = 1 << 11;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct NotAScript;
impl std::fmt::Display for NotAScript {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("not an SSA/ASS script: no [V4/V4+ Styles] section or ScriptType header")
}
}
impl std::error::Error for NotAScript {}
pub(crate) fn parse(content: &str) -> Result<Script, NotAScript> {
let mut parser = ScriptParser::new();
parser.process_data(content);
parser.finish()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
None,
Info,
Styles,
Events,
Fonts,
}
pub(crate) struct ScriptParser {
script: Script,
section: Section,
style_format: Option<String>,
event_format: Option<String>,
header_flags: u32,
fontname: Option<String>,
fontdata: Vec<u8>,
seen_read_orders: std::collections::HashSet<i32>,
read_order_bitmap_active: bool,
}
fn starts_ci(s: &str, prefix: &str) -> bool {
s.as_bytes()
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes()))
}
fn preview(line: &str) -> String {
line.chars().take(30).collect()
}
impl ScriptParser {
pub(crate) fn new() -> Self {
Self {
script: Script::new(),
section: Section::None,
style_format: None,
event_format: None,
header_flags: 0,
fontname: None,
fontdata: Vec::new(),
seen_read_orders: std::collections::HashSet::new(),
read_order_bitmap_active: false,
}
}
pub(crate) fn process_data(&mut self, content: &str) {
const LINE_BREAKS: &[char] = &['\r', '\n'];
let mut rest = content.split('\0').next().unwrap_or("");
loop {
loop {
if let Some(tail) = rest.strip_prefix(LINE_BREAKS) {
rest = tail;
} else if let Some(tail) = rest.strip_prefix('\u{feff}') {
rest = tail;
} else {
break;
}
}
if rest.is_empty() {
break;
}
let line_end = rest.find(LINE_BREAKS).unwrap_or(rest.len());
let (line, tail) = rest.split_at(line_end);
rest = tail;
self.process_line(line);
}
self.flush_font();
}
pub(crate) fn process_codec_private(&mut self, header: &str) {
self.process_data(header);
if self.event_format.is_none() {
log::debug!("ass parser: no event format found, using fallback");
self.event_format = Some(match self.script.track_type {
TrackType::Ssa => SSA_EVENT_FORMAT.to_string(),
_ => ASS_EVENT_FORMAT.to_string(),
});
}
}
pub(crate) fn process_chunk(&mut self, body: &str, start_ms: i64, duration_ms: i64) {
let Some(format) = self.event_format.clone() else {
log::warn!("ass parser: event format header missing");
return;
};
let body = body.split('\0').next().unwrap_or("");
if !self.read_order_bitmap_active && !self.script.events.is_empty() {
self.read_order_bitmap_active = true;
for event in &self.script.events {
self.seen_read_orders.insert(event.read_order);
}
}
let mut values = Cursor::new(body);
let Some(read_order_token) = values.next_token() else {
return;
};
let read_order = parse_i32_saturating(read_order_token, 10);
if self.read_order_bitmap_active {
if !self.seen_read_orders.insert(read_order) {
return;
}
} else if self
.script
.events
.iter()
.any(|event| event.read_order == read_order)
{
return;
}
let Some(layer_token) = values.next_token() else {
return;
};
let layer = parse_int(layer_token);
if let Some(mut event) = parse_event_tail(&self.script, &format, values.remainder(), 3) {
event.read_order = read_order;
event.layer = layer;
event.start_ms = start_ms;
event.duration_ms = duration_ms;
self.script.events.push(event);
}
}
fn process_line(&mut self, line: &str) {
let line = line.trim_start_matches(ASS_SPACES);
if starts_ci(line, "[Script Info]") {
self.section = Section::Info;
} else if starts_ci(line, "[V4 Styles]") {
self.section = Section::Styles;
self.script.track_type = TrackType::Ssa;
} else if starts_ci(line, "[V4+ Styles]") {
self.section = Section::Styles;
self.script.track_type = TrackType::Ass;
} else if starts_ci(line, "[Events]") {
self.section = Section::Events;
} else if starts_ci(line, "[Fonts]") {
self.section = Section::Fonts;
} else {
match self.section {
Section::Info => self.process_info_line(line),
Section::Styles => self.process_styles_line(line),
Section::Events => self.process_events_line(line),
Section::Fonts => self.process_fonts_line(line),
Section::None => {}
}
}
}
fn note_header(&mut self, flag: u32, name: &str) {
if self.header_flags & flag != 0 {
log::warn!(
"ass parser: duplicate Script Info header '{name}'; previous value overwritten"
);
} else {
self.header_flags |= flag;
}
}
fn process_info_line(&mut self, line: &str) {
if let Some(value) = line.strip_prefix("PlayResX:") {
self.note_header(SINFO_PLAYRESX, "PlayResX");
self.script.play_res_x = parse_int(value);
} else if let Some(value) = line.strip_prefix("PlayResY:") {
self.note_header(SINFO_PLAYRESY, "PlayResY");
self.script.play_res_y = parse_int(value);
} else if let Some(value) = line.strip_prefix("LayoutResX:") {
self.note_header(SINFO_LAYOUTRESX, "LayoutResX");
self.script.layout_res_x = parse_int(value);
} else if let Some(value) = line.strip_prefix("LayoutResY:") {
self.note_header(SINFO_LAYOUTRESY, "LayoutResY");
self.script.layout_res_y = parse_int(value);
} else if let Some(value) = line.strip_prefix("Timer:") {
self.note_header(SINFO_TIMER, "Timer");
self.script.timer = parse_f64(value);
} else if let Some(value) = line.strip_prefix("WrapStyle:") {
self.note_header(SINFO_WRAPSTYLE, "WrapStyle");
self.script.wrap_style = parse_int(value);
} else if let Some(value) = line.strip_prefix("ScaledBorderAndShadow:") {
self.note_header(SINFO_SCALEDBORDER, "ScaledBorderAndShadow");
self.script.scaled_border_and_shadow = parse_bool(value);
} else if let Some(value) = line.strip_prefix("Kerning:") {
self.note_header(SINFO_KERNING, "Kerning");
self.script.kerning = parse_bool(value);
} else if let Some(value) = line.strip_prefix("YCbCr Matrix:") {
self.note_header(SINFO_COLOURMATRIX, "YCbCr Matrix");
self.script.ycbcr_matrix = parse_ycbcr_matrix(value);
} else if let Some(value) = line.strip_prefix("Language:") {
self.note_header(SINFO_LANGUAGE, "Language");
const C_SPACES: &[char] = &[' ', '\t', '\n', '\x0b', '\x0c', '\r'];
let value = value.trim_start_matches(C_SPACES);
self.script.language = Some(value.chars().take(2).collect());
} else if let Some(value) = line.strip_prefix("ScriptType:") {
self.note_header(SINFO_SCRIPTTYPE, "ScriptType");
self.parse_script_type(value);
} else if let Some(rest) = line.strip_prefix("; Script generated by ") {
if rest.starts_with("FFmpeg/Lavc") {
self.header_flags |= GENBY_FFMPEG;
}
}
}
fn parse_script_type(&mut self, value: &str) {
let trimmed = value.trim_end_matches(ASS_SPACES);
if trimmed.len() < 4 {
return;
}
let (body, track_type) = match trimmed.strip_suffix('+') {
Some(body) => (body, TrackType::Ass),
None => (trimmed, TrackType::Ssa),
};
let bytes = body.as_bytes();
if bytes.len() >= 4 && &bytes[bytes.len() - 4..] == b"4.00" {
self.script.track_type = track_type;
}
}
fn custom_format_compat(&mut self, format: &str, standard: &str) {
if self.header_flags & SINFO_SCALEDBORDER == 0 && !format_line_compare(format, standard) {
log::info!(
"ass parser: track has custom format line(s); \
'ScaledBorderAndShadow' will default to 'yes'"
);
self.script.scaled_border_and_shadow = true;
}
}
fn detect_legacy_conv_subs(&self) -> bool {
self.header_flags == (SINFO_SCRIPTTYPE | SINFO_PLAYRESX | SINFO_PLAYRESY | GENBY_FFMPEG)
&& self.script.styles.len() == 2
&& self.script.styles[1]
.name
.as_bytes()
.starts_with(b"Default")
}
fn process_styles_line(&mut self, line: &str) {
if let Some(value) = line.strip_prefix("Format:") {
let format = value.trim_start_matches(ASS_SPACES);
self.style_format = Some(format.to_string());
let standard = match self.script.track_type {
TrackType::Ass => ASS_STYLE_FORMAT,
_ => SSA_STYLE_FORMAT,
};
self.custom_format_compat(format, standard);
} else if let Some(value) = line.strip_prefix("Style:") {
let body = value.trim_start_matches(ASS_SPACES);
let format = self
.style_format
.get_or_insert_with(|| match self.script.track_type {
TrackType::Ssa => SSA_STYLE_FORMAT.to_string(),
_ => ASS_STYLE_FORMAT.to_string(),
})
.clone();
process_style_line(&mut self.script, &format, body);
}
}
fn process_events_line(&mut self, line: &str) {
if let Some(value) = line.strip_prefix("Format:") {
let format = value.trim_start_matches(ASS_SPACES);
self.event_format = Some(format.to_string());
let standard = match self.script.track_type {
TrackType::Ass => ASS_EVENT_FORMAT,
_ => SSA_EVENT_FORMAT,
};
self.custom_format_compat(format, standard);
if self.detect_legacy_conv_subs() {
log::info!("ass parser: track treated as legacy ffmpeg sub");
self.script.scaled_border_and_shadow = true;
}
} else if let Some(value) = line.strip_prefix("Dialogue:") {
let body = value.trim_start_matches(ASS_SPACES);
let format = self
.event_format
.get_or_insert_with(|| {
log::debug!("ass parser: no event format found, using fallback");
match self.script.track_type {
TrackType::Ssa => SSA_EVENT_FORMAT.to_string(),
_ => ASS_EVENT_FORMAT.to_string(),
}
})
.clone();
if let Some(event) = parse_event_tail(&self.script, &format, body, 0) {
self.script.events.push(event);
}
} else {
log::debug!("ass parser: not understood: '{}'", preview(line));
}
}
fn process_fonts_line(&mut self, line: &str) {
if let Some(value) = line.strip_prefix("fontname:") {
self.flush_font();
self.fontname = Some(value.trim_start_matches(ASS_SPACES).to_string());
return;
}
if self.fontname.is_none() {
log::debug!("ass parser: not understood: '{}'", preview(line));
return;
}
self.fontdata.extend_from_slice(line.as_bytes());
}
fn flush_font(&mut self) {
let Some(filename) = self.fontname.take() else {
return;
};
let data = std::mem::take(&mut self.fontdata);
if data.len() % 4 == 1 {
log::warn!("ass parser: bad encoded data size for font '{filename}'");
return;
}
let mut decoded = Vec::with_capacity(data.len() / 4 * 3 + 2);
for chunk in data.chunks(4) {
let mut value: u32 = 0;
for (i, &c) in chunk.iter().enumerate() {
value |= u32::from(c.wrapping_sub(33) & 63) << (6 * (3 - i));
}
decoded.push((value >> 16) as u8);
if chunk.len() >= 3 {
decoded.push((value >> 8) as u8);
}
if chunk.len() >= 4 {
decoded.push(value as u8);
}
}
self.script.fonts.push(EmbeddedFont {
filename,
data: decoded,
});
}
fn finish(mut self) -> Result<Script, NotAScript> {
for (index, event) in self.script.events.iter_mut().enumerate() {
event.read_order = index as i32;
}
if self.script.track_type == TrackType::Unknown {
return Err(NotAScript);
}
Ok(self.script)
}
pub(crate) fn into_script(self) -> Script {
self.script
}
}
pub(crate) use super::fields::apply_force_style;
#[cfg(test)]
mod tests {
use super::super::value::{Color, YcbcrMatrix, VALIGN_CENTER, VALIGN_TOP};
use super::*;
use crate::subtitle::test_util;
#[test]
fn parses_the_shared_minimal_script() {
let script = parse(&test_util::minimal_ass(test_util::HELLO_EVENT)).expect("parse");
assert_eq!(script.track_type, TrackType::Ass);
assert_eq!((script.play_res_x, script.play_res_y), (640, 360));
assert_eq!(script.styles.len(), 2);
assert_eq!(script.default_style, 1);
let style = &script.styles[1];
assert_eq!(style.name, "Default");
assert_eq!(style.font_name, "DejaVu Sans");
assert_eq!(style.font_size, 48.0);
assert_eq!(
style.primary_colour,
Color {
r: 255,
g: 255,
b: 255,
t: 0
}
);
assert_eq!((style.scale_x, style.scale_y), (1.0, 1.0));
assert_eq!(style.border_style, 1);
assert_eq!((style.outline, style.shadow), (2.0, 0.0));
assert_eq!(style.alignment, 2); assert_eq!(
(style.margin_l, style.margin_r, style.margin_v),
(10, 10, 10)
);
assert_eq!(style.encoding, 1);
assert_eq!(script.events.len(), 1);
let event = &script.events[0];
assert_eq!(event.text, "Hello subtitle");
assert_eq!((event.start_ms, event.duration_ms), (0, 5_000));
assert_eq!(event.style, 1);
assert_eq!(event.read_order, 0);
}
#[test]
fn respects_reordered_format_lines() {
let script = parse(
"[V4+ Styles]\n\
Format: Fontsize, Name, Alignment\n\
Style: 64,Big,8\n\
[Events]\n\
Format: Text, Start, End\n\
Dialogue: ignored tail\n\
",
)
.expect("parse");
let style = &script.styles[1];
assert_eq!((style.name.as_str(), style.font_size), ("Big", 64.0));
assert_eq!(style.alignment, 2 | VALIGN_TOP); let event = &script.events[0];
assert_eq!(event.text, "ignored tail");
assert_eq!((event.start_ms, event.duration_ms), (0, 0));
}
#[test]
fn short_style_line_keeps_zeroed_defaults() {
let script = parse(
"[V4+ Styles]\n\
Style: **Starred\n\
",
)
.expect("parse");
let style = &script.styles[1];
assert_eq!(style.name, "Starred"); assert_eq!(style.font_name, "Arial"); assert_eq!((style.scale_x, style.scale_y), (1.0, 1.0)); assert_eq!(style.font_size, 0.0);
assert_eq!(style.alignment, 0);
assert_eq!(script.default_style, 0); }
#[test]
fn ssa_styles_get_vsfilter_compatibility_fixups() {
let header = "[Script Info]\nScriptType: v4.00\n\n[V4 Styles]\n";
let format = "Format: Name, PrimaryColour, SecondaryColour, TertiaryColour, \
BackColour, Bold, Alignment, AlphaLevel\n";
let parse_one = |style_line: &str| {
let script = parse(&format!("{header}{format}{style_line}\n")).expect("parse ssa");
script.styles[1].clone()
};
let style = parse_one("Style: A,&HFF&,&H00FF00&,&H123456&,&H0000FF&,-1,2,100");
assert_eq!(script_type_of(header), TrackType::Ssa);
let rgb = |c: Color| (c.r, c.g, c.b);
assert_eq!(rgb(style.outline_colour), (255, 0, 0));
assert_eq!(rgb(style.outline_colour), rgb(style.back_colour));
assert_eq!(style.primary_colour.t, 100);
assert_eq!(style.secondary_colour.t, 100);
assert_eq!(style.outline_colour.t, 100);
assert_eq!(style.back_colour.t, 0x80);
assert_eq!(style.bold, 1);
assert_eq!(style.alignment, 2);
assert_eq!(parse_one("Style: B,,,,,0,8,0").alignment, 3);
assert_eq!(parse_one("Style: C,,,,,0,4,0").alignment, 11);
}
fn script_type_of(content: &str) -> TrackType {
parse(content).map(|s| s.track_type).unwrap_or_default()
}
#[test]
fn ass_alignment_is_numpad_converted() {
let script = parse(
"[V4+ Styles]\n\
Format: Name, Alignment\n\
Style: Center,5\n\
Style: TopLeft,7\n\
",
)
.expect("parse");
assert_eq!(script.styles[1].alignment, 2 | VALIGN_CENTER);
assert_eq!(script.styles[2].alignment, 1 | VALIGN_TOP);
}
#[test]
fn event_tail_preserves_text_verbatim() {
let script = parse(
"[V4+ Styles]\n\
Style: Default\n\
[Events]\n\
Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:02.00,Default,Speaker,0,0,0,, {\\b1}Hi, there\\N \t \n\
Comment: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,ignored\n\
Dialogue: 0,0:00:01.00\n\
Dialogue: 1,0:00:02.00,0:00:01.00,Missing,,0,0,0,,\n\
",
)
.expect("parse");
assert_eq!(script.events.len(), 2, "comment + short dialogue dropped");
let event = &script.events[0];
assert_eq!(event.text, " {\\b1}Hi, there\\N");
assert_eq!(event.name, "Speaker"); assert_eq!((event.start_ms, event.duration_ms), (1_000, 1_000));
let event = &script.events[1];
assert_eq!(event.text, ""); assert_eq!(event.style, script.default_style); assert_eq!(event.duration_ms, -1_000); assert_eq!(event.read_order, 1);
}
#[test]
fn dialogue_without_event_format_uses_fallback() {
let script = parse(
"[Script Info]\n\
ScriptType: v4.00+\n\
[Events]\n\
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,text\n\
",
)
.expect("parse");
assert_eq!(script.events.len(), 1);
assert_eq!(script.events[0].text, "text");
assert_eq!(script.events[0].duration_ms, 1_000);
}
#[test]
fn info_headers_parse_and_duplicates_overwrite() {
let script = parse(
"[Script Info]\n\
ScriptType: v4.00+\n\
PlayResX: 1280\n\
PlayResX: 1920\n\
PlayResY: 1080\n\
LayoutResX: 640\n\
LayoutResY: 360\n\
Timer: 100.0000\n\
WrapStyle: 2\n\
ScaledBorderAndShadow: yes\n\
Kerning: yes\n\
YCbCr Matrix: TV.709\n\
Language: eng\n\
playresx: 1\n\
",
)
.expect("parse");
assert_eq!(script.play_res_x, 1920, "last duplicate wins");
assert_eq!(script.play_res_y, 1080);
assert_eq!((script.layout_res_x, script.layout_res_y), (640, 360));
assert_eq!(script.timer, 100.0);
assert_eq!(script.wrap_style, 2);
assert!(script.scaled_border_and_shadow);
assert!(script.kerning);
assert_eq!(script.ycbcr_matrix, YcbcrMatrix::Bt709Tv);
assert_eq!(script.language.as_deref(), Some("en"));
assert_eq!(script.play_res_x, 1920, "info prefixes are case-sensitive");
}
#[test]
fn script_type_detection_is_vsfilter_compatible() {
let of = |value: &str| script_type_of(&format!("[Script Info]\nScriptType:{value}\n"));
assert_eq!(of(" v4.00+"), TrackType::Ass);
assert_eq!(of(" v4.00"), TrackType::Ssa);
assert_eq!(of(" V4.00+ "), TrackType::Ass); assert_eq!(of("anything4.00"), TrackType::Ssa); assert_eq!(of(" v4.1"), TrackType::Unknown);
assert_eq!(of(" 4.0+"), TrackType::Unknown); }
#[test]
fn rejects_input_that_never_reveals_a_type() {
assert_eq!(parse("definitely not an ass script"), Err(NotAScript));
assert_eq!(
parse("[Script Info]\nTitle: no type here\n[Events]\n"),
Err(NotAScript)
);
}
#[test]
fn custom_format_lines_default_scaled_border_to_yes() {
let custom = "[V4+ Styles]\nFormat: Name, Fontsize, RelativeTo\n";
assert!(parse(custom).expect("parse").scaled_border_and_shadow);
let pinned = format!("[Script Info]\nScaledBorderAndShadow: no\n{custom}");
assert!(!parse(&pinned).expect("parse").scaled_border_and_shadow);
let standard = "[V4+ Styles]\n[Events]\nFormat: Layer, Start, End, Style, Actor, \
MarginL, MarginR, MarginV, Effect, Text\n";
assert!(!parse(standard).expect("parse").scaled_border_and_shadow);
}
#[test]
fn detects_legacy_ffmpeg_converted_subtitles() {
let legacy = "[Script Info]\n\
; Script generated by FFmpeg/Lavc57.107.100\n\
ScriptType: v4.00+\n\
PlayResX: 384\n\
PlayResY: 288\n\
\n\
[V4+ Styles]\n\
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, \
OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, \
ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, \
MarginR, MarginV, Encoding\n\
Style: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,0\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,legacy\n";
assert!(parse(legacy).expect("parse").scaled_border_and_shadow);
let with_wrapstyle = legacy.replace("PlayResY: 288\n", "PlayResY: 288\nWrapStyle: 0\n");
assert!(
!parse(&with_wrapstyle)
.expect("parse")
.scaled_border_and_shadow
);
}
#[test]
fn handles_bom_crlf_blank_lines_and_nul_truncation() {
let script = parse(
"\u{feff}[Script Info]\r\n\
ScriptType: v4.00+\r\n\
\r\n\
\u{feff}PlayResX: 640\r\n\
[Events]\r\n\
Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,kept\r\n\
\0Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,after nul\r\n",
)
.expect("parse");
assert_eq!(script.play_res_x, 640);
assert_eq!(script.events.len(), 1, "everything after NUL is invisible");
assert_eq!(script.events[0].text, "kept");
}
#[test]
fn unknown_section_headers_bleed_into_the_current_section() {
let script = parse(
"[Script Info]\n\
ScriptType: v4.00+\n\
PlayResX: 640\n\
[Aegisub Project Garbage]\n\
PlayResX: 999\n\
",
)
.expect("parse");
assert_eq!(script.play_res_x, 999);
}
#[test]
fn fonts_section_uudecodes_attachments() {
let script = parse(
"[Script Info]\n\
ScriptType: v4.00+\n\
[Fonts]\n\
fontname: sample_bold.ttf\n\
!!!!\n\
0B\n\
fontname: dropped.ttf\n\
!!!!!\n\
fontname: tail.ttf\n\
0000\n\
",
)
.expect("parse");
assert_eq!(script.fonts.len(), 2, "4k+1 data length discards the font");
assert_eq!(script.fonts[0].filename, "sample_bold.ttf");
assert_eq!(script.fonts[0].data, vec![0, 0, 0, 62]);
assert_eq!(script.fonts[1].filename, "tail.ttf");
assert_eq!(script.fonts[1].data, vec![60, 243, 207]);
}
#[test]
fn incremental_feed_matches_the_decoder_contract() {
let mut parser = ScriptParser::new();
parser.process_codec_private(
"[Script Info]\n\
ScriptType: v4.00+\n\
PlayResX: 384\n\
PlayResY: 288\n\
[V4+ Styles]\n\
Format: Name, Fontname, Fontsize\n\
Style: Default,Arial,16\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
);
parser.process_chunk("0,0,Default,,0,0,0,,First line", 1_000, 2_000);
parser.process_chunk("1,0,Default,,0,0,0,,Second, with comma", 4_000, 1_500);
parser.process_chunk("0,0,Default,,0,0,0,,Duplicate", 9_000, 1_000);
let script = parser.into_script();
assert_eq!(script.play_res_x, 384);
assert_eq!(script.events.len(), 2);
let first = &script.events[0];
assert_eq!(first.text, "First line");
assert_eq!((first.start_ms, first.duration_ms), (1_000, 2_000));
assert_eq!(first.read_order, 0);
assert_eq!(first.style, 1);
let second = &script.events[1];
assert_eq!(second.text, "Second, with comma");
assert_eq!((second.start_ms, second.duration_ms), (4_000, 1_500));
assert_eq!(second.read_order, 1);
}
#[test]
fn codec_private_without_event_format_gets_fallback() {
let mut parser = ScriptParser::new();
parser.process_codec_private("[Script Info]\nScriptType: v4.00+\n");
parser.process_chunk("0,0,Default,,0,0,0,,text", 0, 1_000);
let script = parser.into_script();
assert_eq!(script.events.len(), 1);
assert_eq!(script.events[0].text, "text");
}
#[test]
fn malformed_chunk_reserves_read_order_only_after_first_event() {
let header = "[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
let mut parser = ScriptParser::new();
parser.process_codec_private(header);
parser.process_chunk("5", 0, 1_000);
parser.process_chunk("5,0,Default,,0,0,0,,Visible", 0, 1_000);
let script = parser.into_script();
assert_eq!(script.events.len(), 1);
assert_eq!(script.events[0].text, "Visible");
let mut parser = ScriptParser::new();
parser.process_codec_private(header);
parser.process_chunk("1,0,Default,,0,0,0,,First", 0, 1_000);
parser.process_chunk("7", 0, 1_000);
parser.process_chunk("7,0,Default,,0,0,0,,Late", 0, 1_000);
let script = parser.into_script();
assert_eq!(script.events.len(), 1);
assert_eq!(script.events[0].text, "First");
}
#[test]
fn chunk_layer_is_a_wrapping_int_header() {
let mut parser = ScriptParser::new();
parser.process_codec_private(
"[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
);
parser.process_chunk("7,4294967298,Default,,0,0,0,,wrapped", 100, 200);
let script = parser.into_script();
assert_eq!(script.events.len(), 1);
assert_eq!(script.events[0].read_order, 7);
assert_eq!(script.events[0].layer, 2); }
#[test]
fn force_style_applies_raw_values() {
let mut script = parse(&test_util::minimal_ass(test_util::HELLO_EVENT)).expect("parse");
apply_force_style(
&mut script,
&[
"FontSize=96",
"default.PrimaryColour=&HFF&", "PlayResX=1920", "Bold=700", "ScaleX=200", "Blur=2.5",
"Justify=1",
"AlphaLevel=300", "NoEqualsSignHere",
],
);
assert_eq!(script.play_res_x, 1920);
for style in &script.styles {
assert_eq!(style.font_size, 96.0);
assert_eq!(style.bold, 700);
assert_eq!(style.scale_x, 200.0);
assert_eq!(style.blur, 2.5);
assert_eq!(style.justify, 1);
assert_eq!(style.primary_colour.t, 255);
assert_eq!(style.back_colour.t, 255);
}
assert_eq!(script.styles[0].primary_colour.r, 255);
assert_eq!(script.styles[1].primary_colour.r, 255);
assert_eq!(script.styles[1].primary_colour.b, 0);
}
}