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}