// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! Subtitle parameter set, mirroring `src/common/src/subtitleparams.h` //! and `include/common/subtitleparams.h`. A handle-wrapped value object //! holding an ordered list of subtitle entries. The C++-only //! `_init_from_native` entry point deals with `olive::SubtitleParams` and //! is served by the C++ adapter layer, not here. //! //! XML loading/saving is hand-rolled here (no external crate, no serde/xml) //! rather than built on `crate::xmlutils`' streaming reader/writer, keeping //! this value module self-contained. The produced/consumed XML matches //! `olive::XmlStreamWriter`/`XmlStreamReader` exactly (see //! `src/common/src/xmlutils.cpp`): no indentation, `& < >` escaped in //! character data, `& < > "` escaped in attributes. use crate::error::{Error, Result}; /// A single subtitle entry. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Subtitle { /// In-time as a rational. in_time: (i32, i32), /// Out-time as a rational. out_time: (i32, i32), /// Subtitle text. text: String, } /// `olive::SubtitleParams` — a handle-wrapped subtitle parameter set. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SubtitleParams { /// Stream index within the source file. stream_index: i32, /// Whether the subtitle stream is enabled. enabled: bool, /// Ordered subtitle entries. subtitles: Vec, } impl SubtitleParams { /// Create an empty subtitle parameter set. /// /// CPP-PARITY: the C++ default constructor sets `stream_index_ = 0` and /// `enabled_ = true` (`src/common/src/subtitleparams.h`); the subtitle /// vector starts empty. pub fn new() -> Self { SubtitleParams { stream_index: 0, enabled: true, subtitles: Vec::new(), } } /// Stream index within the source file. pub fn stream_index(&self) -> i32 { self.stream_index } /// Set the stream index. pub fn set_stream_index(&mut self, index: i32) { self.stream_index = index; } /// Whether the subtitle stream is enabled. pub fn enabled(&self) -> bool { self.enabled } /// Set whether the subtitle stream is enabled. pub fn set_enabled(&mut self, enabled: bool) { self.enabled = enabled; } /// Whether the set contains at least one subtitle. pub fn is_valid(&self) -> bool { !self.subtitles.is_empty() } /// Number of subtitle entries. pub fn count(&self) -> i32 { self.subtitles.len() as i32 } /// Out-time of the last subtitle (`0/1` when empty), as a rational. /// /// CPP-PARITY: C++ `duration()` returns `Rational(0)` (= `0/1`) when /// empty, otherwise `back().time().out()`. pub fn duration(&self) -> (i32, i32) { match self.subtitles.last() { Some(s) => s.out_time, None => (0, 1), } } /// Append a subtitle entry. /// /// CPP-PARITY: the C++ c_api builds `olive::core::Rational(in_num, /// in_den)` / `Rational(out_num, out_den)`, whose constructor calls /// `fix_signs()` + `reduce()`. The stored rationals are therefore the /// reduced forms, so we reduce here too. 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(()) } /// Remove all subtitle entries. pub fn clear(&mut self) -> Result<()> { self.subtitles.clear(); Ok(()) } /// Get the time range of the subtitle at `index`. pub fn get_subtitle(&self, index: i32) -> Result<((i32, i32), (i32, i32))> { let s = self.subtitle(index)?; Ok((s.in_time, s.out_time)) } /// Get the text of the subtitle at `index`. pub fn get_subtitle_text(&self, index: i32) -> Result { let s = self.subtitle(index)?; Ok(s.text.clone()) } /// Generate a default ASS header (static, no handle required). /// /// CPP-PARITY: verbatim port of `SubtitleParams::generate_ass_header()` /// (`src/common/src/subtitleparams.cpp`); `&H%X` is uppercase hex, and /// lines end in CRLF. pub fn generate_ass_header() -> Result { let mut ass_code = String::new(); // Header info ass_code.push_str("[Script Info]\r\n"); ass_code.push_str("; Script generated by Oak\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"); // ASSv4 header 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) } /// Load subtitles from an XML fragment. /// /// CPP-PARITY: mirrors the c_api `load_xml`, which positions the reader /// on the root element and then calls `load()`. `streamindex`/`enabled` /// use `std::stoi` semantics (invalid text makes the C++ path throw, /// surfacing as `E_FAILED`), while the `in`/`out` attributes use /// `Rational::from_string` (lenient, invalid text becomes `0`). pub fn load_xml(&mut self, xml: &str) -> Result<()> { let mut reader = XmlReader::parse(xml)?; // Position on the root element; load() consumes its children. 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(()) } /// Save subtitles to an XML fragment. /// /// CPP-PARITY: emits `` wrapping `streamindex`, `enabled` /// and `subtitles` exactly as `write_text_element`/`write_start_element`/ /// `write_attribute`/`write_characters`/`write_end_element` would /// (`src/common/src/xmlutils.cpp`), with no indentation. pub fn save_xml(&self) -> Result { let mut out = String::new(); out.push_str(""); out.push_str(""); out.push_str(&self.stream_index.to_string()); out.push_str(""); out.push_str(""); out.push_str(if self.enabled { "1" } else { "0" }); out.push_str(""); out.push_str(""); for sub in &self.subtitles { out.push_str("'); out.push_str(&escape_text(&sub.text)); out.push_str(""); } out.push_str(""); out.push_str(""); Ok(out) } /// Borrow the subtitle at `index` (or `E_NOT_FOUND`). 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) } } /// Normalize a rational to its canonical reduced form, mirroring the C++ /// `Rational(int, int)` constructor (`fix_signs()` then `reduce()`, /// `core/src/util/rational.cpp`). fn rational_reduce(mut num: i32, mut den: i32) -> (i32, i32) { // fix_signs() if den < 0 { den = -den; num = -num; } else if den == 0 { num = 0; } else if num == 0 { den = 1; } // reduce_fraction(num, den, INT_MAX): inputs are i32, so after gcd // division both |num| and |den| are <= INT_MAX and the continued-fraction // fallback in the C++ `reduce_fraction` is unreachable — the simple gcd // path is exact. 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) } /// `Rational::to_string()` — `"%d/%d"`. fn rational_to_string((num, den): (i32, i32)) -> String { format!("{}/{}", num, den) } /// `Rational::from_string()` — `num/den`, or a bare `num` (denominator 1); /// anything else is `0/0` (NaN). Values are reduced via the two-argument /// constructor. Lenient: invalid integers become `0`, mirroring /// `StringUtils::to_int` (which does not throw). 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), } } /// Lenient `to_int`: returns `0` for empty/unparseable text. fn parse_lenient(s: &str) -> i32 { s.parse::().unwrap_or(0) } /// Absolute-value GCD on i64 (`i64_gcd` in `core/src/util/fractionutils.cpp`). 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 } /// Escape text character data (`& < >`), mirroring `escape_text` in /// `src/common/src/xmlutils.cpp`. 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 } /// Escape attribute values (`& < > "`), mirroring `escape_attribute` in /// `src/common/src/xmlutils.cpp`. 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 } /// One parsed XML token, mirroring the `XmlStreamReader::Event` subset the /// C++ `load()` actually consumes. #[derive(Clone, Debug)] enum XmlEvent { Start { name: String, attrs: Vec<(String, String)>, }, End { name: String, }, Characters(String), } /// A start element (name + attributes), returned by [`XmlReader::read_next_start`]. struct XmlStart { name: String, attrs: Vec<(String, String)>, } /// The token returned by [`XmlReader::read_next`]. enum XmlToken { Start(XmlStart), End, Characters(String), EndDocument, } /// Minimal pull-style XML reader, mirroring the subset of /// `olive::XmlStreamReader` used by `SubtitleParams::load()`: /// start/end elements, attributes, character data, `read_element_text()` /// and `skip_current_element()`. The whole document is parsed up front /// (like the expat-backed C++ reader); comments, processing instructions /// and DOCTYPE declarations are skipped. The five predefined entities plus /// numeric character references are decoded. struct XmlReader { events: Vec, pos: usize, } impl XmlReader { fn parse(data: &str) -> Result { let events = parse_events(data)?; Ok(XmlReader { events, pos: 0 }) } /// `read_next()`: advance one token. 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()), } } /// `xml_read_next_start_element()`: advance until the next start element /// (returned), or until an end element / end of document (returned as /// `None`). fn read_next_start(&mut self) -> Option { loop { match self.read_next() { XmlToken::Start(s) => return Some(s), XmlToken::End | XmlToken::EndDocument => return None, XmlToken::Characters(_) => continue, } } } /// `read_element_text()`: concatenated character data of the current /// element, consumed up to and including the matching end element. 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 } /// `skip_current_element()`: skip the current element and all children. 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(_) => {} } } } } /// Parse a document into an event list. Comments, processing instructions /// and DOCTYPE declarations are skipped (mirroring the expat handlers that /// omit them). Any malformed construct yields `E_FAILED`, matching the c_api /// `load_xml` returning `E_FAILED` on a reader with an error. fn parse_events(data: &str) -> Result> { 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; } // b[i] == b'<' 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())); } let name = data[i + 2..j].trim().to_string(); events.push(XmlEvent::End { name }); i = j + 1; } b'!' => { // Comment or DOCTYPE. 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 { // (possibly with an internal subset in [...]) 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'?' => { // processing instruction (e.g. the XML decl). match data[i + 2..].find("?>") { Some(p) => i = i + 2 + p + 2, None => { return Err(Error::Failed("unterminated processing instruction".into())) } } } _ => { // Start element: or . 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; let end_name = name.clone(); events.push(XmlEvent::Start { name, attrs }); events.push(XmlEvent::End { name: end_name }); break; } // Attribute name. 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) } /// Advance `i` past ASCII whitespace. 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; } } /// Delimiter bytes for element/attribute names. fn is_tag_delim(c: u8) -> bool { matches!(c, b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/' | b'=') } /// Decode the five predefined XML entities plus numeric character /// references, mirroring expat's character-data handling. An unrecognized /// `&...;` sequence is preserved verbatim. 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::().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(); // CPP-PARITY: Rational(2,4) -> 1/2, Rational(9,3) -> 3/1. 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(); // num=0 -> 0/1; den<0 -> flip signs: -3/-1 -> 3/1. 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 Oak\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 = "\ 2\ 0\ \ hello\ world\ \ "; 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(); // Text content uses `escape_text` (mirrors `xmlutils.cpp`): `& < >` // are escaped, but `"` is not. 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("31") .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( "01\ a < b & c\ ", ) .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( "\ 9\ xok\ ", ) .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( "11", ) .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("").is_err()); assert!(sp.load_xml("not xml at all").is_err()); // No start element at all. 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("abc") .is_err()); } #[test] fn load_xml_whitespace_round_trip() { let mut sp = SubtitleParams::new(); sp.load_xml( " \n 4\n 1\n \ \n hi\n \n \ \n", ) .unwrap(); assert_eq!(sp.stream_index(), 4); assert!(sp.enabled()); assert_eq!(sp.count(), 1); // CPP-PARITY: whitespace immediately following a start element is // character data at depth 1, so it is included in the text — the same // behavior as the C++ reader. 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)); // NaN 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)); // lenient -> 0/1 assert_eq!(rational_from_string("a/b"), (0, 0)); // NaN assert_eq!(rational_from_string("1/2/3"), (0, 0)); // NaN } #[test] fn escape_helpers() { assert_eq!(escape_text("ad"), "a<b&c>d"); assert_eq!(escape_attribute("a\"b&d"), "a"b<c>&d"); assert_eq!( decode_entities("a<b&c>d"e'f"), "ad\"e'f" ); assert_eq!(decode_entities("AB"), "AB"); assert_eq!(decode_entities("keep &unknown;"), "keep &unknown;"); } // ---- Extended coverage -------------------------------------------------- #[test] fn add_subtitle_nan_rational_round_trip() { let mut sp = SubtitleParams::new(); sp.add_subtitle(5, 0, 1, 1, "t").unwrap(); // Zero denominator -> NaN rational (0/0), stored as-is like the C++ // two-argument Rational constructor. 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"); // duration() is the last entry's out-time, not the maximum. assert_eq!(sp.duration(), (20, 1)); } #[test] fn load_xml_enabled_variants() { let mut sp = SubtitleParams::new(); sp.load_xml("2") .unwrap(); assert!(sp.enabled()); // any nonzero is true (C++ bool conversion) // std::stoi throws on junk -> E_FAILED. assert!(sp .load_xml("abc") .is_err()); } #[test] fn load_xml_subtitle_default_attributes() { let mut sp = SubtitleParams::new(); sp.load_xml( "\ no attrs\ only in\ \ ", ) .unwrap(); assert_eq!(sp.count(), 3); // Missing attributes keep the default-constructed Rational (0/1). assert_eq!(sp.get_subtitle(0).unwrap(), ((0, 1), (0, 1))); assert_eq!(sp.get_subtitle(1).unwrap(), ((3, 1), (0, 1))); // Self-closing subtitle -> empty text. 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( "\n\ \n\ \n\ 6\ x\ ", ) .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( "\ ABc\ ", ) .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() { // Unlike the videoparams reader, this decoder keeps an unrecognized // entity verbatim (documented leniency of decode_entities). let mut sp = SubtitleParams::new(); sp.load_xml( "\ a &bogus; b\ ", ) .unwrap(); assert_eq!(sp.get_subtitle_text(0).unwrap(), "a &bogus; b"); } #[test] fn load_xml_malformed_variants() { let mut sp = SubtitleParams::new(); // Unterminated attribute value. assert!(sp .load_xml("x") .is_err()); // Attribute missing '='. assert!(sp .load_xml("x") .is_err()); // Unquoted attribute value. assert!(sp .load_xml("x") .is_err()); // Unterminated processing instruction / comment / declaration. assert!(sp.load_xml("").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))); // Re-saving after load is byte-identical. assert_eq!(loaded.save_xml().unwrap(), xml); } #[test] fn rational_reduce_matches_oakcore() { // Cross-validate the hand-rolled reduction against the canonical // oakcore-rs port of the C++ Rational. for (n, d) in [ (2, 4), (0, 5), (5, 0), (-3, -1), (1, -2), (7, 3), (i32::MAX, 1), ] { let r = oakcore_rs::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 = oakcore_rs::Rational::from_string(s); assert_eq!( rational_from_string(s), (r.numerator() as i32, r.denominator() as i32), "input {s:?}" ); } } }