Files
oak-editor/crates/oakcommon/src/subtitleparams.rs
T
Mike-Solar ab1a2e9c7b refactor: drop internal bridge/ffi layers; exporter family lands
Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are
gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/
oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain
Rust, CHandle marshalling shrinks to the oakengine boundary, and tests
call the Rust APIs directly (pure C-ABI wrapper tests removed where
the domain layer already covers the behavior).

exporter.h family implemented: oakengine_export_render (CLI contract),
oakengine_export_render_with_params (was a stub), last_error and
progress callback; synchronous path reuses task_create_export +
start_sync. Fixes on the way: oaktask video ticket self-deadlock,
audio params dropped on the export path, codec encoder AAC slicing and
H.264 time base. Real-mp4 tests cover both entry points, progress and
the illegal-argument matrix.

Also: oakstorage session maps null project handles to None (version-
info path), configstore test double literal 3.14 -> 3.15 (clippy PI
lint), oakaudio output callback scratch buffer + env-aware P1 test,
cli media round-trip test uses a generated 16-frame clip (no more
minute-long debug runs).
2026-08-16 00:33:45 +08:00

1174 lines
34 KiB
Rust

// 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 <http://www.gnu.org/licenses/>.
//! 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<Subtitle>,
}
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<String> {
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<String> {
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 `<subtitleparams>` 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<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_str("\"");
out.push_str(" out=\"");
out.push_str(&escape_attribute(&rational_to_string(sub.out_time)));
out.push_str("\"");
out.push('>');
out.push_str(&escape_text(&sub.text));
out.push_str("</subtitle>");
}
out.push_str("</subtitles>");
out.push_str("</subtitleparams>");
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::<i32>().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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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<XmlEvent>,
pos: usize,
}
impl XmlReader {
fn parse(data: &str) -> Result<XmlReader> {
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<XmlStart> {
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<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;
}
// b[i] == b'<'
if i + 1 >= n {
return Err(Error::Failed(
"unterminated '<' in subtitleparams xml".into(),
));
}
match b[i + 1] {
b'/' => {
// </name>
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 {
// <!DOCTYPE ... > (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: <name attr="value" ...> or <name .../>.
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::<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();
// 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 = "<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();
// Text content uses `escape_text` (mirrors `xmlutils.cpp`): `& < >`
// are escaped, but `"` is not.
assert!(xml.contains(">a &lt; b &amp; \"c\" &gt; d<"));
assert!(!xml.contains("&quot;"));
}
#[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 &lt; b &amp; 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());
// 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("<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);
// 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("a<b&c>d"), "a&lt;b&amp;c&gt;d");
assert_eq!(escape_attribute("a\"b<c>&d"), "a&quot;b&lt;c&gt;&amp;d");
assert_eq!(
decode_entities("a&lt;b&amp;c&gt;d&quot;e&apos;f"),
"a<b&c>d\"e'f"
);
assert_eq!(decode_entities("&#65;&#x42;"), "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("<subtitleparams><enabled>2</enabled><subtitles/></subtitleparams>")
.unwrap();
assert!(sp.enabled()); // any nonzero is true (C++ bool conversion)
// std::stoi throws on junk -> E_FAILED.
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);
// 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(
"<?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'>&#65;&#x42;c</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() {
// Unlike the videoparams reader, this decoder keeps an unrecognized
// entity verbatim (documented leniency of decode_entities).
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();
// Unterminated attribute value.
assert!(sp
.load_xml("<subtitleparams><subtitles><subtitle in=\"1/1>x</subtitle></subtitles></subtitleparams>")
.is_err());
// Attribute missing '='.
assert!(sp
.load_xml("<subtitleparams><subtitles><subtitle in \"1/1\">x</subtitle></subtitles></subtitleparams>")
.is_err());
// Unquoted attribute value.
assert!(sp
.load_xml("<subtitleparams><subtitles><subtitle in=1/1>x</subtitle></subtitles></subtitleparams>")
.is_err());
// Unterminated processing instruction / comment / declaration.
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)));
// 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:?}"
);
}
}
}