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).
1174 lines
34 KiB
Rust
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("&"),
|
|
'<' => 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<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 < 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());
|
|
// 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<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;");
|
|
}
|
|
|
|
// ---- 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'>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() {
|
|
// 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:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|