oxihuman_export/
ttml_export.rs1#![allow(dead_code)]
4
5#[derive(Debug, Clone)]
9pub struct TtmlSpan {
10 pub text: String,
11 pub style_id: Option<String>,
12}
13
14#[derive(Debug, Clone)]
16pub struct TtmlParagraph {
17 pub begin_ms: u64,
19 pub end_ms: u64,
21 pub spans: Vec<TtmlSpan>,
22 pub region: Option<String>,
23}
24
25impl TtmlParagraph {
26 pub fn plain_text(&self) -> String {
28 self.spans
29 .iter()
30 .map(|s| s.text.as_str())
31 .collect::<Vec<_>>()
32 .join(" ")
33 }
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct TtmlDocument {
39 pub paragraphs: Vec<TtmlParagraph>,
40 pub lang: String,
41}
42
43impl TtmlDocument {
44 pub fn add_paragraph(&mut self, begin_ms: u64, end_ms: u64, text: impl Into<String>) {
46 self.paragraphs.push(TtmlParagraph {
47 begin_ms,
48 end_ms,
49 spans: vec![TtmlSpan {
50 text: text.into(),
51 style_id: None,
52 }],
53 region: None,
54 });
55 }
56
57 pub fn paragraph_count(&self) -> usize {
59 self.paragraphs.len()
60 }
61}
62
63pub fn ms_to_ttml_time(ms: u64) -> String {
65 let h = ms / 3_600_000;
66 let m = (ms % 3_600_000) / 60_000;
67 let s = (ms % 60_000) / 1_000;
68 let ms = ms % 1_000;
69 format!("{h:02}:{m:02}:{s:02}.{ms:03}")
70}
71
72pub fn render_ttml(doc: &TtmlDocument) -> String {
74 let lang = if doc.lang.is_empty() { "en" } else { &doc.lang };
75 let mut out = format!(
76 r#"<?xml version="1.0" encoding="UTF-8"?>
77<tt xml:lang="{lang}" xmlns="http://www.w3.org/ns/ttml">
78 <body>
79 <div>
80"#
81 );
82 for p in &doc.paragraphs {
83 out.push_str(&format!(
84 " <p begin=\"{}\" end=\"{}\">{}</p>\n",
85 ms_to_ttml_time(p.begin_ms),
86 ms_to_ttml_time(p.end_ms),
87 p.plain_text()
88 ));
89 }
90 out.push_str(" </div>\n </body>\n</tt>\n");
91 out
92}
93
94pub fn validate_ttml(doc: &TtmlDocument) -> bool {
96 doc.paragraphs
97 .iter()
98 .all(|p| p.begin_ms < p.end_ms && !p.plain_text().is_empty())
99}
100
101pub fn total_duration_ms(doc: &TtmlDocument) -> u64 {
103 doc.paragraphs.iter().map(|p| p.end_ms).max().unwrap_or(0)
104}
105
106pub fn xml_escape(s: &str) -> String {
108 s.replace('&', "&")
109 .replace('<', "<")
110 .replace('>', ">")
111 .replace('"', """)
112 .replace('\'', "'")
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn sample_doc() -> TtmlDocument {
120 let mut d = TtmlDocument {
121 lang: "en".into(),
122 ..Default::default()
123 };
124 d.add_paragraph(0, 2000, "Hello TTML");
125 d.add_paragraph(3000, 6000, "Second");
126 d
127 }
128
129 #[test]
130 fn paragraph_count() {
131 assert_eq!(sample_doc().paragraph_count(), 2);
132 }
133
134 #[test]
135 fn ms_to_ttml_format() {
136 assert_eq!(ms_to_ttml_time(3_723_456), "01:02:03.456");
137 }
138
139 #[test]
140 fn render_ttml_starts_with_xml() {
141 let s = render_ttml(&sample_doc());
142 assert!(s.starts_with("<?xml"));
143 }
144
145 #[test]
146 fn render_ttml_contains_tt_tag() {
147 assert!(render_ttml(&sample_doc()).contains("<tt"));
148 }
149
150 #[test]
151 fn render_ttml_contains_text() {
152 assert!(render_ttml(&sample_doc()).contains("Hello TTML"));
153 }
154
155 #[test]
156 fn validate_ok() {
157 assert!(validate_ttml(&sample_doc()));
158 }
159
160 #[test]
161 fn validate_bad_timing() {
162 let mut d = TtmlDocument::default();
163 d.add_paragraph(5000, 1000, "bad");
164 assert!(!validate_ttml(&d));
165 }
166
167 #[test]
168 fn total_duration() {
169 assert_eq!(total_duration_ms(&sample_doc()), 6000);
170 }
171
172 #[test]
173 fn xml_escape_ampersand() {
174 assert_eq!(xml_escape("a & b"), "a & b");
175 }
176
177 #[test]
178 fn xml_escape_lt() {
179 assert_eq!(xml_escape("<tag>"), "<tag>");
180 }
181}