Skip to main content

oximedia_subtitle/
lib.rs

1//! Subtitle and closed caption rendering for OxiMedia.
2//!
3//! This crate provides comprehensive subtitle rendering support including:
4//!
5//! - **Subtitle Formats**: SubRip (SRT), WebVTT, SSA/ASS, CEA-608/708
6//! - **Text Rendering**: Font loading, glyph caching, Unicode support, bidirectional text
7//! - **Styling**: Font properties, colors, outlines, shadows, positioning
8//! - **Advanced Features**: Burn-in, animations, collision detection, karaoke effects
9//!
10//! # Supported Formats
11//!
12//! | Format | Description | Features |
13//! |--------|-------------|----------|
14//! | SRT | SubRip | Basic text, simple HTML tags |
15//! | WebVTT | Web Video Text Tracks | Positioning, cue settings |
16//! | SSA/ASS | Advanced SubStation Alpha | Full styling, animations, karaoke |
17//! | CEA-608/708 | Closed Captions | Real-time captions, pop-on, roll-up |
18//!
19//! # Example
20//!
21//! ```ignore
22//! use oximedia_subtitle::{SubtitleRenderer, SubtitleStyle, Font};
23//! use oximedia_codec::VideoFrame;
24//!
25//! // Load font
26//! let font_data = std::fs::read("font.ttf")?;
27//! let font = Font::from_bytes(font_data)?;
28//!
29//! // Create renderer with custom style
30//! let style = SubtitleStyle::default()
31//!     .with_font_size(48.0)
32//!     .with_color(255, 255, 255, 255);
33//!
34//! let renderer = SubtitleRenderer::new(font, style);
35//!
36//! // Parse subtitles
37//! let subtitle_data = std::fs::read("movie.srt")?;
38//! let subtitles = parser::srt::parse(&subtitle_data)?;
39//!
40//! // Render onto frame
41//! let mut frame = VideoFrame::new(...);
42//! renderer.render_subtitle(&subtitles[0], &mut frame, timestamp)?;
43//! ```
44
45#![warn(missing_docs)]
46#![allow(
47    clippy::cast_possible_truncation,
48    clippy::cast_precision_loss,
49    clippy::cast_sign_loss,
50    clippy::cast_lossless,
51    clippy::must_use_candidate,
52    clippy::missing_errors_doc,
53    clippy::missing_panics_doc,
54    clippy::unused_self,
55    clippy::doc_markdown,
56    clippy::too_many_lines,
57    clippy::too_many_arguments,
58    dead_code,
59    clippy::similar_names,
60    clippy::many_single_char_names,
61    clippy::unnested_or_patterns,
62    unused_imports,
63    unused_variables,
64    clippy::unnecessary_wraps,
65    clippy::redundant_pattern_matching,
66    clippy::pedantic,
67    clippy::approx_constant,
68    clippy::builtin_type_shadow
69)]
70
71pub mod burn_in;
72pub mod error;
73pub mod font;
74pub mod format_convert;
75pub mod format_converter;
76pub mod overlay;
77pub mod parser;
78pub mod renderer;
79pub mod soft_shadow;
80pub mod style;
81pub mod sub_style;
82pub mod text;
83pub mod timing;
84pub mod timing_adjuster;
85
86// CEA-608/708 encoding and embedding
87pub mod cea;
88pub mod convert;
89
90// New accessibility and language modules
91pub mod accessibility;
92pub mod segmentation;
93pub mod translation;
94
95// Timing, line-breaking, and spell-check utilities
96pub mod line_break;
97pub mod spell_check;
98pub mod timing_adjust;
99
100// New parsing and validation modules
101pub mod cue_parser;
102pub mod subtitle_merge;
103pub mod subtitle_validator;
104
105// New reading-speed, style, and overlap modules
106pub mod overlap_detect;
107pub mod reading_speed;
108pub mod subtitle_style_ext;
109
110// Timestamp-indexed lookup, cue point annotations, and multi-format export
111pub mod cue_point;
112pub mod subtitle_export;
113pub mod subtitle_index;
114
115// Cue timing, position calculation, and subtitle diffing
116pub mod cue_timing;
117pub mod position_calc;
118pub mod subtitle_diff;
119
120// Full-text search, statistics, and sanitization
121pub mod subtitle_sanitize;
122pub mod subtitle_search;
123pub mod subtitle_stats;
124
125// Forced subtitle detection
126pub mod forced_subtitle;
127
128// Automatic subtitle timing alignment between two tracks
129pub mod subtitle_alignment;
130
131// IMSC1/TTML2 enhanced parser with regions, styles, and spans
132pub mod ttml_v2;
133
134// CEA-708 DTVCC decoder
135pub mod cea708;
136
137// ── Wave 6 / Slice F orphan modules ──────────────────────────────────────────
138
139// ASS/SSA style override tag parser (\an, \pos, \clip, etc.)
140pub mod ass_override;
141
142// WebVTT / SRT cue positioning helpers (line, position, size, align)
143pub mod cue_positioning;
144
145// Real-time CEA-608 decoder for live streams (`Eia608Decoder`).
146// `parser::eia608_realtime` is a separate module with `RealtimeCea608Decoder`.
147pub mod eia608_realtime;
148
149// Fallback font chain for missing glyphs (CJK, Arabic, Devanagari)
150pub mod fallback_fonts;
151
152// Glyph atlas packing and GPU-ready texture atlas for subtitle rendering
153pub mod glyph_atlas;
154
155// ASS karaoke timing engine with syllable-level highlight events
156pub mod karaoke_engine;
157
158// Real-time roll-up / paint-on / pop-on live caption display manager
159pub mod live_caption;
160
161// Subtitle cue position (x, y, origin) calculation helpers
162pub mod position;
163
164// Full-text subtitle search utilities (index build, query, highlight)
165pub mod search;
166
167// Sign language overlay region and picture-in-picture positioning
168pub mod sign_language;
169
170// SSA/ASS style cache for O(1) style lookups per dialogue line
171pub mod ssa_style_cache;
172
173// CSS-like subtitle style inheritance resolver
174pub mod style_inherit;
175
176// Chapter-level subtitle segmentation and chapter marker extraction
177pub mod subtitle_chapters;
178
179// Bitmap-based subtitle OCR for PGS / VobSub pattern matching
180pub mod subtitle_ocr;
181
182// DVB Teletext (ETS 300 706) subtitle extraction
183pub mod teletext;
184
185// Higher-level Teletext subtitle struct wrappers and utilities
186pub mod teletext_subtitle;
187
188// WCAG 2.1 AA/AAA contrast ratio validator for subtitle colours
189pub mod wcag_validator;
190
191// Re-export main types
192pub use cea708::{CaptionWindow, Dtvcc708Command, Dtvcc708Decoder, Dtvcc708Packet};
193pub use error::{SubtitleError, SubtitleResult};
194pub use font::{Font, GlyphCache};
195pub use overlay::overlay_subtitle;
196pub use renderer::{DirtyRect, IncrementalSubtitleRenderer, SubtitleRenderer};
197pub use style::{Alignment, Animation, Color, OutlineStyle, Position, ShadowStyle, SubtitleStyle};
198pub use text::{BidiLevel, TextLayout, TextLayoutEngine};
199pub use ttml_v2::{SubtitleEntry, TtmlParser, TtmlRegion, TtmlSpan, TtmlStyle};
200
201/// A single subtitle cue with timing and content.
202#[derive(Clone, Debug)]
203pub struct Subtitle {
204    /// Unique identifier (e.g. sequence number in SRT).
205    pub id: Option<String>,
206    /// Start time in milliseconds.
207    pub start_time: i64,
208    /// End time in milliseconds.
209    pub end_time: i64,
210    /// Subtitle text content.
211    pub text: String,
212    /// Optional styling override.
213    pub style: Option<SubtitleStyle>,
214    /// Position override.
215    pub position: Option<Position>,
216    /// Animation effects.
217    pub animations: Vec<Animation>,
218}
219
220impl Subtitle {
221    /// Create a new subtitle cue.
222    #[must_use]
223    pub fn new(start_time: i64, end_time: i64, text: String) -> Self {
224        Self {
225            id: None,
226            start_time,
227            end_time,
228            text,
229            style: None,
230            position: None,
231            animations: Vec::new(),
232        }
233    }
234
235    /// Create a subtitle cue with an id.
236    #[must_use]
237    pub fn with_id(mut self, id: impl Into<String>) -> Self {
238        self.id = Some(id.into());
239        self
240    }
241
242    /// Check if this subtitle is active at the given timestamp.
243    #[must_use]
244    pub fn is_active(&self, timestamp_ms: i64) -> bool {
245        timestamp_ms >= self.start_time && timestamp_ms < self.end_time
246    }
247
248    /// Get duration in milliseconds.
249    #[must_use]
250    pub fn duration(&self) -> i64 {
251        self.end_time - self.start_time
252    }
253
254    /// Add an animation effect.
255    pub fn with_animation(mut self, animation: Animation) -> Self {
256        self.animations.push(animation);
257        self
258    }
259
260    /// Set position override.
261    #[must_use]
262    pub fn with_position(mut self, position: Position) -> Self {
263        self.position = Some(position);
264        self
265    }
266
267    /// Set style override.
268    #[must_use]
269    pub fn with_style(mut self, style: SubtitleStyle) -> Self {
270        self.style = Some(style);
271        self
272    }
273}
274
275// ============================================================================
276// Simple Parser Structs API
277// ============================================================================
278
279/// High-level SRT parser struct.
280///
281/// # Example
282///
283/// ```ignore
284/// use oximedia_subtitle::SrtParser;
285/// let text = "1\n00:00:01,000 --> 00:00:04,000\nHello!\n\n";
286/// let subs = SrtParser::parse(text).expect("should succeed in test");
287/// ```
288pub struct SrtParser;
289
290impl SrtParser {
291    /// Parse SRT subtitle text and return a vector of subtitles.
292    ///
293    /// # Errors
294    ///
295    /// Returns error if the text is not valid SRT format.
296    pub fn parse(text: &str) -> SubtitleResult<Vec<Subtitle>> {
297        parser::srt::parse_srt(text)
298    }
299}
300
301/// High-level ASS/SSA parser struct.
302pub struct AssParser;
303
304impl AssParser {
305    /// Parse ASS/SSA subtitle text and return a vector of subtitles.
306    ///
307    /// # Errors
308    ///
309    /// Returns error if the text is not valid ASS format.
310    pub fn parse(text: &str) -> SubtitleResult<Vec<Subtitle>> {
311        let file = parser::ssa::parse_ass(text)?;
312        Ok(file.events)
313    }
314}
315
316/// High-level WebVTT parser struct.
317pub struct WebVttParser;
318
319impl WebVttParser {
320    /// Parse WebVTT subtitle text and return a vector of subtitles.
321    ///
322    /// # Errors
323    ///
324    /// Returns error if the text is not valid WebVTT format.
325    pub fn parse(text: &str) -> SubtitleResult<Vec<Subtitle>> {
326        parser::webvtt::parse_webvtt(text)
327    }
328}
329
330#[cfg(test)]
331mod subtitle_api_tests {
332    use super::*;
333
334    const SAMPLE_SRT: &str = "1\n00:00:01,000 --> 00:00:04,000\nHello, world!\n\n2\n00:00:05,000 --> 00:00:08,000\nSecond subtitle.\n\n";
335
336    #[test]
337    fn test_subtitle_id_field() {
338        let sub = Subtitle::new(0, 1000, "test".to_string()).with_id("42");
339        assert_eq!(sub.id, Some("42".to_string()));
340    }
341
342    #[test]
343    fn test_subtitle_new_has_no_id() {
344        let sub = Subtitle::new(0, 1000, "test".to_string());
345        assert!(sub.id.is_none());
346    }
347
348    #[test]
349    fn test_srt_parser_basic() {
350        let subs = SrtParser::parse(SAMPLE_SRT).expect("should succeed in test");
351        assert_eq!(subs.len(), 2);
352        assert_eq!(subs[0].text, "Hello, world!");
353        assert_eq!(subs[0].start_time, 1000);
354        assert_eq!(subs[0].end_time, 4000);
355    }
356
357    #[test]
358    fn test_srt_parser_second_entry() {
359        let subs = SrtParser::parse(SAMPLE_SRT).expect("should succeed in test");
360        assert_eq!(subs[1].text, "Second subtitle.");
361        assert_eq!(subs[1].start_time, 5000);
362        assert_eq!(subs[1].end_time, 8000);
363    }
364
365    #[test]
366    fn test_webvtt_parser_basic() {
367        let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nHello VTT!\n\n";
368        let subs = WebVttParser::parse(vtt).expect("should succeed in test");
369        assert!(!subs.is_empty());
370        assert_eq!(subs[0].text, "Hello VTT!");
371    }
372
373    #[test]
374    fn test_webvtt_parser_timing() {
375        let vtt = "WEBVTT\n\n00:00:05.500 --> 00:00:09.000\nTimed cue.\n\n";
376        let subs = WebVttParser::parse(vtt).expect("should succeed in test");
377        assert_eq!(subs[0].start_time, 5500);
378        assert_eq!(subs[0].end_time, 9000);
379    }
380
381    #[test]
382    fn test_subtitle_is_active() {
383        let sub = Subtitle::new(1000, 4000, "test".to_string());
384        assert!(sub.is_active(2000));
385        assert!(!sub.is_active(500));
386        assert!(!sub.is_active(5000));
387    }
388
389    #[test]
390    fn test_subtitle_duration() {
391        let sub = Subtitle::new(1000, 4000, "test".to_string());
392        assert_eq!(sub.duration(), 3000);
393    }
394
395    #[test]
396    fn test_ass_parser_basic() {
397        let ass = "[Script Info]\nScriptType: v4.00+\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Hello ASS!\n\n";
398        let result = AssParser::parse(ass);
399        assert!(result.is_ok());
400        let subs = result.expect("should succeed in test");
401        assert!(!subs.is_empty());
402    }
403}