pub mod animate;
pub mod codec;
pub mod container;
pub mod drawing;
#[cfg(feature = "render")]
pub mod render;
pub mod transform;
pub use animate::{
extract_cue_animation, parse_overrides, AnimatedTag, ClipRect, CueAnimation, KaraokeKind,
KaraokeSpan, RenderState,
};
pub use drawing::parse_drawing;
#[cfg(feature = "render")]
pub use render::{make_animated_decoder, AnimatedRenderedDecoder};
use oxideav_core::ContainerRegistry;
use oxideav_core::RuntimeContext;
use oxideav_core::{CodecCapabilities, CodecId, MediaType};
use oxideav_core::{CodecInfo, CodecRegistry};
pub use transform::{ass_to_srt, ass_to_webvtt, srt_to_ass, webvtt_to_ass};
use oxideav_core::{CuePosition, Error, Result, Segment, SubtitleCue, SubtitleStyle, TextAlign};
use oxideav_subtitle::ir::{SourceFormat, SubtitleTrack};
pub fn parse(bytes: &[u8]) -> Result<SubtitleTrack> {
let text = decode_utf8_lossy_stripping_bom(bytes);
let mut track = SubtitleTrack {
source: Some(SourceFormat::AssOrSsa),
..SubtitleTrack::default()
};
let mut current_section = String::new();
let mut style_format: Vec<String> = Vec::new();
let mut event_format: Vec<String> = Vec::new();
let mut is_ssa = false;
let mut extradata = String::new();
for line_raw in text.split('\n') {
let line = line_raw.trim_end_matches('\r');
let trimmed = line.trim();
if trimmed.is_empty() {
if !is_events_body(¤t_section, &event_format) {
extradata.push_str(line);
extradata.push('\n');
}
continue;
}
if trimmed.starts_with(';') || trimmed.starts_with('!') {
if !is_events_body(¤t_section, &event_format) {
extradata.push_str(line);
extradata.push('\n');
}
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
current_section = trimmed[1..trimmed.len() - 1].to_ascii_lowercase();
if current_section == "v4 styles" {
is_ssa = true;
}
if !is_events_body(¤t_section, &event_format) {
extradata.push_str(line);
extradata.push('\n');
}
continue;
}
match current_section.as_str() {
"script info" => {
if let Some((k, v)) = trimmed.split_once(':') {
track.metadata.push((
k.trim().to_ascii_lowercase().replace(' ', "_"),
v.trim().to_string(),
));
}
extradata.push_str(line);
extradata.push('\n');
}
"v4+ styles" | "v4 styles" => {
extradata.push_str(line);
extradata.push('\n');
if let Some(rest) = strip_prefix_case(trimmed, "Format:") {
style_format = rest.split(',').map(|s| s.trim().to_string()).collect();
} else if let Some(rest) = strip_prefix_case(trimmed, "Style:") {
if let Some(style) = parse_style_line(rest, &style_format, is_ssa) {
track.styles.push(style);
}
}
}
"events" => {
if let Some(rest) = strip_prefix_case(trimmed, "Format:") {
event_format = rest.split(',').map(|s| s.trim().to_string()).collect();
extradata.push_str(line);
extradata.push('\n');
} else if let Some(rest) = strip_prefix_case(trimmed, "Dialogue:") {
if let Some(cue) = parse_event_line(rest, &event_format) {
track.cues.push(cue);
}
} else if let Some(_rest) = strip_prefix_case(trimmed, "Comment:") {
} else {
}
}
"fonts" | "graphics" => {
extradata.push_str(line);
extradata.push('\n');
}
_ => {
extradata.push_str(line);
extradata.push('\n');
}
}
}
track.extradata = extradata.into_bytes();
Ok(track)
}
fn is_events_body(section: &str, event_format: &[String]) -> bool {
section == "events" && !event_format.is_empty()
}
fn strip_prefix_case<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.len() < prefix.len() {
return None;
}
if line[..prefix.len()].eq_ignore_ascii_case(prefix) {
Some(line[prefix.len()..].trim_start())
} else {
None
}
}
fn parse_style_line(line: &str, fmt: &[String], is_ssa: bool) -> Option<SubtitleStyle> {
let fields: Vec<&str> = split_csv(line, fmt.len());
if fields.len() < fmt.len() {
return None;
}
let mut style = SubtitleStyle::default();
for (k, v) in fmt.iter().zip(fields.iter()) {
let key = k.to_ascii_lowercase().replace(' ', "");
let val = v.trim();
match key.as_str() {
"name" => style.name = val.to_string(),
"fontname" => style.font_family = Some(val.to_string()),
"fontsize" => style.font_size = val.parse().ok(),
"primarycolour" | "primarycolor" => {
style.primary_color = parse_ass_color(val);
}
"outlinecolour" | "outlinecolor" => {
style.outline_color = parse_ass_color(val);
}
"backcolour" | "backcolor" => {
style.back_color = parse_ass_color(val);
}
"bold" => style.bold = parse_bool_flag(val),
"italic" => style.italic = parse_bool_flag(val),
"underline" => style.underline = parse_bool_flag(val),
"strikeout" | "strikethrough" => style.strike = parse_bool_flag(val),
"alignment" => {
style.align = if is_ssa {
ssa_alignment_to_textalign(val.parse().unwrap_or(2))
} else {
ass_alignment_to_textalign(val.parse().unwrap_or(2))
};
}
"marginl" => style.margin_l = val.parse().ok(),
"marginr" => style.margin_r = val.parse().ok(),
"marginv" => style.margin_v = val.parse().ok(),
"outline" => style.outline = val.parse().ok(),
"shadow" => style.shadow = val.parse().ok(),
_ => {}
}
}
if style.name.is_empty() {
style.name = "Default".into();
}
Some(style)
}
fn ass_alignment_to_textalign(n: i32) -> TextAlign {
match n {
1 | 4 | 7 => TextAlign::Left,
2 | 5 | 8 => TextAlign::Center,
3 | 6 | 9 => TextAlign::Right,
_ => TextAlign::Center,
}
}
fn ssa_alignment_to_textalign(n: i32) -> TextAlign {
match n & 0x03 {
1 => TextAlign::Left,
3 => TextAlign::Right,
_ => TextAlign::Center,
}
}
fn parse_bool_flag(s: &str) -> bool {
let v: i32 = s.parse().unwrap_or(0);
v != 0
}
fn parse_ass_color(s: &str) -> Option<(u8, u8, u8, u8)> {
let s = s.trim().trim_matches('&');
let s = s.trim_start_matches(['H', 'h']);
let s = s.trim_start_matches("0x");
let s = s.trim_end_matches('&').trim();
if s.is_empty() {
return None;
}
let mut v: u32 = u32::from_str_radix(s, 16).ok()?;
let has_alpha = s.len() > 6;
if !has_alpha {
v &= 0x00FF_FFFF;
}
let a = ((v >> 24) & 0xFF) as u8;
let b = ((v >> 16) & 0xFF) as u8;
let g = ((v >> 8) & 0xFF) as u8;
let r = (v & 0xFF) as u8;
Some((r, g, b, 255_u8.saturating_sub(a)))
}
fn split_csv(line: &str, n: usize) -> Vec<&str> {
if n == 0 {
return vec![line];
}
let mut out: Vec<&str> = Vec::with_capacity(n);
let mut cursor = line;
for _ in 0..n - 1 {
if let Some(i) = cursor.find(',') {
out.push(&cursor[..i]);
cursor = &cursor[i + 1..];
} else {
out.push(cursor);
cursor = "";
}
}
out.push(cursor);
out
}
fn parse_event_line(line: &str, fmt: &[String]) -> Option<SubtitleCue> {
if fmt.is_empty() {
return None;
}
let fields = split_csv(line, fmt.len());
if fields.len() < fmt.len() {
return None;
}
let mut start_us: i64 = 0;
let mut end_us: i64 = 0;
let mut style_ref: Option<String> = None;
let mut text: &str = "";
for (k, v) in fmt.iter().zip(fields.iter()) {
let key = k.to_ascii_lowercase();
let val = v.trim();
match key.as_str() {
"start" => start_us = parse_ass_timestamp(val).unwrap_or(0),
"end" => end_us = parse_ass_timestamp(val).unwrap_or(0),
"style" if !val.is_empty() => {
style_ref = Some(val.to_string());
}
"text" => text = v,
_ => {}
}
}
let (segments, positioning) = parse_ass_text(text);
Some(SubtitleCue {
start_us,
end_us,
style_ref,
positioning,
segments,
})
}
fn parse_ass_timestamp(s: &str) -> Option<i64> {
let (hms, frac) = match s.find('.') {
Some(i) => (&s[..i], &s[i + 1..]),
None => (s, "0"),
};
let parts: Vec<&str> = hms.split(':').collect();
let (h, m, sec) = match parts.len() {
3 => (
parts[0].parse::<u32>().ok()?,
parts[1].parse::<u32>().ok()?,
parts[2].parse::<u32>().ok()?,
),
2 => (
0u32,
parts[0].parse::<u32>().ok()?,
parts[1].parse::<u32>().ok()?,
),
_ => return None,
};
let cs_str = if frac.len() > 2 { &frac[..2] } else { frac };
let cs: u32 = if cs_str.is_empty() {
0
} else {
cs_str.parse().ok()?
};
let cs = if frac.len() == 1 { cs * 10 } else { cs };
Some(
(h as i64) * 3_600_000_000
+ (m as i64) * 60_000_000
+ (sec as i64) * 1_000_000
+ (cs as i64) * 10_000,
)
}
fn format_ass_ts(us: i64) -> String {
let us = us.max(0);
let cs_total = us / 10_000;
let cs = (cs_total % 100) as u32;
let s_total = cs_total / 100;
let s = (s_total % 60) as u32;
let m = ((s_total / 60) % 60) as u32;
let h = (s_total / 3_600) as u32;
format!("{}:{:02}:{:02}.{:02}", h, m, s, cs)
}
fn parse_ass_text(text: &str) -> (Vec<Segment>, Option<CuePosition>) {
let mut out: Vec<Segment> = Vec::new();
let mut state = AssState::default();
let mut positioning: Option<CuePosition> = None;
let mut cursor = 0;
let bytes = text.as_bytes();
let mut text_buf = String::new();
while cursor < bytes.len() {
if bytes[cursor] == b'{' {
if !text_buf.is_empty() {
out.push(state.wrap(Segment::Text(std::mem::take(&mut text_buf))));
}
let end = match text[cursor..].find('}') {
Some(e) => cursor + e,
None => {
text_buf.push('{');
cursor += 1;
continue;
}
};
let overrides = &text[cursor + 1..end];
handle_overrides(overrides, &mut state, &mut positioning, &mut out);
cursor = end + 1;
continue;
}
if bytes[cursor] == b'\\' && cursor + 1 < bytes.len() {
let c = bytes[cursor + 1] as char;
if c == 'N' {
if !text_buf.is_empty() {
out.push(state.wrap(Segment::Text(std::mem::take(&mut text_buf))));
}
out.push(Segment::LineBreak);
cursor += 2;
continue;
}
if c == 'n' {
text_buf.push(' ');
cursor += 2;
continue;
}
if c == 'h' {
text_buf.push('\u{00A0}');
cursor += 2;
continue;
}
}
text_buf.push(bytes[cursor] as char);
cursor += 1;
}
if !text_buf.is_empty() {
out.push(state.wrap(Segment::Text(text_buf)));
}
(out, positioning)
}
#[derive(Clone, Debug, Default)]
struct AssState {
bold: bool,
italic: bool,
underline: bool,
strike: bool,
color: Option<(u8, u8, u8)>,
font_family: Option<String>,
font_size: Option<f32>,
}
impl AssState {
fn wrap(&self, seg: Segment) -> Segment {
let mut s = seg;
if self.bold {
s = Segment::Bold(vec![s]);
}
if self.italic {
s = Segment::Italic(vec![s]);
}
if self.underline {
s = Segment::Underline(vec![s]);
}
if self.strike {
s = Segment::Strike(vec![s]);
}
if let Some(rgb) = self.color {
s = Segment::Color {
rgb,
children: vec![s],
};
}
if self.font_family.is_some() || self.font_size.is_some() {
s = Segment::Font {
family: self.font_family.clone(),
size: self.font_size,
children: vec![s],
};
}
s
}
}
fn handle_overrides(
block: &str,
state: &mut AssState,
positioning: &mut Option<CuePosition>,
out: &mut Vec<Segment>,
) {
let mut i = 0;
let bytes = block.as_bytes();
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let mut passthrough = String::new();
while i < bytes.len() {
if bytes[i] != b'\\' {
i += 1;
continue;
}
let tag_start = i;
i += 1;
let start = i;
if i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
} else {
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
}
let name = &block[start..i];
let param_start = i;
let param = if i < bytes.len() && bytes[i] == b'(' {
let end = match block[i..].find(')') {
Some(e) => i + e,
None => block.len(),
};
let p = &block[i + 1..end];
i = (end + 1).min(block.len());
p.to_string()
} else {
while i < bytes.len() && bytes[i] != b'\\' {
i += 1;
}
block[param_start..i].to_string()
};
let tag_end = i;
let name_lc = name.to_ascii_lowercase();
let understood = match name_lc.as_str() {
"b" => {
state.bold = parse_bool_flag(¶m);
true
}
"i" => {
state.italic = parse_bool_flag(¶m);
true
}
"u" => {
state.underline = parse_bool_flag(¶m);
true
}
"s" => {
state.strike = parse_bool_flag(¶m);
true
}
"c" | "1c" => {
if let Some((r, g, b, _)) = parse_ass_color(¶m) {
state.color = Some((r, g, b));
}
true
}
"fn" => {
state.font_family = Some(param.trim().to_string());
true
}
"fs" => {
state.font_size = param.trim().parse().ok();
true
}
"pos" => {
let parts: Vec<&str> = param.split(',').map(|s| s.trim()).collect();
if parts.len() == 2 {
let cp = positioning.get_or_insert_with(CuePosition::default);
cp.x = parts[0].parse().ok();
cp.y = parts[1].parse().ok();
true
} else {
false
}
}
"an" => {
let n: i32 = param.trim().parse().unwrap_or(2);
let cp = positioning.get_or_insert_with(CuePosition::default);
cp.align = ass_alignment_to_textalign(n);
false
}
"a" => {
let n: i32 = param.trim().parse().unwrap_or(2);
let cp = positioning.get_or_insert_with(CuePosition::default);
cp.align = ssa_alignment_to_textalign(n);
false
}
"k" | "kf" | "ko" => {
let cs: u32 = param.trim().parse().unwrap_or(0);
out.push(Segment::Karaoke {
cs,
children: Vec::new(),
});
true
}
"r" => {
*state = AssState::default();
true
}
_ => false,
};
if !understood {
passthrough.push_str(&block[tag_start..tag_end]);
}
}
if !passthrough.is_empty() {
out.push(Segment::Raw(format!("{{{}}}", passthrough)));
}
}
pub fn write(track: &SubtitleTrack) -> Vec<u8> {
let mut out = String::new();
if !track.extradata.is_empty() {
out.push_str(&String::from_utf8_lossy(&track.extradata));
if !out.ends_with('\n') {
out.push('\n');
}
} else {
out.push_str("[Script Info]\n");
out.push_str("ScriptType: v4.00+\n");
for (k, v) in &track.metadata {
let cap = capitalise_key(k);
out.push_str(&format!("{}: {}\n", cap, v));
}
out.push('\n');
out.push_str("[V4+ Styles]\n");
out.push_str("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, Alignment, MarginL, MarginR, MarginV, Outline, Shadow\n");
let has_default = track.styles.iter().any(|s| s.name == "Default");
if !has_default {
out.push_str(&default_style_line());
}
for s in &track.styles {
out.push_str(&style_row(s));
}
out.push('\n');
out.push_str("[Events]\n");
out.push_str(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
);
}
for cue in &track.cues {
let txt = render_event_text(cue);
let style = cue.style_ref.clone().unwrap_or_else(|| "Default".into());
out.push_str(&format!(
"Dialogue: 0,{},{},{},,0,0,0,,{}\n",
format_ass_ts(cue.start_us),
format_ass_ts(cue.end_us),
style,
txt
));
}
out.into_bytes()
}
fn capitalise_key(k: &str) -> String {
k.split('_')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join("")
}
fn default_style_line() -> String {
"Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,2,10,10,10,1,0\n"
.into()
}
fn style_row(s: &SubtitleStyle) -> String {
let col = s
.primary_color
.map(|(r, g, b, a)| format_ass_color(r, g, b, a))
.unwrap_or_else(|| "&H00FFFFFF".into());
let outline = s
.outline_color
.map(|(r, g, b, a)| format_ass_color(r, g, b, a))
.unwrap_or_else(|| "&H00000000".into());
let back = s
.back_color
.map(|(r, g, b, a)| format_ass_color(r, g, b, a))
.unwrap_or_else(|| "&H00000000".into());
let fn_ = s.font_family.clone().unwrap_or_else(|| "Arial".into());
let fs = s.font_size.unwrap_or(20.0);
let align = match s.align {
TextAlign::Left | TextAlign::Start => 1,
TextAlign::Center => 2,
TextAlign::Right | TextAlign::End => 3,
};
format!(
"Style: {},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
s.name,
fn_,
fs,
col,
outline,
back,
s.bold as u8,
s.italic as u8,
s.underline as u8,
s.strike as u8,
align,
s.margin_l.unwrap_or(10),
s.margin_r.unwrap_or(10),
s.margin_v.unwrap_or(10),
s.outline.unwrap_or(1.0),
s.shadow.unwrap_or(0.0),
)
}
fn format_ass_color(r: u8, g: u8, b: u8, a: u8) -> String {
let inv_a = 255_u8.saturating_sub(a);
format!("&H{:02X}{:02X}{:02X}{:02X}", inv_a, b, g, r)
}
fn render_event_text(cue: &SubtitleCue) -> String {
let mut out = String::new();
if let Some(p) = &cue.positioning {
if let (Some(x), Some(y)) = (p.x, p.y) {
out.push_str(&format!("{{\\pos({},{})}}", x as i32, y as i32));
}
}
append_ass_segments(&cue.segments, &mut out);
out
}
fn append_ass_segments(segments: &[Segment], out: &mut String) {
for seg in segments {
match seg {
Segment::Text(s) => {
for c in s.chars() {
match c {
'\n' => out.push_str("\\N"),
'{' | '}' => out.push(c),
_ => out.push(c),
}
}
}
Segment::LineBreak => out.push_str("\\N"),
Segment::Bold(c) => {
out.push_str("{\\b1}");
append_ass_segments(c, out);
out.push_str("{\\b0}");
}
Segment::Italic(c) => {
out.push_str("{\\i1}");
append_ass_segments(c, out);
out.push_str("{\\i0}");
}
Segment::Underline(c) => {
out.push_str("{\\u1}");
append_ass_segments(c, out);
out.push_str("{\\u0}");
}
Segment::Strike(c) => {
out.push_str("{\\s1}");
append_ass_segments(c, out);
out.push_str("{\\s0}");
}
Segment::Color { rgb, children } => {
out.push_str(&format!(
"{{\\c&H{:02X}{:02X}{:02X}&}}",
rgb.2, rgb.1, rgb.0
));
append_ass_segments(children, out);
out.push_str("{\\c}");
}
Segment::Font {
family,
size,
children,
} => {
if let Some(fam) = family {
out.push_str(&format!("{{\\fn{}}}", fam));
}
if let Some(sz) = size {
out.push_str(&format!("{{\\fs{}}}", sz));
}
append_ass_segments(children, out);
}
Segment::Voice { children, .. } | Segment::Class { children, .. } => {
append_ass_segments(children, out);
}
Segment::Karaoke { cs, children } => {
out.push_str(&format!("{{\\k{}}}", cs));
append_ass_segments(children, out);
}
Segment::Timestamp { .. } => {}
Segment::Raw(s) => out.push_str(s),
}
}
}
fn decode_utf8_lossy_stripping_bom(bytes: &[u8]) -> String {
let stripped = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
&bytes[3..]
} else {
bytes
};
String::from_utf8_lossy(stripped).into_owned()
}
pub(crate) fn looks_like_ass(buf: &[u8]) -> bool {
let text = decode_utf8_lossy_stripping_bom(buf);
let head: String = text.chars().take(2048).collect();
let head_lc = head.to_ascii_lowercase();
head_lc.contains("[script info]")
}
pub fn cue_to_bytes_pub(cue: &SubtitleCue) -> Vec<u8> {
cue_to_bytes(cue)
}
pub(crate) fn cue_to_bytes(cue: &SubtitleCue) -> Vec<u8> {
let style = cue.style_ref.clone().unwrap_or_else(|| "Default".into());
let txt = render_event_text(cue);
let line = format!(
"Dialogue: 0,{},{},{},,0,0,0,,{}",
format_ass_ts(cue.start_us),
format_ass_ts(cue.end_us),
style,
txt
);
line.into_bytes()
}
pub(crate) fn bytes_to_cue(bytes: &[u8]) -> Result<SubtitleCue> {
let text = decode_utf8_lossy_stripping_bom(bytes);
let line = text
.lines()
.find(|l| !l.trim().is_empty())
.ok_or_else(|| Error::invalid("ASS: empty cue"))?;
let rest = strip_prefix_case(line.trim(), "Dialogue:")
.ok_or_else(|| Error::invalid("ASS: cue missing Dialogue prefix"))?;
let fmt = [
"Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV", "Effect", "Text",
]
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
parse_event_line(rest, &fmt).ok_or_else(|| Error::invalid("ASS: bad Dialogue line"))
}
pub fn register_codecs(reg: &mut CodecRegistry) {
let caps = CodecCapabilities {
decode: true,
encode: true,
media_type: MediaType::Subtitle,
intra_only: true,
lossy: false,
lossless: true,
hardware_accelerated: false,
implementation: "ass_sw".into(),
max_width: None,
max_height: None,
max_bitrate: None,
max_sample_rate: None,
max_channels: None,
priority: 100,
accepted_pixel_formats: Vec::new(),
};
reg.register(
CodecInfo::new(CodecId::new(codec::ASS_CODEC_ID))
.capabilities(caps)
.decoder(codec::make_decoder)
.encoder(codec::make_encoder),
);
}
pub fn register_containers(reg: &mut ContainerRegistry) {
container::register(reg);
}
pub fn register(ctx: &mut RuntimeContext) {
register_codecs(&mut ctx.codecs);
register_containers(&mut ctx.containers);
}
oxideav_core::register!("ass", register);
#[cfg(test)]
mod register_tests {
use super::*;
#[test]
fn register_via_runtime_context_installs_both_sides() {
let mut ctx = RuntimeContext::new();
register(&mut ctx);
let id = CodecId::new(codec::ASS_CODEC_ID);
assert!(
ctx.codecs.has_decoder(&id),
"ASS decoder factory not installed via RuntimeContext"
);
assert!(
ctx.codecs.has_encoder(&id),
"ASS encoder factory not installed via RuntimeContext"
);
assert_eq!(
ctx.containers.container_for_extension("ass"),
Some("ass"),
"ASS container extension not installed via RuntimeContext"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r"[Script Info]
Title: Test
ScriptType: v4.00+
PlayResX: 384
PlayResY: 288
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H00000000,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,{\b1}Hello{\b0} world
";
#[test]
fn parse_sample() {
let t = parse(SAMPLE.as_bytes()).unwrap();
assert_eq!(t.cues.len(), 1);
assert_eq!(t.cues[0].start_us, 1_000_000);
assert_eq!(t.cues[0].end_us, 3_000_000);
assert_eq!(t.cues[0].style_ref.as_deref(), Some("Default"));
assert!(t.styles.iter().any(|s| s.name == "Default"));
}
#[test]
fn parse_override() {
let t = parse(SAMPLE.as_bytes()).unwrap();
let s0 = &t.cues[0].segments[0];
match s0 {
Segment::Bold(inner) => match &inner[0] {
Segment::Text(s) => assert_eq!(s, "Hello"),
other => panic!("expected text in bold, got {other:?}"),
},
other => panic!("expected bold, got {other:?}"),
}
}
#[test]
fn ass_color_parse() {
let c = parse_ass_color("&H00FF0000").unwrap();
assert_eq!(c, (0, 0, 255, 255));
}
#[test]
fn ass_timestamp() {
let t = parse_ass_timestamp("0:00:01.50").unwrap();
assert_eq!(t, 1_500_000);
}
#[test]
fn looks_like() {
assert!(looks_like_ass(SAMPLE.as_bytes()));
assert!(!looks_like_ass(b"WEBVTT\n"));
}
#[test]
fn unknown_section_body_round_trips_via_extradata() {
let src = "[Script Info]\n\
ScriptType: v4.00+\n\
\n\
[Aegisub Project Garbage]\n\
Last Style Storage: Default\n\
Video Zoom Percent: 0.500000\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,hi\n";
let t = parse(src.as_bytes()).unwrap();
let out = String::from_utf8(write(&t)).unwrap();
assert!(
out.contains("[Aegisub Project Garbage]"),
"section header lost:\n{out}"
);
assert!(
out.contains("Last Style Storage: Default"),
"body line 1 lost:\n{out}"
);
assert!(
out.contains("Video Zoom Percent: 0.500000"),
"body line 2 lost:\n{out}"
);
assert_eq!(out.matches("[Aegisub Project Garbage]").count(), 1);
assert_eq!(out.matches("Last Style Storage: Default").count(), 1);
}
#[test]
fn fonts_section_body_round_trips() {
let src = "[Script Info]\n\
ScriptType: v4.00+\n\
\n\
[Fonts]\n\
fontname: Demo_B.ttf\n\
M0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz=\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,x\n";
let t = parse(src.as_bytes()).unwrap();
let out = String::from_utf8(write(&t)).unwrap();
assert!(out.contains("fontname: Demo_B.ttf"));
assert!(out.contains("M0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz="));
}
#[test]
fn unknown_section_round_trip_is_reparseable() {
let src = "[Script Info]\n\
ScriptType: v4.00+\n\
\n\
[Aegisub Project Garbage]\n\
Last Style Storage: Default\n\
Audio File: ?dummy\n\
\n\
[Aegisub Extradata]\n\
Data: 1,_aegi_perspective_ambient_plane,0;0|0;0|0;0|0;0\n\
\n\
[V4+ Styles]\n\
Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n\
Style: Default,Arial,20,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,1\n\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,first\n\
Dialogue: 0,0:00:03.00,0:00:04.00,Default,,0,0,0,,second\n";
let t1 = parse(src.as_bytes()).unwrap();
let out = String::from_utf8(write(&t1)).unwrap();
let t2 = parse(out.as_bytes()).unwrap();
assert_eq!(t1.cues.len(), t2.cues.len());
for (a, b) in t1.cues.iter().zip(t2.cues.iter()) {
assert_eq!(a.start_us, b.start_us);
assert_eq!(a.end_us, b.end_us);
assert_eq!(a.style_ref, b.style_ref);
}
let out2 = String::from_utf8(write(&t2)).unwrap();
assert!(out2.contains("Last Style Storage: Default"));
assert!(out2.contains("_aegi_perspective_ambient_plane"));
}
}