#![allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ExportTarget {
Srt,
Vtt,
Ass,
Ttml,
}
impl ExportTarget {
#[must_use]
pub fn extension(&self) -> &str {
match self {
Self::Srt => "srt",
Self::Vtt => "vtt",
Self::Ass => "ass",
Self::Ttml => "ttml",
}
}
#[must_use]
pub fn mime_type(&self) -> &str {
match self {
Self::Srt => "text/plain",
Self::Vtt => "text/vtt",
Self::Ass => "text/x-ssa",
Self::Ttml => "application/ttml+xml",
}
}
}
#[derive(Debug, Clone)]
pub struct ExportEntry {
pub sequence: usize,
pub start_ms: i64,
pub end_ms: i64,
pub text: String,
}
impl ExportEntry {
#[must_use]
pub fn new(sequence: usize, start_ms: i64, end_ms: i64, text: impl Into<String>) -> Self {
Self {
sequence,
start_ms,
end_ms,
text: text.into(),
}
}
}
fn ms_to_srt_ts(ms: i64) -> String {
let sign = if ms < 0 { "-" } else { "" };
let ms = ms.unsigned_abs();
let millis = ms % 1000;
let secs = (ms / 1000) % 60;
let mins = (ms / 60_000) % 60;
let hours = ms / 3_600_000;
format!("{sign}{hours:02}:{mins:02}:{secs:02},{millis:03}")
}
fn ms_to_vtt_ts(ms: i64) -> String {
let sign = if ms < 0 { "-" } else { "" };
let ms = ms.unsigned_abs();
let millis = ms % 1000;
let secs = (ms / 1000) % 60;
let mins = (ms / 60_000) % 60;
let hours = ms / 3_600_000;
format!("{sign}{hours:02}:{mins:02}:{secs:02}.{millis:03}")
}
fn ms_to_ass_ts(ms: i64) -> String {
let ms = ms.max(0) as u64;
let cs = (ms / 10) % 100;
let secs = (ms / 1000) % 60;
let mins = (ms / 60_000) % 60;
let hours = ms / 3_600_000;
format!("{hours}:{mins:02}:{secs:02}.{cs:02}")
}
#[derive(Debug, Default)]
pub struct SubtitleExporter;
impl SubtitleExporter {
#[must_use]
pub fn new() -> Self {
Self
}
#[must_use]
pub fn format_entry(&self, entry: &ExportEntry, target: &ExportTarget) -> String {
match target {
ExportTarget::Srt => self.format_srt(entry),
ExportTarget::Vtt => self.format_vtt(entry),
ExportTarget::Ass => self.format_ass(entry),
ExportTarget::Ttml => self.format_ttml(entry),
}
}
#[must_use]
pub fn file_header(&self, target: &ExportTarget) -> Option<String> {
match target {
ExportTarget::Vtt => Some("WEBVTT\n\n".to_string()),
ExportTarget::Ass => Some(
"[Script Info]\nScriptType: v4.00+\nPlayResX: 384\nPlayResY: 288\n\n\
[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, \
SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, \
StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, \
Alignment, MarginL, MarginR, MarginV, Encoding\n\
Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,\
0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, \
Effect, Text\n"
.to_string(),
),
ExportTarget::Ttml => Some(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<tt xml:lang=\"en\" xmlns=\"http://www.w3.org/ns/ttml\">\n\
<body><div>\n"
.to_string(),
),
ExportTarget::Srt => None,
}
}
#[must_use]
pub fn file_footer(&self, target: &ExportTarget) -> Option<String> {
match target {
ExportTarget::Ttml => Some("</div></body></tt>\n".to_string()),
_ => None,
}
}
#[must_use]
pub fn export_all(&self, entries: &[ExportEntry], target: &ExportTarget) -> String {
let mut out = String::new();
if let Some(header) = self.file_header(target) {
out.push_str(&header);
}
for entry in entries {
out.push_str(&self.format_entry(entry, target));
}
if let Some(footer) = self.file_footer(target) {
out.push_str(&footer);
}
out
}
fn format_srt(&self, entry: &ExportEntry) -> String {
format!(
"{}\n{} --> {}\n{}\n\n",
entry.sequence,
ms_to_srt_ts(entry.start_ms),
ms_to_srt_ts(entry.end_ms),
entry.text,
)
}
fn format_vtt(&self, entry: &ExportEntry) -> String {
format!(
"{} --> {}\n{}\n\n",
ms_to_vtt_ts(entry.start_ms),
ms_to_vtt_ts(entry.end_ms),
entry.text,
)
}
fn format_ass(&self, entry: &ExportEntry) -> String {
format!(
"Dialogue: 0,{},{},Default,,0,0,0,,{}\n",
ms_to_ass_ts(entry.start_ms),
ms_to_ass_ts(entry.end_ms),
entry.text,
)
}
fn format_ttml(&self, entry: &ExportEntry) -> String {
format!(
" <p begin=\"{}\" end=\"{}\">{}</p>\n",
ms_to_vtt_ts(entry.start_ms),
ms_to_vtt_ts(entry.end_ms),
entry.text,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entry() -> ExportEntry {
ExportEntry::new(1, 1_000, 4_000, "Hello, world!")
}
#[test]
fn test_srt_sequence_number() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Srt);
assert!(out.starts_with("1\n"));
}
#[test]
fn test_srt_timestamp_format() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Srt);
assert!(out.contains("00:00:01,000 --> 00:00:04,000"));
}
#[test]
fn test_srt_text_present() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Srt);
assert!(out.contains("Hello, world!"));
}
#[test]
fn test_vtt_timestamp_format() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Vtt);
assert!(out.contains("00:00:01.000 --> 00:00:04.000"));
}
#[test]
fn test_vtt_no_sequence_number() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Vtt);
assert!(!out.starts_with("1\n"));
}
#[test]
fn test_ass_format_contains_dialogue() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Ass);
assert!(out.starts_with("Dialogue:"));
}
#[test]
fn test_ass_timestamp_format() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Ass);
assert!(out.contains("0:00:01.00"));
assert!(out.contains("0:00:04.00"));
}
#[test]
fn test_ttml_format() {
let exporter = SubtitleExporter::new();
let out = exporter.format_entry(&sample_entry(), &ExportTarget::Ttml);
assert!(out.contains("<p begin="));
assert!(out.contains("Hello, world!"));
assert!(out.contains("</p>"));
}
#[test]
fn test_vtt_header() {
let exporter = SubtitleExporter::new();
let hdr = exporter
.file_header(&ExportTarget::Vtt)
.expect("should succeed in test");
assert!(hdr.starts_with("WEBVTT"));
}
#[test]
fn test_srt_no_header() {
let exporter = SubtitleExporter::new();
assert!(exporter.file_header(&ExportTarget::Srt).is_none());
}
#[test]
fn test_ttml_has_header_and_footer() {
let exporter = SubtitleExporter::new();
assert!(exporter.file_header(&ExportTarget::Ttml).is_some());
assert!(exporter.file_footer(&ExportTarget::Ttml).is_some());
}
#[test]
fn test_export_all_srt() {
let entries = vec![
ExportEntry::new(1, 0, 2000, "First"),
ExportEntry::new(2, 3000, 5000, "Second"),
];
let exporter = SubtitleExporter::new();
let out = exporter.export_all(&entries, &ExportTarget::Srt);
assert!(out.contains("1\n"));
assert!(out.contains("2\n"));
assert!(out.contains("First"));
assert!(out.contains("Second"));
}
#[test]
fn test_export_target_extension() {
assert_eq!(ExportTarget::Srt.extension(), "srt");
assert_eq!(ExportTarget::Vtt.extension(), "vtt");
assert_eq!(ExportTarget::Ass.extension(), "ass");
assert_eq!(ExportTarget::Ttml.extension(), "ttml");
}
#[test]
fn test_export_target_mime_type() {
assert_eq!(ExportTarget::Vtt.mime_type(), "text/vtt");
assert_eq!(ExportTarget::Ttml.mime_type(), "application/ttml+xml");
}
#[test]
fn test_ms_to_srt_ts_zero() {
assert_eq!(ms_to_srt_ts(0), "00:00:00,000");
}
#[test]
fn test_ms_to_vtt_ts_hours() {
assert_eq!(ms_to_vtt_ts(3_661_500), "01:01:01.500");
}
}