Skip to main content

oxihuman_export/
ttml_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! TTML (Timed Text Markup Language) subtitle export.
6
7/// A TTML span (inline styled text).
8#[derive(Debug, Clone)]
9pub struct TtmlSpan {
10    pub text: String,
11    pub style_id: Option<String>,
12}
13
14/// A TTML paragraph (block-level subtitle).
15#[derive(Debug, Clone)]
16pub struct TtmlParagraph {
17    /// Start time in milliseconds.
18    pub begin_ms: u64,
19    /// End time in milliseconds.
20    pub end_ms: u64,
21    pub spans: Vec<TtmlSpan>,
22    pub region: Option<String>,
23}
24
25impl TtmlParagraph {
26    /// Plain text content of this paragraph.
27    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/// A TTML document.
37#[derive(Debug, Clone, Default)]
38pub struct TtmlDocument {
39    pub paragraphs: Vec<TtmlParagraph>,
40    pub lang: String,
41}
42
43impl TtmlDocument {
44    /// Add a simple paragraph.
45    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    /// Paragraph count.
58    pub fn paragraph_count(&self) -> usize {
59        self.paragraphs.len()
60    }
61}
62
63/// Format milliseconds as TTML time expression `HH:MM:SS.mmm`.
64pub 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
72/// Build a minimal TTML XML string.
73pub 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
94/// Validate that all paragraphs have non-zero duration and text.
95pub 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
101/// Total subtitle duration.
102pub fn total_duration_ms(doc: &TtmlDocument) -> u64 {
103    doc.paragraphs.iter().map(|p| p.end_ms).max().unwrap_or(0)
104}
105
106/// Escape XML special characters in a string.
107pub fn xml_escape(s: &str) -> String {
108    s.replace('&', "&amp;")
109        .replace('<', "&lt;")
110        .replace('>', "&gt;")
111        .replace('"', "&quot;")
112        .replace('\'', "&apos;")
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 &amp; b");
175    }
176
177    #[test]
178    fn xml_escape_lt() {
179        assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
180    }
181}