Skip to main content

subx_cli/core/formats/ass/
mod.rs

1//! Advanced SubStation Alpha (ASS/SSA) subtitle format implementation.
2//!
3//! This module provides parsing, serialization, and detection capabilities
4//! for the ASS/SSA subtitle format, including style and color definitions.
5//!
6//! Implementation is split across the following submodules:
7//!
8//! - `parser`: pure parsing from `&str` into [`Subtitle`].
9//! - `serializer`: pure serialization from [`Subtitle`] back to ASS text.
10//! - `time`: timestamp parsing/formatting helpers.
11//! - `tests` (test-only): co-located unit tests for the format.
12//!
13//! `AssStyle` and `Color` style descriptors are kept here in `mod.rs`
14//! because they are small data structures shared across this module.
15//!
16//! # Examples
17//!
18//! ```rust,no_run
19//! use subx_cli::core::formats::{SubtitleFormat, ass::AssFormat};
20//! let ass = AssFormat;
21//! let content = "[Events]\nFormat: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\nDialogue: 0,0:00:01.00,0:00:02.50,Default,,0000,0000,0000,,Hello";
22//! let subtitle = ass.parse(content).unwrap();
23//! ```
24
25use crate::Result;
26use crate::core::formats::{Subtitle, SubtitleFormat};
27
28mod parser;
29mod serializer;
30mod time;
31
32#[cfg(test)]
33mod tests;
34
35/// ASS style definition for subtitle entries.
36#[derive(Debug, Clone)]
37pub struct AssStyle {
38    /// Name identifier for this style
39    pub name: String,
40    /// Font family name to use for rendering
41    pub font_name: String,
42    /// Font size in points
43    pub font_size: u32,
44    /// Primary text color
45    pub primary_color: Color,
46    /// Secondary text color for styling effects
47    pub secondary_color: Color,
48    /// Outline border color
49    pub outline_color: Color,
50    /// Shadow color for text depth effect
51    pub shadow_color: Color,
52    /// Whether text should be rendered in bold
53    pub bold: bool,
54    /// Whether text should be rendered in italic
55    pub italic: bool,
56    /// Whether text should be underlined
57    pub underline: bool,
58    /// Text alignment value (1-9 for numpad positions)
59    pub alignment: i32,
60}
61
62/// ASS color structure for style entries.
63#[derive(Debug, Clone)]
64pub struct Color {
65    /// Red component (0-255)
66    pub r: u8,
67    /// Green component (0-255)
68    pub g: u8,
69    /// Blue component (0-255)
70    pub b: u8,
71}
72
73impl Color {
74    /// Creates a white color (RGB: 255, 255, 255).
75    pub fn white() -> Self {
76        Color {
77            r: 255,
78            g: 255,
79            b: 255,
80        }
81    }
82
83    /// Creates a black color (RGB: 0, 0, 0).
84    pub fn black() -> Self {
85        Color { r: 0, g: 0, b: 0 }
86    }
87
88    /// Creates a red color (RGB: 255, 0, 0).
89    pub fn red() -> Self {
90        Color { r: 255, g: 0, b: 0 }
91    }
92}
93
94/// Subtitle format implementation for ASS/SSA.
95///
96/// The `AssFormat` struct implements parsing, serialization, and detection
97/// for the ASS/SSA subtitle format. The actual logic is delegated to the
98/// `parser`, `serializer`, and `time` submodules.
99pub struct AssFormat;
100
101impl SubtitleFormat for AssFormat {
102    /// Parse ASS/SSA subtitle content into a [`Subtitle`].
103    ///
104    /// # Malformed-input dispositions
105    ///
106    /// Per the `subtitle-parser-hardening` capability matrix, this parser
107    /// classifies malformed inputs as follows:
108    ///
109    /// | Scenario | Disposition |
110    /// | --- | --- |
111    /// | Empty input | return `SubXError::SubtitleFormat` |
112    /// | Missing `[Events]` section | return `SubXError::SubtitleFormat` |
113    /// | UTF-8 BOM prefix on valid content | consumed; parse continues |
114    /// | UTF-8 BOM prefix on invalid content | return `SubXError::SubtitleFormat` |
115    /// | `Format:` line missing `Start`, `End`, or `Text` | return `SubXError::SubtitleFormat` |
116    /// | `Dialogue:` row column count mismatches `Format:` | skip-and-continue (`debug!`) |
117    /// | Negative timestamp on a `Dialogue:` row | skip-and-continue (`debug!`) |
118    /// | Timestamp arithmetic overflow | return `SubXError::SubtitleFormat` |
119    /// | Cue body exceeding `MAX_CUE_BYTES` (1 MiB) | return `SubXError::SubtitleFormat` |
120    fn parse(&self, content: &str) -> Result<Subtitle> {
121        parser::parse(content)
122    }
123
124    fn serialize(&self, subtitle: &Subtitle) -> Result<String> {
125        serializer::serialize(subtitle)
126    }
127
128    fn detect(&self, content: &str) -> bool {
129        content.contains("[Script Info]") || content.contains("Dialogue:")
130    }
131
132    fn format_name(&self) -> &'static str {
133        "ASS"
134    }
135
136    fn file_extensions(&self) -> &'static [&'static str] {
137        &["ass", "ssa"]
138    }
139}