Skip to main content

oxihuman_export/
ass_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Advanced SubStation Alpha (ASS/SSA) subtitle export.
6
7/// An ASS dialogue line.
8#[derive(Debug, Clone)]
9pub struct AssDialogue {
10    /// Layer number.
11    pub layer: u32,
12    /// Start time in centiseconds.
13    pub start_cs: u64,
14    /// End time in centiseconds.
15    pub end_cs: u64,
16    /// Style name.
17    pub style: String,
18    /// Speaker name (optional).
19    pub name: String,
20    /// Subtitle text (may include ASS override tags).
21    pub text: String,
22}
23
24/// An ASS style definition.
25#[derive(Debug, Clone)]
26pub struct AssStyle {
27    pub name: String,
28    pub fontname: String,
29    pub fontsize: u32,
30    pub primary_colour: u32,
31}
32
33/// A complete ASS document.
34#[derive(Debug, Clone, Default)]
35pub struct AssDocument {
36    pub styles: Vec<AssStyle>,
37    pub dialogues: Vec<AssDialogue>,
38    pub title: String,
39}
40
41impl AssDocument {
42    /// Add a dialogue entry.
43    pub fn add_dialogue(&mut self, start_cs: u64, end_cs: u64, text: impl Into<String>) {
44        self.dialogues.push(AssDialogue {
45            layer: 0,
46            start_cs,
47            end_cs,
48            style: "Default".to_string(),
49            name: String::new(),
50            text: text.into(),
51        });
52    }
53
54    /// Number of dialogue lines.
55    pub fn dialogue_count(&self) -> usize {
56        self.dialogues.len()
57    }
58}
59
60/// Format centiseconds as ASS timestamp `H:MM:SS.cc`.
61pub fn cs_to_ass_time(cs: u64) -> String {
62    let h = cs / 360_000;
63    let m = (cs % 360_000) / 6_000;
64    let s = (cs % 6_000) / 100;
65    let c = cs % 100;
66    format!("{h}:{m:02}:{s:02}.{c:02}")
67}
68
69/// Render the ASS document header.
70pub fn render_script_info(doc: &AssDocument) -> String {
71    format!(
72        "[Script Info]\nTitle: {}\nScriptType: v4.00+\n\n",
73        doc.title
74    )
75}
76
77/// Render all dialogue lines.
78pub fn render_dialogues(doc: &AssDocument) -> String {
79    let mut out = String::from("[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n");
80    for d in &doc.dialogues {
81        out.push_str(&format!(
82            "Dialogue: {},{},{},{},{},0000,0000,0000,,{}\n",
83            d.layer,
84            cs_to_ass_time(d.start_cs),
85            cs_to_ass_time(d.end_cs),
86            d.style,
87            d.name,
88            d.text,
89        ));
90    }
91    out
92}
93
94/// Render the full ASS document.
95pub fn render_ass(doc: &AssDocument) -> String {
96    render_script_info(doc) + &render_dialogues(doc)
97}
98
99/// Validate that all dialogues have valid timings.
100pub fn validate_ass(doc: &AssDocument) -> bool {
101    doc.dialogues
102        .iter()
103        .all(|d| d.start_cs < d.end_cs && !d.text.is_empty())
104}
105
106/// Maximum dialogue end time.
107pub fn total_duration_cs(doc: &AssDocument) -> u64 {
108    doc.dialogues.iter().map(|d| d.end_cs).max().unwrap_or(0)
109}
110
111/// Default ASS style definition.
112pub fn default_style() -> AssStyle {
113    AssStyle {
114        name: "Default".to_string(),
115        fontname: "Arial".to_string(),
116        fontsize: 20,
117        primary_colour: 0x00FFFFFF,
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn sample_doc() -> AssDocument {
126        let mut d = AssDocument {
127            title: "Test".into(),
128            ..Default::default()
129        };
130        d.add_dialogue(0, 200, "Hello ASS");
131        d.add_dialogue(300, 500, "Second line");
132        d
133    }
134
135    #[test]
136    fn dialogue_count() {
137        assert_eq!(sample_doc().dialogue_count(), 2);
138    }
139
140    #[test]
141    fn cs_to_ass_time_format() {
142        /* 0 cs → 0:00:00.00 */
143        assert_eq!(cs_to_ass_time(0), "0:00:00.00");
144    }
145
146    #[test]
147    fn cs_to_ass_time_nonzero() {
148        /* 360000 cs = 1 hour → 1:00:00.00 */
149        assert_eq!(cs_to_ass_time(360_000), "1:00:00.00");
150    }
151
152    #[test]
153    fn render_script_info_header() {
154        let s = render_script_info(&sample_doc());
155        assert!(s.contains("[Script Info]"));
156    }
157
158    #[test]
159    fn render_dialogues_contains_event() {
160        let s = render_dialogues(&sample_doc());
161        assert!(s.contains("Dialogue:"));
162    }
163
164    #[test]
165    fn render_ass_complete() {
166        let s = render_ass(&sample_doc());
167        assert!(s.contains("[Script Info]"));
168        assert!(s.contains("[Events]"));
169    }
170
171    #[test]
172    fn validate_ok() {
173        assert!(validate_ass(&sample_doc()));
174    }
175
176    #[test]
177    fn validate_bad() {
178        let mut d = AssDocument::default();
179        d.dialogues.push(AssDialogue {
180            layer: 0,
181            start_cs: 500,
182            end_cs: 100,
183            style: "Default".into(),
184            name: String::new(),
185            text: "bad".into(),
186        });
187        assert!(!validate_ass(&d));
188    }
189
190    #[test]
191    fn total_duration_correct() {
192        assert_eq!(total_duration_cs(&sample_doc()), 500);
193    }
194
195    #[test]
196    fn default_style_name() {
197        assert_eq!(default_style().name, "Default");
198    }
199}