#![allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TtmlSpan {
pub text: String,
pub style_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TtmlParagraph {
pub begin_ms: u64,
pub end_ms: u64,
pub spans: Vec<TtmlSpan>,
pub region: Option<String>,
}
impl TtmlParagraph {
pub fn plain_text(&self) -> String {
self.spans
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Debug, Clone, Default)]
pub struct TtmlDocument {
pub paragraphs: Vec<TtmlParagraph>,
pub lang: String,
}
impl TtmlDocument {
pub fn add_paragraph(&mut self, begin_ms: u64, end_ms: u64, text: impl Into<String>) {
self.paragraphs.push(TtmlParagraph {
begin_ms,
end_ms,
spans: vec![TtmlSpan {
text: text.into(),
style_id: None,
}],
region: None,
});
}
pub fn paragraph_count(&self) -> usize {
self.paragraphs.len()
}
}
pub fn ms_to_ttml_time(ms: u64) -> String {
let h = ms / 3_600_000;
let m = (ms % 3_600_000) / 60_000;
let s = (ms % 60_000) / 1_000;
let ms = ms % 1_000;
format!("{h:02}:{m:02}:{s:02}.{ms:03}")
}
pub fn render_ttml(doc: &TtmlDocument) -> String {
let lang = if doc.lang.is_empty() { "en" } else { &doc.lang };
let mut out = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<tt xml:lang="{lang}" xmlns="http://www.w3.org/ns/ttml">
<body>
<div>
"#
);
for p in &doc.paragraphs {
out.push_str(&format!(
" <p begin=\"{}\" end=\"{}\">{}</p>\n",
ms_to_ttml_time(p.begin_ms),
ms_to_ttml_time(p.end_ms),
p.plain_text()
));
}
out.push_str(" </div>\n </body>\n</tt>\n");
out
}
pub fn validate_ttml(doc: &TtmlDocument) -> bool {
doc.paragraphs
.iter()
.all(|p| p.begin_ms < p.end_ms && !p.plain_text().is_empty())
}
pub fn total_duration_ms(doc: &TtmlDocument) -> u64 {
doc.paragraphs.iter().map(|p| p.end_ms).max().unwrap_or(0)
}
pub fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_doc() -> TtmlDocument {
let mut d = TtmlDocument {
lang: "en".into(),
..Default::default()
};
d.add_paragraph(0, 2000, "Hello TTML");
d.add_paragraph(3000, 6000, "Second");
d
}
#[test]
fn paragraph_count() {
assert_eq!(sample_doc().paragraph_count(), 2);
}
#[test]
fn ms_to_ttml_format() {
assert_eq!(ms_to_ttml_time(3_723_456), "01:02:03.456");
}
#[test]
fn render_ttml_starts_with_xml() {
let s = render_ttml(&sample_doc());
assert!(s.starts_with("<?xml"));
}
#[test]
fn render_ttml_contains_tt_tag() {
assert!(render_ttml(&sample_doc()).contains("<tt"));
}
#[test]
fn render_ttml_contains_text() {
assert!(render_ttml(&sample_doc()).contains("Hello TTML"));
}
#[test]
fn validate_ok() {
assert!(validate_ttml(&sample_doc()));
}
#[test]
fn validate_bad_timing() {
let mut d = TtmlDocument::default();
d.add_paragraph(5000, 1000, "bad");
assert!(!validate_ttml(&d));
}
#[test]
fn total_duration() {
assert_eq!(total_duration_ms(&sample_doc()), 6000);
}
#[test]
fn xml_escape_ampersand() {
assert_eq!(xml_escape("a & b"), "a & b");
}
#[test]
fn xml_escape_lt() {
assert_eq!(xml_escape("<tag>"), "<tag>");
}
}