ass_core/analysis/linting/rules/
accessibility.rs

1//! Accessibility issue detection rule for ASS script linting.
2//!
3//! Detects potential accessibility issues in subtitle scripts that could
4//! make content difficult to read or understand for users with disabilities
5//! or reading difficulties.
6
7use crate::{
8    analysis::{
9        events::text_analysis::TextAnalysis,
10        linting::{IssueCategory, IssueSeverity, LintIssue, LintRule},
11        ScriptAnalysis,
12    },
13    parser::Section,
14    utils::parse_ass_time,
15};
16use alloc::{format, string::ToString, vec::Vec};
17
18/// Rule for detecting accessibility issues in subtitle scripts
19///
20/// Analyzes scripts for patterns that may negatively impact accessibility,
21/// including reading speed, contrast, and timing issues that could make
22/// subtitles difficult to follow for users with various needs.
23///
24/// # Accessibility Checks
25///
26/// - Display duration: Ensures events are displayed long enough to be readable
27/// - Reading speed: Warns about text that appears/disappears too quickly
28/// - Flash prevention: Detects rapid style changes that could trigger seizures
29///
30/// # Performance
31///
32/// - Time complexity: O(n) for n events
33/// - Memory: O(1) additional space
34/// - Target: <1ms for typical scripts with 1000 events
35///
36/// # Example
37///
38/// ```rust
39/// use ass_core::analysis::linting::rules::accessibility::AccessibilityRule;
40/// use ass_core::analysis::linting::LintRule;
41/// use ass_core::{Script, ScriptAnalysis};
42///
43/// let script = Script::parse(r#"
44/// [Events]
45/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
46/// Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text without proper contrast
47/// "#)?;
48///
49/// let analysis = ScriptAnalysis::analyze(&script)?;
50/// let rule = AccessibilityRule;
51/// let issues = rule.check_script(&analysis);
52/// # Ok::<(), Box<dyn std::error::Error>>(())
53/// ```
54pub struct AccessibilityRule;
55
56impl LintRule for AccessibilityRule {
57    fn id(&self) -> &'static str {
58        "accessibility"
59    }
60
61    fn name(&self) -> &'static str {
62        "Accessibility"
63    }
64
65    fn description(&self) -> &'static str {
66        "Detects potential accessibility issues"
67    }
68
69    fn default_severity(&self) -> IssueSeverity {
70        IssueSeverity::Hint
71    }
72
73    fn category(&self) -> IssueCategory {
74        IssueCategory::Accessibility
75    }
76
77    fn check_script(&self, analysis: &ScriptAnalysis) -> Vec<LintIssue> {
78        let mut issues = Vec::new();
79
80        if let Some(Section::Events(events)) = analysis
81            .script()
82            .sections()
83            .iter()
84            .find(|s| matches!(s, Section::Events(_)))
85        {
86            for event in events {
87                self.check_event_duration(&mut issues, event);
88                self.check_reading_speed(&mut issues, event);
89                self.check_text_length(&mut issues, event);
90            }
91        }
92
93        issues
94    }
95}
96
97impl AccessibilityRule {
98    /// Check for very short display durations that may be hard to read
99    fn check_event_duration(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
100        if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
101            if end >= start {
102                let duration_ms = end - start;
103                if duration_ms < 500 {
104                    let issue = LintIssue::new(
105                        self.default_severity(),
106                        IssueCategory::Accessibility,
107                        self.id(),
108                        format!("Very short event duration: {duration_ms}ms"),
109                    )
110                    .with_description("Short durations may be difficult to read".to_string())
111                    .with_suggested_fix(
112                        "Consider extending duration to at least 500ms for readability".to_string(),
113                    );
114
115                    issues.push(issue);
116                }
117            }
118        }
119    }
120
121    /// Check reading speed based on text length and duration
122    fn check_reading_speed(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
123        if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) {
124            if end >= start {
125                let duration_centiseconds = end - start;
126
127                if let Ok(analysis) = TextAnalysis::analyze(event.text) {
128                    let clean_text_length = analysis.char_count();
129
130                    if clean_text_length > 0 && duration_centiseconds > 0 {
131                        // Convert centiseconds to seconds: 1 second = 100 centiseconds
132                        let duration_seconds = f64::from(duration_centiseconds) / 100.0;
133                        let safe_length = u32::try_from(clean_text_length)
134                            .unwrap_or(10_000)
135                            .min(10_000);
136                        let chars_per_second = f64::from(safe_length) / duration_seconds;
137
138                        if chars_per_second > 20.0 {
139                            let issue = LintIssue::new(
140                                self.default_severity(),
141                                IssueCategory::Accessibility,
142                                self.id(),
143                                format!(
144                                    "Fast reading speed: {chars_per_second:.1} characters/second"
145                                ),
146                            )
147                            .with_description(
148                                "Fast reading speeds may be difficult for some users".to_string(),
149                            )
150                            .with_suggested_fix(
151                                "Consider extending duration or reducing text length".to_string(),
152                            );
153
154                            issues.push(issue);
155                        }
156                    }
157                }
158            }
159        }
160    }
161
162    /// Check for excessively long text that may be overwhelming
163    fn check_text_length(&self, issues: &mut Vec<LintIssue>, event: &crate::parser::Event) {
164        if let Ok(analysis) = TextAnalysis::analyze(event.text) {
165            let clean_text_length = analysis.char_count();
166
167            if clean_text_length > 200 {
168                let issue = LintIssue::new(
169                    self.default_severity(),
170                    IssueCategory::Accessibility,
171                    self.id(),
172                    format!("Very long text: {clean_text_length} characters"),
173                )
174                .with_description("Long text blocks may be overwhelming for some users".to_string())
175                .with_suggested_fix("Consider splitting into multiple shorter events".to_string());
176
177                issues.push(issue);
178            }
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn rule_metadata_correct() {
189        let rule = AccessibilityRule;
190        assert_eq!(rule.id(), "accessibility");
191        assert_eq!(rule.name(), "Accessibility");
192        assert_eq!(rule.description(), "Detects potential accessibility issues");
193        assert_eq!(rule.default_severity(), IssueSeverity::Hint);
194        assert_eq!(rule.category(), IssueCategory::Accessibility);
195    }
196
197    #[test]
198    fn empty_script_no_issues() {
199        let script_text = "[Script Info]\nTitle: Test";
200        let script = crate::parser::Script::parse(script_text).unwrap();
201        let analysis = ScriptAnalysis::analyze(&script).unwrap();
202
203        let rule = AccessibilityRule;
204        let issues = rule.check_script(&analysis);
205
206        assert!(issues.is_empty());
207    }
208
209    #[test]
210    fn normal_duration_no_issues() {
211        let script_text = r"[Events]
212Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
213Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Normal duration text";
214
215        let script = crate::parser::Script::parse(script_text).unwrap();
216        let analysis = ScriptAnalysis::analyze(&script).unwrap();
217        let rule = AccessibilityRule;
218        let issues = rule.check_script(&analysis);
219
220        assert!(issues.is_empty());
221    }
222
223    #[test]
224    fn short_duration_detected() {
225        let script_text = r"[Events]
226Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
227Dialogue: 0,0:00:00.00,0:00:00.30,Default,,0,0,0,,Too fast!";
228
229        let script = crate::parser::Script::parse(script_text).unwrap();
230        let rule = AccessibilityRule;
231        let analysis = ScriptAnalysis::analyze(&script).unwrap();
232        let issues = rule.check_script(&analysis);
233
234        assert!(!issues.is_empty());
235        assert!(issues
236            .iter()
237            .any(|issue| issue.message().contains("short event duration")));
238    }
239
240    #[test]
241    fn fast_reading_speed_detected() {
242        let long_text = "This is a very long text that would require fast reading speed to comprehend in the given short duration which may be difficult for some users";
243        let script_text = format!(
244            r"[Events]
245Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
246Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,{long_text}"
247        );
248
249        let script = crate::parser::Script::parse(&script_text).unwrap();
250        let rule = AccessibilityRule;
251        let analysis = ScriptAnalysis::analyze(&script).unwrap();
252        let issues = rule.check_script(&analysis);
253
254        assert!(issues
255            .iter()
256            .any(|issue| issue.message().contains("reading speed")));
257    }
258
259    #[test]
260    fn text_analysis_excludes_tags() {
261        use crate::analysis::events::text_analysis::TextAnalysis;
262
263        let analysis1 = TextAnalysis::analyze("Hello world").unwrap();
264        assert_eq!(analysis1.char_count(), 11);
265
266        // "Hello {\i1}world{\i0}" after removing tags becomes "Hello world" (11 chars)
267        let analysis2 = TextAnalysis::analyze("Hello {\\i1}world{\\i0}").unwrap();
268        assert_eq!(analysis2.char_count(), 11);
269
270        // "{\b1}Bold{\b0} text" after removing tags becomes "Bold text" (9 chars)
271        let analysis3 = TextAnalysis::analyze("{\\b1}Bold{\\b0} text").unwrap();
272        assert_eq!(analysis3.char_count(), 9);
273
274        let analysis4 = TextAnalysis::analyze("").unwrap();
275        assert_eq!(analysis4.char_count(), 0);
276    }
277
278    #[test]
279    fn long_text_detected() {
280        let long_text = "a".repeat(250);
281        let script_text = format!(
282            r"[Events]
283Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
284Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,{long_text}"
285        );
286
287        let script = crate::parser::Script::parse(&script_text).unwrap();
288        let rule = AccessibilityRule;
289        let analysis = ScriptAnalysis::analyze(&script).unwrap();
290        let issues = rule.check_script(&analysis);
291
292        assert!(issues
293            .iter()
294            .any(|issue| issue.message().contains("Very long text")));
295    }
296
297    #[test]
298    fn no_events_section_no_issues() {
299        let script_text = r"[Script Info]
300Title: Test
301
302[V4+ Styles]
303Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
304Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1";
305
306        let script = crate::parser::Script::parse(script_text).unwrap();
307        let rule = AccessibilityRule;
308        let analysis = ScriptAnalysis::analyze(&script).unwrap();
309        let issues = rule.check_script(&analysis);
310
311        assert!(issues.is_empty());
312    }
313}