Skip to main content

ass_core/analysis/linting/rules/accessibility/
rule.rs

1//! [`AccessibilityRule`] implementation and its accessibility checks.
2//!
3//! Houses the rule type, its [`LintRule`] implementation, and the per-event
4//! duration, reading-speed, and text-length checks it performs.
5
6use crate::{
7    analysis::{
8        events::text_analysis::TextAnalysis,
9        linting::{IssueCategory, IssueSeverity, LintIssue, LintRule},
10        ScriptAnalysis,
11    },
12    parser::Section,
13    utils::parse_ass_time,
14};
15use alloc::{format, string::ToString, vec::Vec};
16
17/// Rule for detecting accessibility issues in subtitle scripts
18///
19/// Analyzes scripts for patterns that may negatively impact accessibility,
20/// including reading speed, contrast, and timing issues that could make
21/// subtitles difficult to follow for users with various needs.
22///
23/// # Accessibility Checks
24///
25/// - Display duration: Ensures events are displayed long enough to be readable
26/// - Reading speed: Warns about text that appears/disappears too quickly
27/// - Flash prevention: Detects rapid style changes that could trigger seizures
28///
29/// # Performance
30///
31/// - Time complexity: O(n) for n events
32/// - Memory: O(1) additional space
33/// - Target: <1ms for typical scripts with 1000 events
34///
35/// # Example
36///
37/// ```rust
38/// use ass_core::analysis::linting::rules::accessibility::AccessibilityRule;
39/// use ass_core::analysis::linting::LintRule;
40/// use ass_core::{Script, ScriptAnalysis};
41///
42/// let script = Script::parse(r#"
43/// [Events]
44/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
45/// Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text without proper contrast
46/// "#)?;
47///
48/// let analysis = ScriptAnalysis::analyze(&script)?;
49/// let rule = AccessibilityRule;
50/// let issues = rule.check_script(&analysis);
51/// # Ok::<(), Box<dyn std::error::Error>>(())
52/// ```
53pub struct AccessibilityRule;
54
55impl LintRule for AccessibilityRule {
56    fn id(&self) -> &'static str {
57        "accessibility"
58    }
59
60    fn name(&self) -> &'static str {
61        "Accessibility"
62    }
63
64    fn description(&self) -> &'static str {
65        "Detects potential accessibility issues"
66    }
67
68    fn default_severity(&self) -> IssueSeverity {
69        IssueSeverity::Hint
70    }
71
72    fn category(&self) -> IssueCategory {
73        IssueCategory::Accessibility
74    }
75
76    fn check_script(&self, analysis: &ScriptAnalysis) -> Vec<LintIssue> {
77        let mut issues = Vec::new();
78
79        if let Some(Section::Events(events)) = analysis
80            .script()
81            .sections()
82            .iter()
83            .find(|s| matches!(s, Section::Events(_)))
84        {
85            for event in events {
86                self.check_event_duration(&mut issues, event);
87                self.check_reading_speed(&mut issues, event);
88                self.check_text_length(&mut issues, event);
89            }
90        }
91
92        issues
93    }
94}
95
96impl AccessibilityRule {
97    /// Check for very short display durations that may be hard to read
98    fn check_event_duration(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
99        if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
100            if end >= start {
101                let duration_ms = end - start;
102                if duration_ms < 500 {
103                    let issue = LintIssue::new(
104                        self.default_severity(),
105                        IssueCategory::Accessibility,
106                        self.id(),
107                        format!("Very short event duration: {duration_ms}ms"),
108                    )
109                    .with_description("Short durations may be difficult to read".to_string())
110                    .with_suggested_fix(
111                        "Consider extending duration to at least 500ms for readability".to_string(),
112                    );
113
114                    issues.push(issue);
115                }
116            }
117        }
118    }
119
120    /// Check reading speed based on text length and duration
121    fn check_reading_speed(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
122        if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
123            if end >= start {
124                let duration_centiseconds = end - start;
125
126                if let Ok(analysis) = TextAnalysis::analyze(event.text) {
127                    let clean_text_length = analysis.char_count();
128
129                    if clean_text_length > 0 && duration_centiseconds > 0 {
130                        // Convert centiseconds to seconds: 1 second = 100 centiseconds
131                        let duration_seconds = f64::from(duration_centiseconds) / 100.0;
132                        let safe_length = u32::try_from(clean_text_length)
133                            .unwrap_or(10_000)
134                            .min(10_000);
135                        let chars_per_second = f64::from(safe_length) / duration_seconds;
136
137                        if chars_per_second > 20.0 {
138                            let issue = LintIssue::new(
139                                self.default_severity(),
140                                IssueCategory::Accessibility,
141                                self.id(),
142                                format!(
143                                    "Fast reading speed: {chars_per_second:.1} characters/second"
144                                ),
145                            )
146                            .with_description(
147                                "Fast reading speeds may be difficult for some users".to_string(),
148                            )
149                            .with_suggested_fix(
150                                "Consider extending duration or reducing text length".to_string(),
151                            );
152
153                            issues.push(issue);
154                        }
155                    }
156                }
157            }
158        }
159    }
160
161    /// Check for excessively long text that may be overwhelming
162    fn check_text_length(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
163        if let Ok(analysis) = TextAnalysis::analyze(event.text) {
164            let clean_text_length = analysis.char_count();
165
166            if clean_text_length > 200 {
167                let issue = LintIssue::new(
168                    self.default_severity(),
169                    IssueCategory::Accessibility,
170                    self.id(),
171                    format!("Very long text: {clean_text_length} characters"),
172                )
173                .with_description("Long text blocks may be overwhelming for some users".to_string())
174                .with_suggested_fix("Consider splitting into multiple shorter events".to_string());
175
176                issues.push(issue);
177            }
178        }
179    }
180}