use crate::error::{Error, Result};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Subtitle {
in_time: (i32, i32),
out_time: (i32, i32),
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubtitleParams {
stream_index: i32,
enabled: bool,
subtitles: Vec<Subtitle>,
}
impl SubtitleParams {
pub fn new() -> Self {
SubtitleParams {
stream_index: 0,
enabled: true,
subtitles: Vec::new(),
}
}
pub fn stream_index(&self) -> i32 {
self.stream_index
}
pub fn set_stream_index(&mut self, index: i32) {
self.stream_index = index;
}
pub fn enabled(&self) -> bool {
self.enabled
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
pub fn is_valid(&self) -> bool {
!self.subtitles.is_empty()
}
pub fn count(&self) -> i32 {
self.subtitles.len() as i32
}
pub fn duration(&self) -> (i32, i32) {
match self.subtitles.last() {
Some(s) => s.out_time,
None => (0, 1),
}
}
pub fn add_subtitle(
&mut self,
in_num: i32,
in_den: i32,
out_num: i32,
out_den: i32,
text: &str,
) -> Result<()> {
self.subtitles.push(Subtitle {
in_time: rational_reduce(in_num, in_den),
out_time: rational_reduce(out_num, out_den),
text: text.to_owned(),
});
Ok(())
}
pub fn clear(&mut self) -> Result<()> {
self.subtitles.clear();
Ok(())
}
pub fn get_subtitle(&self, index: i32) -> Result<((i32, i32), (i32, i32))> {
let s = self.subtitle(index)?;
Ok((s.in_time, s.out_time))
}
pub fn get_subtitle_text(&self, index: i32) -> Result<String> {
let s = self.subtitle(index)?;
Ok(s.text.clone())
}
pub fn generate_ass_header() -> Result<String> {
let mut ass_code = String::new();
ass_code.push_str("[Script Info]\r\n");
ass_code.push_str("; Script generated by Quercus\r\n");
ass_code.push_str("ScriptType: v4.00+\r\n");
ass_code.push_str("PlayResX: 384\r\n");
ass_code.push_str("PlayResY: 288\r\n");
ass_code.push_str("ScaledBorderAndShadow: yes\r\n");
ass_code.push_str("\r\n");
ass_code.push_str("[V4+ Styles]\r\n");
ass_code.push_str(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, \
OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, \
Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, \
MarginV, Encoding\r\n",
);
ass_code.push_str("Style: Default,Arial,16,&HFFFFFF,&HFFFFFF,&H000000,&H000000,");
ass_code.push_str("0,0,0,0,");
ass_code.push_str("100,100,");
ass_code.push_str("0,0,");
ass_code.push_str("1,1,0,");
ass_code.push_str("2,10,10,10,");
ass_code.push_str("0\r\n");
ass_code.push_str("\r\n");
ass_code.push_str("[Events]\r\n");
ass_code.push_str(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\r\n",
);
Ok(ass_code)
}
pub fn load_xml(&mut self, xml: &str) -> Result<()> {
let mut reader = XmlReader::parse(xml)?;
if reader.read_next_start().is_none() {
return Err(Error::Failed("missing root element".into()));
}
self.clear()?;
while let Some(ev) = reader.read_next_start() {
match ev.name.as_str() {
"streamindex" => {
let text = reader.read_element_text();
let index: i32 = text
.parse()
.map_err(|_| Error::Failed("invalid subtitleparams streamindex".into()))?;
self.set_stream_index(index);
}
"enabled" => {
let text = reader.read_element_text();
let enabled: i32 = text
.parse()
.map_err(|_| Error::Failed("invalid subtitleparams enabled".into()))?;
self.set_enabled(enabled != 0);
}
"subtitles" => {
while let Some(sub) = reader.read_next_start() {
if sub.name == "subtitle" {
let mut in_time = (0, 1);
let mut out_time = (0, 1);
for (name, value) in &sub.attrs {
if name == "in" {
in_time = rational_from_string(value);
} else if name == "out" {
out_time = rational_from_string(value);
}
}
let text = reader.read_element_text();
self.subtitles.push(Subtitle {
in_time,
out_time,
text,
});
} else {
reader.skip_current_element();
}
}
}
_ => reader.skip_current_element(),
}
}
Ok(())
}
pub fn save_xml(&self) -> Result<String> {
let mut out = String::new();
out.push_str("<subtitleparams>");
out.push_str("<streamindex>");
out.push_str(&self.stream_index.to_string());
out.push_str("</streamindex>");
out.push_str("<enabled>");
out.push_str(if self.enabled { "1" } else { "0" });
out.push_str("</enabled>");
out.push_str("<subtitles>");
for sub in &self.subtitles {
out.push_str("<subtitle");
out.push_str(" in=\"");
out.push_str(&escape_attribute(&rational_to_string(sub.in_time)));
out.push('"');
out.push_str(" out=\"");
out.push_str(&escape_attribute(&rational_to_string(sub.out_time)));
out.push('"');
out.push('>');
out.push_str(&escape_text(&sub.text));
out.push_str("</subtitle>");
}
out.push_str("</subtitles>");
out.push_str("</subtitleparams>");
Ok(out)
}
fn subtitle(&self, index: i32) -> Result<&Subtitle> {
let i = usize::try_from(index).map_err(|_| Error::NotFound)?;
self.subtitles.get(i).ok_or(Error::NotFound)
}
}
fn rational_reduce(mut num: i32, mut den: i32) -> (i32, i32) {
if den < 0 {
den = -den;
num = -num;
} else if den == 0 {
num = 0;
} else if num == 0 {
den = 1;
}
if den != 0 {
let gcd = i64_gcd(num as i64, den as i64);
if gcd > 0 {
num = (num as i64 / gcd) as i32;
den = (den as i64 / gcd) as i32;
}
}
(num, den)
}
fn rational_to_string((num, den): (i32, i32)) -> String {
format!("{}/{}", num, den)
}
fn rational_from_string(s: &str) -> (i32, i32) {
let parts: Vec<&str> = s.split('/').collect();
match parts.len() {
1 => (parse_lenient(parts[0]), 1),
2 => rational_reduce(parse_lenient(parts[0]), parse_lenient(parts[1])),
_ => (0, 0),
}
}
fn parse_lenient(s: &str) -> i32 {
s.parse::<i32>().unwrap_or(0)
}
fn i64_gcd(mut a: i64, mut b: i64) -> i64 {
if a < 0 {
a = -a;
}
if b < 0 {
b = -b;
}
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a
}
fn escape_text(inp: &str) -> String {
let mut out = String::with_capacity(inp.len());
for c in inp.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn escape_attribute(inp: &str) -> String {
let mut out = String::with_capacity(inp.len());
for c in inp.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
#[derive(Clone, Debug)]
enum XmlEvent {
Start {
name: String,
attrs: Vec<(String, String)>,
},
End,
Characters(String),
}
struct XmlStart {
name: String,
attrs: Vec<(String, String)>,
}
enum XmlToken {
Start(XmlStart),
End,
Characters(String),
EndDocument,
}
struct XmlReader {
events: Vec<XmlEvent>,
pos: usize,
}
impl XmlReader {
fn parse(data: &str) -> Result<XmlReader> {
let events = parse_events(data)?;
Ok(XmlReader { events, pos: 0 })
}
fn read_next(&mut self) -> XmlToken {
if self.pos >= self.events.len() {
return XmlToken::EndDocument;
}
let ev = &self.events[self.pos];
self.pos += 1;
match ev {
XmlEvent::Start { name, attrs } => XmlToken::Start(XmlStart {
name: name.clone(),
attrs: attrs.clone(),
}),
XmlEvent::End => XmlToken::End,
XmlEvent::Characters(t) => XmlToken::Characters(t.clone()),
}
}
fn read_next_start(&mut self) -> Option<XmlStart> {
loop {
match self.read_next() {
XmlToken::Start(s) => return Some(s),
XmlToken::End | XmlToken::EndDocument => return None,
XmlToken::Characters(_) => continue,
}
}
}
fn read_element_text(&mut self) -> String {
let mut result = String::new();
let mut depth = 1;
loop {
match self.read_next() {
XmlToken::EndDocument => break,
XmlToken::Start(_) => depth += 1,
XmlToken::End => {
depth -= 1;
if depth == 0 {
break;
}
}
XmlToken::Characters(t) => {
if depth == 1 {
result.push_str(&t);
}
}
}
}
result
}
fn skip_current_element(&mut self) {
let mut depth = 1;
loop {
match self.read_next() {
XmlToken::EndDocument => break,
XmlToken::Start(_) => depth += 1,
XmlToken::End => {
depth -= 1;
if depth == 0 {
break;
}
}
XmlToken::Characters(_) => {}
}
}
}
}
fn parse_events(data: &str) -> Result<Vec<XmlEvent>> {
let b = data.as_bytes();
let n = b.len();
let mut i = 0;
let mut events = Vec::new();
while i < n {
if b[i] != b'<' {
let start = i;
while i < n && b[i] != b'<' {
i += 1;
}
if i > start {
events.push(XmlEvent::Characters(decode_entities(&data[start..i])));
}
continue;
}
if i + 1 >= n {
return Err(Error::Failed(
"unterminated '<' in subtitleparams xml".into(),
));
}
match b[i + 1] {
b'/' => {
let mut j = i + 2;
while j < n && b[j] != b'>' {
j += 1;
}
if j >= n {
return Err(Error::Failed("unterminated end element".into()));
}
events.push(XmlEvent::End);
i = j + 1;
}
b'!' => {
if data[i + 2..].starts_with("--") {
let content = i + 4;
match data[content..].find("-->") {
Some(p) => i = content + p + 3,
None => return Err(Error::Failed("unterminated comment".into())),
}
} else {
let mut j = i + 2;
let mut subset = 0usize;
while j < n {
match b[j] {
b'[' => subset += 1,
b']' => subset = subset.saturating_sub(1),
b'>' if subset == 0 => break,
_ => {}
}
j += 1;
}
if j >= n {
return Err(Error::Failed("unterminated declaration".into()));
}
i = j + 1;
}
}
b'?' => {
match data[i + 2..].find("?>") {
Some(p) => i = i + 2 + p + 2,
None => {
return Err(Error::Failed("unterminated processing instruction".into()))
}
}
}
_ => {
i += 1;
let name_start = i;
while i < n && !is_tag_delim(b[i]) {
i += 1;
}
if name_start == i {
return Err(Error::Failed("empty element name".into()));
}
let name = data[name_start..i].to_string();
let mut attrs = Vec::new();
loop {
skip_ws(&mut i, b, n);
if i >= n {
return Err(Error::Failed("unterminated start element".into()));
}
if b[i] == b'>' {
i += 1;
events.push(XmlEvent::Start { name, attrs });
break;
}
if b[i] == b'/' && i + 1 < n && b[i + 1] == b'>' {
i += 2;
events.push(XmlEvent::Start { name, attrs });
events.push(XmlEvent::End);
break;
}
let aname_start = i;
while i < n && !is_tag_delim(b[i]) {
i += 1;
}
if aname_start == i {
return Err(Error::Failed(
"malformed attribute in subtitleparams xml".into(),
));
}
let aname = data[aname_start..i].to_string();
skip_ws(&mut i, b, n);
if i >= n || b[i] != b'=' {
return Err(Error::Failed("attribute missing '='".into()));
}
i += 1;
skip_ws(&mut i, b, n);
if i >= n || (b[i] != b'"' && b[i] != b'\'') {
return Err(Error::Failed("attribute value not quoted".into()));
}
let quote = b[i];
i += 1;
let vstart = i;
while i < n && b[i] != quote {
i += 1;
}
if i >= n {
return Err(Error::Failed("unterminated attribute value".into()));
}
let value = decode_entities(&data[vstart..i]);
attrs.push((aname, value));
i += 1;
}
}
}
}
Ok(events)
}
fn skip_ws(i: &mut usize, b: &[u8], n: usize) {
while *i < n && (b[*i] == b' ' || b[*i] == b'\t' || b[*i] == b'\r' || b[*i] == b'\n') {
*i += 1;
}
}
fn is_tag_delim(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/' | b'=')
}
fn decode_entities(s: &str) -> String {
let b = s.as_bytes();
let n = b.len();
let mut out = String::with_capacity(n);
let mut i = 0;
while i < n {
if b[i] == b'&' {
if let Some(rel) = b[i + 1..].iter().position(|&x| x == b';') {
let semi = i + 1 + rel;
let entity = &s[i + 1..semi];
match entity {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
_ => {
let decoded = if let Some(hex) = entity
.strip_prefix("#x")
.or_else(|| entity.strip_prefix("#X"))
{
u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
} else if let Some(dec) = entity.strip_prefix('#') {
dec.parse::<u32>().ok().and_then(char::from_u32)
} else {
None
};
match decoded {
Some(c) => out.push(c),
None => {
out.push('&');
out.push_str(entity);
out.push(';');
}
}
}
}
i = semi + 1;
continue;
}
out.push('&');
i += 1;
continue;
}
let ch = s[i..].chars().next().expect("byte index on char boundary");
out.push(ch);
i += ch.len_utf8();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_params() -> SubtitleParams {
let mut sp = SubtitleParams::new();
sp.set_stream_index(2);
sp.set_enabled(false);
sp.add_subtitle(0, 1, 25, 1, "hello").unwrap();
sp.add_subtitle(25, 1, 50, 1, "world").unwrap();
sp
}
#[test]
fn defaults() {
let sp = SubtitleParams::new();
assert_eq!(sp.stream_index(), 0);
assert!(sp.enabled());
assert!(!sp.is_valid());
assert_eq!(sp.count(), 0);
assert_eq!(sp.duration(), (0, 1));
}
#[test]
fn getters_setters() {
let mut sp = SubtitleParams::new();
sp.set_stream_index(7);
assert_eq!(sp.stream_index(), 7);
sp.set_enabled(false);
assert!(!sp.enabled());
sp.set_enabled(true);
assert!(sp.enabled());
}
#[test]
fn add_and_query() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(0, 1, 25, 1, "hello").unwrap();
sp.add_subtitle(25, 1, 50, 1, "world").unwrap();
assert_eq!(sp.count(), 2);
assert!(sp.is_valid());
assert_eq!(sp.duration(), (50, 1));
assert_eq!(sp.get_subtitle(0).unwrap(), ((0, 1), (25, 1)));
assert_eq!(sp.get_subtitle(1).unwrap(), ((25, 1), (50, 1)));
assert_eq!(sp.get_subtitle_text(0).unwrap(), "hello");
assert_eq!(sp.get_subtitle_text(1).unwrap(), "world");
}
#[test]
fn add_subtitle_reduces() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(2, 4, 9, 3, "t").unwrap();
assert_eq!(sp.get_subtitle(0).unwrap(), ((1, 2), (3, 1)));
assert_eq!(sp.duration(), (3, 1));
}
#[test]
fn add_subtitle_normalizes_sign_and_zero() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(0, 5, -3, -1, "t").unwrap();
assert_eq!(sp.get_subtitle(0).unwrap(), ((0, 1), (3, 1)));
}
#[test]
fn out_of_range() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(0, 1, 1, 1, "t").unwrap();
assert!(sp.get_subtitle(-1).is_err());
assert!(sp.get_subtitle(1).is_err());
assert!(sp.get_subtitle_text(-1).is_err());
assert!(sp.get_subtitle_text(5).is_err());
}
#[test]
fn clear() {
let mut sp = sample_params();
assert_eq!(sp.count(), 2);
sp.clear().unwrap();
assert_eq!(sp.count(), 0);
assert!(!sp.is_valid());
assert_eq!(sp.duration(), (0, 1));
}
#[test]
fn ass_header_exact() {
let expected = "[Script Info]\r\n\
; Script generated by Quercus\r\n\
ScriptType: v4.00+\r\n\
PlayResX: 384\r\n\
PlayResY: 288\r\n\
ScaledBorderAndShadow: yes\r\n\
\r\n\
[V4+ Styles]\r\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\r\n\
Style: Default,Arial,16,&HFFFFFF,&HFFFFFF,&H000000,&H000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,0\r\n\
\r\n\
[Events]\r\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\r\n";
assert_eq!(SubtitleParams::generate_ass_header().unwrap(), expected);
}
#[test]
fn save_xml_exact() {
let sp = sample_params();
let expected = "<subtitleparams>\
<streamindex>2</streamindex>\
<enabled>0</enabled>\
<subtitles>\
<subtitle in=\"0/1\" out=\"25/1\">hello</subtitle>\
<subtitle in=\"25/1\" out=\"50/1\">world</subtitle>\
</subtitles>\
</subtitleparams>";
assert_eq!(sp.save_xml().unwrap(), expected);
}
#[test]
fn save_xml_escapes_text_and_attrs() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(1, 1, 2, 1, "a < b & \"c\" > d").unwrap();
let xml = sp.save_xml().unwrap();
assert!(xml.contains(">a < b & \"c\" > d<"));
assert!(!xml.contains("""));
}
#[test]
fn load_xml_round_trip() {
let sp = sample_params();
let xml = sp.save_xml().unwrap();
let mut loaded = SubtitleParams::new();
loaded.load_xml(&xml).unwrap();
assert_eq!(loaded.stream_index(), 2);
assert!(!loaded.enabled());
assert_eq!(loaded.count(), 2);
assert_eq!(loaded.get_subtitle(0).unwrap(), ((0, 1), (25, 1)));
assert_eq!(loaded.get_subtitle_text(0).unwrap(), "hello");
assert_eq!(loaded.get_subtitle(1).unwrap(), ((25, 1), (50, 1)));
assert_eq!(loaded.duration(), (50, 1));
}
#[test]
fn load_xml_empty() {
let mut sp = SubtitleParams::new();
sp.load_xml("<subtitleparams><streamindex>3</streamindex><enabled>1</enabled><subtitles/></subtitleparams>")
.unwrap();
assert_eq!(sp.stream_index(), 3);
assert!(sp.enabled());
assert_eq!(sp.count(), 0);
}
#[test]
fn load_xml_escaped_entities() {
let mut sp = SubtitleParams::new();
sp.load_xml(
"<subtitleparams><streamindex>0</streamindex><enabled>1</enabled>\
<subtitles><subtitle in=\"1/2\" out=\"3/4\">a < b & c</subtitle></subtitles>\
</subtitleparams>",
)
.unwrap();
assert_eq!(sp.count(), 1);
assert_eq!(sp.get_subtitle(0).unwrap(), ((1, 2), (3, 4)));
assert_eq!(sp.get_subtitle_text(0).unwrap(), "a < b & c");
}
#[test]
fn load_xml_skips_unknown_elements() {
let mut sp = SubtitleParams::new();
sp.load_xml(
"<subtitleparams><unknown><nested/></unknown>\
<streamindex>9</streamindex>\
<subtitles><mystery>x</mystery><subtitle in=\"5/1\" out=\"10/1\">ok</subtitle></subtitles>\
</subtitleparams>",
)
.unwrap();
assert_eq!(sp.stream_index(), 9);
assert_eq!(sp.count(), 1);
assert_eq!(sp.get_subtitle_text(0).unwrap(), "ok");
}
#[test]
fn load_xml_resets_existing_content() {
let mut sp = sample_params();
sp.load_xml(
"<subtitleparams><streamindex>1</streamindex><enabled>1</enabled><subtitles/></subtitleparams>",
)
.unwrap();
assert_eq!(sp.count(), 0);
assert_eq!(sp.stream_index(), 1);
assert!(sp.enabled());
}
#[test]
fn load_xml_malformed_is_failed() {
let mut sp = SubtitleParams::new();
assert!(sp.load_xml("<subtitleparams><streamindex>").is_err());
assert!(sp.load_xml("not xml at all").is_err());
assert!(sp.load_xml("text only").is_err());
assert!(sp.load_xml("").is_err());
}
#[test]
fn load_xml_invalid_streamindex_is_failed() {
let mut sp = SubtitleParams::new();
assert!(sp
.load_xml("<subtitleparams><streamindex>abc</streamindex><subtitles/></subtitleparams>")
.is_err());
}
#[test]
fn load_xml_whitespace_round_trip() {
let mut sp = SubtitleParams::new();
sp.load_xml(
" <subtitleparams>\n <streamindex>4</streamindex>\n <enabled>1</enabled>\n \
<subtitles>\n <subtitle in=\"0/1\" out=\"1/1\">hi</subtitle>\n </subtitles>\n \
</subtitleparams>\n",
)
.unwrap();
assert_eq!(sp.stream_index(), 4);
assert!(sp.enabled());
assert_eq!(sp.count(), 1);
assert_eq!(sp.get_subtitle_text(0).unwrap(), "hi");
}
#[test]
fn rational_helpers() {
assert_eq!(rational_to_string((0, 1)), "0/1");
assert_eq!(rational_to_string((-25, 1)), "-25/1");
assert_eq!(rational_reduce(2, 4), (1, 2));
assert_eq!(rational_reduce(0, 5), (0, 1));
assert_eq!(rational_reduce(-3, -1), (3, 1));
assert_eq!(rational_reduce(5, 0), (0, 0)); assert_eq!(rational_from_string("1/2"), (1, 2));
assert_eq!(rational_from_string("7"), (7, 1));
assert_eq!(rational_from_string("4/2"), (2, 1));
assert_eq!(rational_from_string("junk"), (0, 1)); assert_eq!(rational_from_string("a/b"), (0, 0)); assert_eq!(rational_from_string("1/2/3"), (0, 0)); }
#[test]
fn escape_helpers() {
assert_eq!(escape_text("a<b&c>d"), "a<b&c>d");
assert_eq!(escape_attribute("a\"b<c>&d"), "a"b<c>&d");
assert_eq!(
decode_entities("a<b&c>d"e'f"),
"a<b&c>d\"e'f"
);
assert_eq!(decode_entities("AB"), "AB");
assert_eq!(decode_entities("keep &unknown;"), "keep &unknown;");
}
#[test]
fn add_subtitle_nan_rational_round_trip() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(5, 0, 1, 1, "t").unwrap();
assert_eq!(sp.get_subtitle(0).unwrap(), ((0, 0), (1, 1)));
let xml = sp.save_xml().unwrap();
assert!(xml.contains("in=\"0/0\""));
let mut loaded = SubtitleParams::new();
loaded.load_xml(&xml).unwrap();
assert_eq!(loaded.get_subtitle(0).unwrap(), ((0, 0), (1, 1)));
}
#[test]
fn get_subtitle_index_at_count() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(0, 1, 1, 1, "t").unwrap();
assert!(sp.get_subtitle(1).is_err());
assert!(sp.get_subtitle_text(1).is_err());
assert!(sp.get_subtitle(i32::MAX).is_err());
assert!(sp.get_subtitle(i32::MIN).is_err());
}
#[test]
fn ordering_and_duplicates_preserved() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(10, 1, 20, 1, "b").unwrap();
sp.add_subtitle(0, 1, 5, 1, "a").unwrap();
sp.add_subtitle(10, 1, 20, 1, "b").unwrap();
assert_eq!(sp.count(), 3);
assert_eq!(sp.get_subtitle_text(0).unwrap(), "b");
assert_eq!(sp.get_subtitle_text(1).unwrap(), "a");
assert_eq!(sp.get_subtitle_text(2).unwrap(), "b");
assert_eq!(sp.duration(), (20, 1));
}
#[test]
fn load_xml_enabled_variants() {
let mut sp = SubtitleParams::new();
sp.load_xml("<subtitleparams><enabled>2</enabled><subtitles/></subtitleparams>")
.unwrap();
assert!(sp.enabled()); assert!(sp
.load_xml("<subtitleparams><enabled>abc</enabled><subtitles/></subtitleparams>")
.is_err());
}
#[test]
fn load_xml_subtitle_default_attributes() {
let mut sp = SubtitleParams::new();
sp.load_xml(
"<subtitleparams><subtitles>\
<subtitle>no attrs</subtitle>\
<subtitle in=\"3/1\">only in</subtitle>\
<subtitle in=\"1/1\" out=\"2/1\"/>\
</subtitles></subtitleparams>",
)
.unwrap();
assert_eq!(sp.count(), 3);
assert_eq!(sp.get_subtitle(0).unwrap(), ((0, 1), (0, 1)));
assert_eq!(sp.get_subtitle(1).unwrap(), ((3, 1), (0, 1)));
assert_eq!(sp.get_subtitle(2).unwrap(), ((1, 1), (2, 1)));
assert_eq!(sp.get_subtitle_text(2).unwrap(), "");
}
#[test]
fn load_xml_declaration_comment_doctype_skipped() {
let mut sp = SubtitleParams::new();
sp.load_xml(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE subtitleparams>\n\
<!-- a comment -->\n\
<subtitleparams><streamindex>6</streamindex><subtitles>\
<subtitle in=\"0/1\" out=\"1/1\">x</subtitle>\
</subtitles></subtitleparams>",
)
.unwrap();
assert_eq!(sp.stream_index(), 6);
assert_eq!(sp.count(), 1);
}
#[test]
fn load_xml_numeric_entities_and_single_quotes() {
let mut sp = SubtitleParams::new();
sp.load_xml(
"<subtitleparams><subtitles>\
<subtitle in='1/2' out='3/4'>ABc</subtitle>\
</subtitles></subtitleparams>",
)
.unwrap();
assert_eq!(sp.get_subtitle(0).unwrap(), ((1, 2), (3, 4)));
assert_eq!(sp.get_subtitle_text(0).unwrap(), "ABc");
}
#[test]
fn load_xml_unknown_entity_preserved() {
let mut sp = SubtitleParams::new();
sp.load_xml(
"<subtitleparams><subtitles>\
<subtitle in=\"0/1\" out=\"1/1\">a &bogus; b</subtitle>\
</subtitles></subtitleparams>",
)
.unwrap();
assert_eq!(sp.get_subtitle_text(0).unwrap(), "a &bogus; b");
}
#[test]
fn load_xml_malformed_variants() {
let mut sp = SubtitleParams::new();
assert!(sp
.load_xml("<subtitleparams><subtitles><subtitle in=\"1/1>x</subtitle></subtitles></subtitleparams>")
.is_err());
assert!(sp
.load_xml("<subtitleparams><subtitles><subtitle in \"1/1\">x</subtitle></subtitles></subtitleparams>")
.is_err());
assert!(sp
.load_xml("<subtitleparams><subtitles><subtitle in=1/1>x</subtitle></subtitles></subtitleparams>")
.is_err());
assert!(sp.load_xml("<?xml version=\"1.0\"").is_err());
assert!(sp.load_xml("<!-- no end").is_err());
assert!(sp.load_xml("<!DOCTYPE foo").is_err());
}
#[test]
fn save_load_round_trip_unicode_and_empty_text() {
let mut sp = SubtitleParams::new();
sp.add_subtitle(0, 1, 1, 2, "").unwrap();
sp.add_subtitle(1, 2, 1, 1, "héllo — 你好 <&>").unwrap();
let xml = sp.save_xml().unwrap();
let mut loaded = SubtitleParams::new();
loaded.load_xml(&xml).unwrap();
assert_eq!(loaded.count(), 2);
assert_eq!(loaded.get_subtitle_text(0).unwrap(), "");
assert_eq!(loaded.get_subtitle_text(1).unwrap(), "héllo — 你好 <&>");
assert_eq!(loaded.get_subtitle(1).unwrap(), ((1, 2), (1, 1)));
assert_eq!(loaded.save_xml().unwrap(), xml);
}
#[test]
fn rational_reduce_matches_quercuscore() {
for (n, d) in [
(2, 4),
(0, 5),
(5, 0),
(-3, -1),
(1, -2),
(7, 3),
(i32::MAX, 1),
] {
let r = crate::Rational::new(n as i64, d as i64);
assert_eq!(
rational_reduce(n, d),
(r.numerator() as i32, r.denominator() as i32)
);
}
for s in ["1/2", "7", "4/2", "junk", "a/b", "1/2/3", ""] {
let r = crate::Rational::from_string(s);
assert_eq!(
rational_from_string(s),
(r.numerator() as i32, r.denominator() as i32),
"input {s:?}"
);
}
}
}