use crate::cea::{
Cea608Channel, Cea608Color, Cea608Encoder, Cea608Mode, Cea708Color, Cea708Encoder,
Cea708FontStyle, Cea708Opacity, Cea708PenAttributes, Cea708PenColor, Cea708PenSize,
Cea708ServiceNumber, Cea708WindowAttributes, Cea708WindowId, FrameRate, FrameRateAdapter,
};
use crate::style::{Alignment, Color};
use crate::{Subtitle, SubtitleError, SubtitleResult};
#[derive(Clone, Debug)]
pub struct CeaConversionOptions {
pub frame_rate: FrameRate,
pub cea608_channel: Cea608Channel,
pub cea608_mode: Cea608Mode,
pub cea708_service: Cea708ServiceNumber,
pub max_chars_per_line: usize,
pub max_lines: usize,
pub auto_line_break: bool,
pub strip_formatting: bool,
}
impl Default for CeaConversionOptions {
fn default() -> Self {
Self {
frame_rate: FrameRate::ntsc(),
cea608_channel: Cea608Channel::CC1,
cea608_mode: Cea608Mode::PopOn,
cea708_service: Cea708ServiceNumber::new(1).expect("hardcoded value is valid"),
max_chars_per_line: 32,
max_lines: 4,
auto_line_break: true,
strip_formatting: true,
}
}
}
#[derive(Clone, Debug)]
pub struct Cea608Output {
pub start_time: i64,
pub end_time: i64,
pub data: Vec<(u8, u8)>,
}
#[derive(Clone, Debug)]
pub struct Cea708Output {
pub start_time: i64,
pub end_time: i64,
pub service_block: Vec<u8>,
pub cdp: Vec<u8>,
}
pub fn srt_to_cea608(
subtitles: &[Subtitle],
options: &CeaConversionOptions,
) -> SubtitleResult<Vec<Cea608Output>> {
let mut output = Vec::new();
let mut encoder = Cea608Encoder::new(options.cea608_channel);
encoder.set_mode(options.cea608_mode);
for subtitle in subtitles {
encoder.clear_buffer();
let text = if options.strip_formatting {
strip_html_tags(&subtitle.text)
} else {
subtitle.text.clone()
};
let lines = split_into_lines(&text, options.max_chars_per_line, options.max_lines);
for (row, line) in lines.iter().enumerate() {
let row_num = 15 - (lines.len() - 1) as u8 + row as u8;
encoder.set_position(row_num, 0);
encoder.add_text(line)?;
}
if matches!(options.cea608_mode, Cea608Mode::PopOn) {
encoder.end_caption();
}
let data = encoder.take_output();
output.push(Cea608Output {
start_time: subtitle.start_time,
end_time: subtitle.end_time,
data,
});
}
Ok(output)
}
pub fn webvtt_to_cea608(
subtitles: &[Subtitle],
options: &CeaConversionOptions,
) -> SubtitleResult<Vec<Cea608Output>> {
let mut output = Vec::new();
let mut encoder = Cea608Encoder::new(options.cea608_channel);
encoder.set_mode(options.cea608_mode);
for subtitle in subtitles {
encoder.clear_buffer();
let text = strip_webvtt_tags(&subtitle.text);
let row = if let Some(pos) = &subtitle.position {
map_position_to_row(pos.y)
} else {
15 };
let lines = split_into_lines(&text, options.max_chars_per_line, options.max_lines);
for (line_offset, line) in lines.iter().enumerate() {
encoder.set_position(row - line_offset as u8, 0);
encoder.add_text(line)?;
}
if matches!(options.cea608_mode, Cea608Mode::PopOn) {
encoder.end_caption();
}
output.push(Cea608Output {
start_time: subtitle.start_time,
end_time: subtitle.end_time,
data: encoder.take_output(),
});
}
Ok(output)
}
pub fn ssa_to_cea608(
subtitles: &[Subtitle],
options: &CeaConversionOptions,
) -> SubtitleResult<Vec<Cea608Output>> {
let mut output = Vec::new();
let mut encoder = Cea608Encoder::new(options.cea608_channel);
encoder.set_mode(options.cea608_mode);
for subtitle in subtitles {
encoder.clear_buffer();
let (text, color) = parse_ssa_style(&subtitle.text);
if let Some(cea_color) = map_color_to_cea608(color) {
encoder.set_style(cea_color, false, false);
}
let lines = split_into_lines(&text, options.max_chars_per_line, options.max_lines);
for (row, line) in lines.iter().enumerate() {
let row_num = 15 - (lines.len() - 1) as u8 + row as u8;
encoder.set_position(row_num, 0);
encoder.add_text(line)?;
}
if matches!(options.cea608_mode, Cea608Mode::PopOn) {
encoder.end_caption();
}
output.push(Cea608Output {
start_time: subtitle.start_time,
end_time: subtitle.end_time,
data: encoder.take_output(),
});
}
Ok(output)
}
pub fn srt_to_cea708(
subtitles: &[Subtitle],
options: &CeaConversionOptions,
) -> SubtitleResult<Vec<Cea708Output>> {
let mut output = Vec::new();
let mut encoder = Cea708Encoder::new(options.cea708_service);
let window_id = Cea708WindowId::new(0)?;
let window_attrs = Cea708WindowAttributes::default();
encoder.define_window(window_id, window_attrs)?;
encoder.set_current_window(window_id);
for subtitle in subtitles {
encoder.clear_windows(0x01);
let text = if options.strip_formatting {
strip_html_tags(&subtitle.text)
} else {
subtitle.text.clone()
};
encoder.add_text(&text)?;
encoder.display_windows(0x01);
let service_block = encoder.build_service_block();
let framerate_code = crate::cea::get_framerate_code(options.frame_rate.as_float());
let cdp = encoder.build_cdp(framerate_code, None);
output.push(Cea708Output {
start_time: subtitle.start_time,
end_time: subtitle.end_time,
service_block,
cdp,
});
}
Ok(output)
}
pub fn webvtt_to_cea708(
subtitles: &[Subtitle],
options: &CeaConversionOptions,
) -> SubtitleResult<Vec<Cea708Output>> {
let mut output = Vec::new();
let mut encoder = Cea708Encoder::new(options.cea708_service);
let window_id = Cea708WindowId::new(0)?;
let mut window_attrs = Cea708WindowAttributes::default();
for subtitle in subtitles {
if let Some(pos) = &subtitle.position {
window_attrs.anchor.vertical = (pos.y * 100.0) as u8;
window_attrs.anchor.horizontal = (pos.x * 100.0) as u8;
}
encoder.define_window(window_id, window_attrs)?;
encoder.set_current_window(window_id);
encoder.clear_windows(0x01);
let text = strip_webvtt_tags(&subtitle.text);
encoder.add_text(&text)?;
encoder.display_windows(0x01);
let service_block = encoder.build_service_block();
let framerate_code = crate::cea::get_framerate_code(options.frame_rate.as_float());
let cdp = encoder.build_cdp(framerate_code, None);
output.push(Cea708Output {
start_time: subtitle.start_time,
end_time: subtitle.end_time,
service_block,
cdp,
});
}
Ok(output)
}
pub fn ssa_to_cea708(
subtitles: &[Subtitle],
options: &CeaConversionOptions,
) -> SubtitleResult<Vec<Cea708Output>> {
let mut output = Vec::new();
let mut encoder = Cea708Encoder::new(options.cea708_service);
let window_id = Cea708WindowId::new(0)?;
let window_attrs = Cea708WindowAttributes::default();
encoder.define_window(window_id, window_attrs)?;
for subtitle in subtitles {
encoder.set_current_window(window_id);
encoder.clear_windows(0x01);
let (text, color) = parse_ssa_style(&subtitle.text);
if let Some(cea_color) = map_color_to_cea708(color) {
let pen_color = Cea708PenColor {
foreground: cea_color,
..Default::default()
};
encoder.set_pen_color(pen_color);
}
encoder.add_text(&text)?;
encoder.display_windows(0x01);
let service_block = encoder.build_service_block();
let framerate_code = crate::cea::get_framerate_code(options.frame_rate.as_float());
let cdp = encoder.build_cdp(framerate_code, None);
output.push(Cea708Output {
start_time: subtitle.start_time,
end_time: subtitle.end_time,
service_block,
cdp,
});
}
Ok(output)
}
pub struct SubtitleConverter {
options: CeaConversionOptions,
}
impl SubtitleConverter {
#[must_use]
pub const fn new(options: CeaConversionOptions) -> Self {
Self { options }
}
pub fn to_cea608(&self, subtitles: &[Subtitle]) -> SubtitleResult<Vec<Cea608Output>> {
srt_to_cea608(subtitles, &self.options)
}
pub fn to_cea708(&self, subtitles: &[Subtitle]) -> SubtitleResult<Vec<Cea708Output>> {
srt_to_cea708(subtitles, &self.options)
}
#[must_use]
pub const fn options(&self) -> &CeaConversionOptions {
&self.options
}
}
fn strip_html_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut in_tag = false;
for c in text.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ => {
if !in_tag {
result.push(c);
}
}
}
}
result
}
fn strip_webvtt_tags(text: &str) -> String {
let mut result = text
.replace("<v ", "")
.replace("</v>", "")
.replace("<c>", "")
.replace("</c>", "");
if let Some(idx) = result.find('<') {
if let Some(end_idx) = result[idx..].find('>') {
result = result[..idx].to_string() + &result[idx + end_idx + 1..];
}
}
result
}
fn parse_ssa_style(text: &str) -> (String, Option<Color>) {
let mut clean_text = String::with_capacity(text.len());
let mut color = None;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut tag = String::new();
while let Some(&next_c) = chars.peek() {
if next_c == '}' {
chars.next();
break;
}
tag.push(chars.next().expect("invariant: peek confirmed Some"));
}
if tag.starts_with("\\c&H") {
if let Some(hex) = tag.strip_prefix("\\c&H") {
if let Some(hex_color) = hex.strip_suffix('&') {
if let Ok(bgr) = u32::from_str_radix(hex_color, 16) {
let b = ((bgr >> 16) & 0xFF) as u8;
let g = ((bgr >> 8) & 0xFF) as u8;
let r = (bgr & 0xFF) as u8;
color = Some(Color::rgb(r, g, b));
}
}
}
}
} else if c == '\\' && chars.peek() == Some(&'N') {
chars.next();
clean_text.push('\n');
} else if c == '\\' && chars.peek() == Some(&'n') {
chars.next();
clean_text.push(' ');
} else {
clean_text.push(c);
}
}
(clean_text, color)
}
fn map_color_to_cea608(color: Option<Color>) -> Option<Cea608Color> {
color.map(|c| {
if c.r > 200 && c.g > 200 && c.b > 200 {
Cea608Color::White
} else if c.r > 200 && c.g < 100 && c.b < 100 {
Cea608Color::Red
} else if c.r < 100 && c.g > 200 && c.b < 100 {
Cea608Color::Green
} else if c.r < 100 && c.g < 100 && c.b > 200 {
Cea608Color::Blue
} else if c.r > 200 && c.g > 200 && c.b < 100 {
Cea608Color::Yellow
} else if c.r > 200 && c.g < 100 && c.b > 200 {
Cea608Color::Magenta
} else if c.r < 100 && c.g > 200 && c.b > 200 {
Cea608Color::Cyan
} else {
Cea608Color::White
}
})
}
fn map_color_to_cea708(color: Option<Color>) -> Option<Cea708Color> {
color.map(|c| {
let r = (c.r >> 6) & 0x03;
let g = (c.g >> 6) & 0x03;
let b = (c.b >> 6) & 0x03;
Cea708Color::new(r, g, b)
})
}
fn map_position_to_row(y: f32) -> u8 {
let row = (y * 14.0 + 1.0).round() as u8;
row.clamp(1, 15)
}
fn split_into_lines(text: &str, max_chars: usize, max_lines: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= max_chars {
current_line.push(' ');
current_line.push_str(word);
} else {
if lines.len() < max_lines {
lines.push(current_line);
current_line = word.to_string();
} else {
break;
}
}
}
if !current_line.is_empty() && lines.len() < max_lines {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub struct TimingAdjuster {
min_duration_ms: i64,
max_duration_ms: i64,
gap_ms: i64,
}
impl TimingAdjuster {
#[must_use]
pub const fn new(min_duration_ms: i64, max_duration_ms: i64, gap_ms: i64) -> Self {
Self {
min_duration_ms,
max_duration_ms,
gap_ms,
}
}
#[must_use]
pub const fn default_adjuster() -> Self {
Self::new(1000, 7000, 100)
}
pub fn adjust(&self, subtitle: &mut Subtitle) {
let duration = subtitle.end_time - subtitle.start_time;
if duration < self.min_duration_ms {
subtitle.end_time = subtitle.start_time + self.min_duration_ms;
}
if duration > self.max_duration_ms {
subtitle.end_time = subtitle.start_time + self.max_duration_ms;
}
}
pub fn adjust_list(&self, subtitles: &mut [Subtitle]) {
for i in 0..subtitles.len() {
self.adjust(&mut subtitles[i]);
if i + 1 < subtitles.len() {
let end_time = subtitles[i].end_time;
let next_start = subtitles[i + 1].start_time;
if next_start < end_time + self.gap_ms {
subtitles[i].end_time = (next_start - self.gap_ms).max(subtitles[i].start_time);
}
}
}
}
}
pub struct FormatConverter {
pub preserve_styles: bool,
pub preserve_timing: bool,
}
impl FormatConverter {
#[must_use]
pub const fn new(preserve_styles: bool, preserve_timing: bool) -> Self {
Self {
preserve_styles,
preserve_timing,
}
}
pub fn srt_to_webvtt(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::webvtt::write(subtitles)
}
pub fn srt_to_ass(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::ssa::write(subtitles)
}
pub fn srt_to_ttml(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::ttml::write(subtitles)
}
pub fn webvtt_to_srt(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::srt::write(subtitles)
}
pub fn webvtt_to_ass(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::ssa::write(subtitles)
}
pub fn webvtt_to_ttml(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::ttml::write(subtitles)
}
pub fn ass_to_srt(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::srt::write(subtitles)
}
pub fn ass_to_webvtt(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::webvtt::write(subtitles)
}
pub fn ass_to_ttml(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::ttml::write(subtitles)
}
pub fn ttml_to_srt(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::srt::write(subtitles)
}
pub fn ttml_to_webvtt(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::webvtt::write(subtitles)
}
pub fn ttml_to_ass(&self, subtitles: &[Subtitle]) -> SubtitleResult<String> {
crate::parser::ssa::write(subtitles)
}
}
impl Default for FormatConverter {
fn default() -> Self {
Self::new(true, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_html_tags() {
let text = "<b>Hello</b> <i>World</i>";
assert_eq!(strip_html_tags(text), "Hello World");
}
#[test]
fn test_split_lines() {
let text = "This is a long line that should be split into multiple lines";
let lines = split_into_lines(text, 20, 4);
assert!(lines.len() > 1);
assert!(lines.iter().all(|l| l.len() <= 20));
}
#[test]
fn test_color_mapping() {
let red = Color::rgb(255, 0, 0);
assert_eq!(map_color_to_cea608(Some(red)), Some(Cea608Color::Red));
let white = Color::rgb(255, 255, 255);
assert_eq!(map_color_to_cea608(Some(white)), Some(Cea608Color::White));
}
#[test]
fn test_position_mapping() {
assert_eq!(map_position_to_row(0.0), 1);
assert_eq!(map_position_to_row(1.0), 15);
assert_eq!(map_position_to_row(0.5), 8);
}
#[test]
fn test_timing_adjuster() {
let mut sub = Subtitle::new(0, 500, "Test".to_string());
let adjuster = TimingAdjuster::default_adjuster();
adjuster.adjust(&mut sub);
assert_eq!(sub.end_time - sub.start_time, 1000); }
#[test]
fn test_format_converter() {
let converter = FormatConverter::default();
let subtitles = vec![Subtitle::new(1000, 2000, "Test".to_string())];
let webvtt = converter
.srt_to_webvtt(&subtitles)
.expect("should succeed in test");
assert!(webvtt.contains("WEBVTT"));
let ass = converter
.srt_to_ass(&subtitles)
.expect("should succeed in test");
assert!(ass.contains("[Script Info]"));
let ttml = converter
.srt_to_ttml(&subtitles)
.expect("should succeed in test");
assert!(ttml.contains("<tt"));
}
}